Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace

This commit is contained in:
SleepWalker
2019-12-07 21:02:00 +02:00
parent d8d2df0702
commit f9d3bb4e20
404 changed files with 758 additions and 742 deletions

View File

@@ -0,0 +1,125 @@
import React from 'react';
import classNames from 'classnames';
import { omit } from 'app/functions';
import styles from './panel.scss';
import icons from './icons.scss';
export function Panel(props: {
title?: string;
icon?: string;
children: React.ReactNode;
}) {
const { title: titleText, icon: iconType } = props;
let icon: React.ReactElement | undefined;
let title: React.ReactElement | undefined;
if (iconType) {
icon = (
<button className={styles.headerControl}>
<span className={icons[iconType]} />
</button>
);
}
if (titleText) {
title = (
<PanelHeader>
{icon}
{titleText}
</PanelHeader>
);
}
return (
<div className={styles.panel}>
{title}
{props.children}
</div>
);
}
export function PanelHeader(props: { children: React.ReactNode }) {
return (
<div className={styles.header} {...props}>
{props.children}
</div>
);
}
export function PanelBody(props: { children: React.ReactNode }) {
return (
<div className={styles.body} {...props}>
{props.children}
</div>
);
}
export function PanelFooter(props: { children: React.ReactNode }) {
return (
<div className={styles.footer} {...props}>
{props.children}
</div>
);
}
export class PanelBodyHeader extends React.Component<
{
type?: 'default' | 'error';
onClose?: () => void;
children: React.ReactNode;
},
{
isClosed: boolean;
}
> {
state: {
isClosed: boolean;
} = {
isClosed: false,
};
render() {
const { type = 'default', children } = this.props;
let close;
if (type === 'error') {
close = <span className={styles.close} onClick={this.onClose} />;
}
const className = classNames(styles[`${type}BodyHeader`], {
[styles.isClosed]: this.state.isClosed,
});
const extraProps = omit(this.props, ['type', 'onClose']);
return (
<div className={className} {...extraProps}>
{close}
{children}
</div>
);
}
onClose = (event: React.MouseEvent) => {
event.preventDefault();
const { onClose } = this.props;
this.setState({ isClosed: true });
if (onClose) {
onClose();
}
};
}
export function PanelIcon({ icon }: { icon: string }) {
return (
<div className={styles.panelIcon}>
<span className={icons[icon]} />
</div>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { FormattedRelativeTime } from 'react-intl';
import { selectUnit } from '@formatjs/intl-utils';
function RelativeTime({ timestamp }: { timestamp: number }) {
const { unit, value }: { unit: any; value: number } = selectUnit(timestamp);
return (
<FormattedRelativeTime
value={value}
unit={unit}
numeric="auto"
updateIntervalInSeconds={
['seconds', 'minute', 'hour'].includes(unit) ? 1 : undefined
}
/>
);
}
export default RelativeTime;

View File

@@ -0,0 +1,5 @@
{
"criticalErrorHappened": "There was a critical error due to which the application can not continue its normal operation.",
"reloadPageOrContactUs": "Please reload this page and try again. If problem occurs again, please report it to the developers by sending email to",
"alsoYouCanInteractWithBackground": "You can also play around with the background it's interactable ;)"
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { IntlProvider } from 'app/components/i18n';
import logger from 'app/services/logger';
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
import styles from './styles.scss';
import BoxesField from './BoxesField';
import messages from './BSoD.intl.json';
interface State {
lastEventId?: string | void;
}
// TODO: probably it is better to render this view from the App view
// to remove dependencies from store and IntlProvider
class BSoD extends React.Component<{}, State> {
state: State = {};
componentDidMount() {
// poll for event id
const timer = setInterval(() => {
if (!logger.getLastEventId()) {
return;
}
clearInterval(timer);
this.setState({
lastEventId: logger.getLastEventId(),
});
}, 500);
}
render() {
const { lastEventId } = this.state;
let emailUrl = 'mailto:support@ely.by';
if (lastEventId) {
emailUrl += `?subject=Bug report for #${lastEventId}`;
}
return (
<IntlProvider>
<div className={styles.body}>
<canvas
className={styles.canvas}
ref={(el: HTMLCanvasElement | null) => el && new BoxesField(el)}
/>
<div className={styles.wrapper}>
<div className={styles.title}>
<Message {...appInfo.appName} />
</div>
<div className={styles.lineWithMargin}>
<Message {...messages.criticalErrorHappened} />
</div>
<div className={styles.line}>
<Message {...messages.reloadPageOrContactUs} />
</div>
<a href={emailUrl} className={styles.support}>
support@ely.by
</a>
<div className={styles.easterEgg}>
<Message {...messages.alsoYouCanInteractWithBackground} />
</div>
</div>
</div>
</IntlProvider>
);
}
}
export default BSoD;

View File

@@ -0,0 +1,100 @@
export default class Box {
constructor({ size, startX, startY, startRotate, color, shadowColor }) {
this.color = color;
this.shadowColor = shadowColor;
this.halfSize = 0;
this.setSize(size);
this.x = startX;
this.y = startY;
this.angle = startRotate;
this.shadowLength = 2000; // TODO: should be calculated
}
get size() {
return this._initialSize;
}
get dots() {
const full = (Math.PI * 2) / 4;
const p1 = {
x: this.x + this.halfSize * Math.sin(this.angle),
y: this.y + this.halfSize * Math.cos(this.angle),
};
const p2 = {
x: this.x + this.halfSize * Math.sin(this.angle + full),
y: this.y + this.halfSize * Math.cos(this.angle + full),
};
const p3 = {
x: this.x + this.halfSize * Math.sin(this.angle + full * 2),
y: this.y + this.halfSize * Math.cos(this.angle + full * 2),
};
const p4 = {
x: this.x + this.halfSize * Math.sin(this.angle + full * 3),
y: this.y + this.halfSize * Math.cos(this.angle + full * 3),
};
return { p1, p2, p3, p4 };
}
rotate() {
const speed = (60 - this.halfSize) / 20;
this.angle += speed * 0.002;
this.x += speed;
this.y += speed;
}
draw(ctx) {
const { dots } = this;
ctx.beginPath();
ctx.moveTo(dots.p1.x, dots.p1.y);
ctx.lineTo(dots.p2.x, dots.p2.y);
ctx.lineTo(dots.p3.x, dots.p3.y);
ctx.lineTo(dots.p4.x, dots.p4.y);
ctx.fillStyle = this.color;
ctx.fill();
}
drawShadow(ctx, light) {
const { dots } = this;
const angles = [];
const points = [];
for (const i in dots) {
if (!dots.hasOwnProperty(i)) {
continue;
}
const dot = dots[i];
const angle = Math.atan2(light.y - dot.y, light.x - dot.x);
const endX = dot.x + this.shadowLength * Math.sin(-angle - Math.PI / 2);
const endY = dot.y + this.shadowLength * Math.cos(-angle - Math.PI / 2);
angles.push(angle);
points.push({
endX,
endY,
startX: dot.x,
startY: dot.y,
});
}
for (let i = points.length - 1; i >= 0; i--) {
const n = i === 3 ? 0 : i + 1;
ctx.beginPath();
ctx.moveTo(points[i].startX, points[i].startY);
ctx.lineTo(points[n].startX, points[n].startY);
ctx.lineTo(points[n].endX, points[n].endY);
ctx.lineTo(points[i].endX, points[i].endY);
ctx.fillStyle = this.shadowColor;
ctx.fill();
}
}
setSize(size) {
this._initialSize = size;
this.halfSize = Math.floor(size / 2);
}
}

View File

@@ -0,0 +1,158 @@
import Box from './Box';
/**
* Основано на http://codepen.io/mladen___/pen/gbvqBo
*/
export default class BoxesField {
/**
* @param {HTMLCanvasElement} elem - canvas DOM node
* @param {object} params
*/
constructor(
elem,
params = {
countBoxes: 14,
boxMinSize: 20,
boxMaxSize: 75,
backgroundColor: '#233d49',
lightColor: '#28555b',
shadowColor: '#274451',
boxColors: [
'#207e5c',
'#5b9aa9',
'#e66c69',
'#6b5b8c',
'#8b5d79',
'#dd8650',
],
},
) {
this.elem = elem;
const ctx = elem.getContext('2d');
if (!ctx) {
throw new Error('Can not get canvas 2d context');
}
this.ctx = ctx;
this.params = params;
this.light = {
x: 160,
y: 200,
};
this.resize();
this.drawLoop();
this.bindWindowListeners();
/**
* @type {Box[]}
*/
this.boxes = [];
while (this.boxes.length < this.params.countBoxes) {
this.boxes.push(
new Box({
size: Math.floor(
Math.random() * (this.params.boxMaxSize - this.params.boxMinSize) +
this.params.boxMinSize,
),
startX: Math.floor(Math.random() * elem.width + 1),
startY: Math.floor(Math.random() * elem.height + 1),
startRotate: Math.random() * Math.PI,
color: this.getRandomColor(),
shadowColor: this.params.shadowColor,
}),
);
}
}
resize() {
const { width, height } = this.elem.getBoundingClientRect();
this.elem.width = width;
this.elem.height = height;
}
drawLight(light) {
const greaterSize =
window.screen.width > window.screen.height
? window.screen.width
: window.screen.height;
// еее, теорема пифагора и описывание окружности вокруг квадрата, не зря в универ ходил!!!
const lightRadius = greaterSize * Math.sqrt(2);
this.ctx.beginPath();
this.ctx.arc(light.x, light.y, lightRadius, 0, 2 * Math.PI);
const gradient = this.ctx.createRadialGradient(
light.x,
light.y,
0,
light.x,
light.y,
lightRadius,
);
gradient.addColorStop(0, this.params.lightColor);
gradient.addColorStop(1, this.params.backgroundColor);
this.ctx.fillStyle = gradient;
this.ctx.fill();
}
drawLoop() {
this.ctx.clearRect(0, 0, this.elem.width, this.elem.height);
this.drawLight(this.light);
for (const i in this.boxes) {
if (!this.boxes.hasOwnProperty(i)) {
continue;
}
const box = this.boxes[i];
box.rotate();
box.drawShadow(this.ctx, this.light);
}
for (const i in this.boxes) {
if (!this.boxes.hasOwnProperty(i)) {
continue;
}
const box = this.boxes[i];
box.draw(this.ctx);
// Если квадратик вылетел за пределы экрана
if (box.y - box.halfSize > this.elem.height) {
box.y -= this.elem.height + 100;
this.updateBox(box);
}
if (box.x - box.halfSize > this.elem.width) {
box.x -= this.elem.width + 100;
this.updateBox(box);
}
}
requestAnimationFrame(this.drawLoop.bind(this));
}
bindWindowListeners() {
window.addEventListener('resize', this.resize.bind(this));
window.addEventListener('mousemove', event => {
this.light.x = event.clientX;
this.light.y = event.clientY;
});
}
/**
* @param {Box} box
*/
updateBox(box) {
box.color = this.getRandomColor();
}
getRandomColor() {
return this.params.boxColors[
Math.floor(Math.random() * this.params.boxColors.length)
];
}
}

View File

@@ -0,0 +1,49 @@
import expect from 'app/test/unexpected';
import sinon from 'sinon';
import BsodMiddleware from 'app/components/ui/bsod/BsodMiddleware';
describe('BsodMiddleware', () => {
[500, 503, 555].forEach(code =>
it(`should dispatch for ${code}`, () => {
const resp = {
originalResponse: { status: code },
};
const dispatchBsod = sinon.spy();
const logger = { warn: sinon.spy() };
const middleware = new BsodMiddleware(dispatchBsod, logger as any);
return expect(middleware.catch(resp), 'to be rejected with', resp).then(
() => {
expect(dispatchBsod, 'was called');
expect(logger.warn, 'to have a call satisfying', [
'Unexpected response (BSoD)',
{ resp },
]);
},
);
}),
);
[200, 404].forEach(code =>
it(`should not dispatch for ${code}`, () => {
const resp = {
originalResponse: { status: code },
};
const dispatchBsod = sinon.spy();
const logger = { warn: sinon.spy() };
const middleware = new BsodMiddleware(dispatchBsod, logger as any);
return expect(middleware.catch(resp), 'to be rejected with', resp).then(
() => {
expect(dispatchBsod, 'was not called');
expect(logger.warn, 'was not called');
},
);
}),
);
});

View File

@@ -0,0 +1,49 @@
import { InternalServerError } from 'app/services/request';
import { Resp, Middleware } from 'app/services/request';
import defaultLogger from 'app/services/logger';
type Logger = typeof defaultLogger;
const ABORT_ERR = 20;
class BsodMiddleware implements Middleware {
dispatchBsod: () => any;
logger: Logger;
constructor(dispatchBsod: () => any, logger: Logger = defaultLogger) {
this.dispatchBsod = dispatchBsod;
this.logger = logger;
}
async catch<T extends Resp<any>>(
resp?: T | InternalServerError | Error,
): Promise<T> {
const { originalResponse }: { originalResponse?: Resp<any> } = (resp ||
{}) as InternalServerError;
if (
resp &&
((resp instanceof InternalServerError &&
(resp.error as any).code !== ABORT_ERR) ||
(originalResponse && /5\d\d/.test(originalResponse.status)))
) {
this.dispatchBsod();
const { message: errorMessage } = resp as { [key: string]: any };
if (!errorMessage || !/NetworkError/.test(errorMessage)) {
let message = 'Unexpected response (BSoD)';
if (errorMessage) {
message = `BSoD: ${errorMessage}`;
}
this.logger.warn(message, { resp });
}
}
return Promise.reject(resp);
}
}
export default BsodMiddleware;

View File

@@ -0,0 +1,10 @@
export const BSOD = 'BSOD';
/**
* @returns {object}
*/
export function bsod() {
return {
type: BSOD,
};
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { bsod } from './actions';
import BSoD from 'app/components/ui/bsod/BSoD';
let injectedStore;
let onBsod;
export default function dispatchBsod(store = injectedStore) {
store.dispatch(bsod());
onBsod && onBsod();
ReactDOM.render(<BSoD />, document.getElementById('app'));
}
export function inject(store, stopLoading) {
injectedStore = store;
onBsod = stopLoading;
}

View File

@@ -0,0 +1,12 @@
import request from 'app/services/request';
import logger from 'app/services/logger';
import dispatchBsod, { inject } from './dispatchBsod';
import BsodMiddleware from './BsodMiddleware';
export default function factory(store, stopLoading) {
inject(store, stopLoading);
// do bsod for 500/404 errors
request.addMiddleware(new BsodMiddleware(dispatchBsod, logger));
}

View File

@@ -0,0 +1,9 @@
import { BSOD } from './actions';
export default function(state = false, { type }) {
if (type === BSOD) {
return true;
}
return state;
}

View File

@@ -0,0 +1,57 @@
@import '~app/components/ui/colors.scss';
$font-family-monospaced: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Roboto Mono',
monospace;
.body {
height: 100%;
background-color: $dark_blue;
color: #fff;
text-align: center;
font-family: $font-family-monospaced;
box-sizing: border-box;
}
.canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.wrapper {
position: relative;
margin: 85px auto 0;
max-width: 500px;
padding: 0 20px;
}
.title {
font-size: 26px;
margin-bottom: 13px;
}
.line {
margin: 0 auto;
font-size: 16px;
color: #ebe8e1;
}
.lineWithMargin {
composes: line;
margin-bottom: 20px;
}
.support {
font-size: 18px;
color: #fff;
margin: 3px 0 44px;
display: block;
}
.easterEgg {
font-size: 14px;
color: #ebe8e1;
}

View File

@@ -0,0 +1,20 @@
.horizontalGroup {
display: flex;
}
.item {
// TODO: in some cases we do not need overflow hidden
// probably, it is better to create a separate class for children, that will
// enable overflow hidden and ellipsis
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
$borderConfig: 1px solid rgba(#fff, 0.15);
border-left: $borderConfig;
&:last-child {
border-right: $borderConfig;
}
}

View File

@@ -0,0 +1,98 @@
@import './colors.scss';
@import './fonts.scss';
@mixin button-theme($themeName, $backgroundColor) {
.#{$themeName} {
composes: button;
background-color: $backgroundColor;
&:hover {
background-color: lighter($backgroundColor);
}
&:active {
background-color: darker($backgroundColor);
}
}
}
.button {
display: inline-block;
box-sizing: border-box;
height: 50px;
padding: 0 15px;
border: none;
border-radius: 0;
font-family: $font-family-title;
color: $defaultButtonTextColor;
font-size: 18px;
line-height: 50px;
text-decoration: none;
cursor: pointer;
transition: 0.25s;
&:hover {
color: $defaultButtonTextColor;
}
&:focus {
outline: none;
}
}
.smallButton {
composes: button;
height: 30px;
padding: 0 15px;
font-size: 14px;
line-height: 30px;
}
.white {
composes: button;
background-color: #fff;
color: #444;
&:hover {
color: #262626;
background-color: $whiteButtonLight;
}
&:active {
background-color: $whiteButtonDark;
}
}
@include button-theme('black', $black);
@include button-theme('blue', $blue);
@include button-theme('green', $green);
@include button-theme('orange', $orange);
@include button-theme('darkBlue', $dark_blue);
@include button-theme('lightViolet', $light_violet);
@include button-theme('violet', $violet);
@include button-theme('red', $red);
.block {
display: block;
width: 100%;
}
.loading {
background: url('./form/images/loader_button.gif') #95a5a6 center center !important;
cursor: default;
color: #fff;
transition: 0.25s;
outline: none;
pointer-events: none;
}
.black.disabled {
background: #95a5a6;
cursor: default;
}

View File

@@ -0,0 +1,85 @@
import React, { Component } from 'react';
import { Motion, spring } from 'react-motion';
import MeasureHeight from 'app/components/MeasureHeight';
import styles from './collapse.scss';
type Props = {
isOpened?: boolean;
children: React.ReactNode;
onRest: () => void;
};
export default class Collapse extends Component<
Props,
{
height: number;
wasInitialized: boolean;
}
> {
state = {
height: 0,
wasInitialized: false,
};
static defaultProps = {
onRest: () => {},
};
componentWillReceiveProps(nextProps: Props) {
if (
this.props.isOpened !== nextProps.isOpened &&
!this.state.wasInitialized
) {
this.setState({
wasInitialized: true,
});
}
}
render() {
const { isOpened, children, onRest } = this.props;
const { height, wasInitialized } = this.state;
return (
<div
className={styles.overflow}
style={wasInitialized ? {} : { height: 0 }}
>
<MeasureHeight
state={this.shouldMeasureHeight()}
onMeasure={this.onUpdateHeight}
>
<Motion
style={{
top: wasInitialized ? spring(isOpened ? 0 : -height) : -height,
}}
onRest={onRest}
>
{({ top }) => (
<div
className={styles.content}
style={{
marginTop: top,
visibility: wasInitialized ? 'inherit' : 'hidden',
}}
>
{children}
</div>
)}
</Motion>
</MeasureHeight>
</div>
);
}
onUpdateHeight = (height: number) => {
this.setState({
height,
});
};
shouldMeasureHeight = () => {
return [this.props.isOpened, this.state.wasInitialized].join('');
};
}

View File

@@ -0,0 +1,8 @@
@import '~app/components/ui/colors.scss';
.overflow {
overflow: hidden;
}
.content {
}

View File

@@ -0,0 +1 @@
export { default } from './Collapse';

View File

@@ -0,0 +1,52 @@
$green: #207e5c;
$blue: #5b9aa9;
$red: #e66c69;
$violet: #6b5b8c;
$dark_blue: #28555b;
$light_violet: #8b5d79;
$orange: #dd8650;
$white: #ebe8e1;
$black: #232323;
$defaultButtonTextColor: #fff;
$whiteButtonLight: #f5f5f5;
$whiteButtonDark: #f5f5f5; // TODO: найти оптимальный цвет для прожатого состояния
@function darker($color) {
$elyColorsMap: (
$green: #1a6449,
$blue: #568297,
$red: #e15457,
$violet: #66437a,
$dark_blue: #233d49,
$light_violet: #864567,
$orange: #d86e3e,
$black: #1c1c1c,
);
@if (map_has_key($elyColorsMap, $color)) {
@return map-get($elyColorsMap, $color);
} @else {
@return darken($color, 4%);
}
}
@function lighter($color) {
$elyColorsMap: (
$green: #379070,
$blue: #71a6b2,
$red: #fc7872,
$violet: #816795,
$dark_blue: #3e6164,
$light_violet: #a16982,
$orange: #f39259,
$black: #2e2e2e,
);
@if (map_has_key($elyColorsMap, $color)) {
@return map-get($elyColorsMap, $color);
} @else {
@return lighten($color, 3%);
}
}

View File

@@ -0,0 +1,4 @@
$font-family-title: 'Roboto Condensed', Arial, sans-serif;
$font-family-base: 'Roboto', Arial, sans-serif;
$font-weight-bold: 500;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import classNames from 'classnames';
import buttons from 'app/components/ui/buttons.scss';
import { COLOR_GREEN } from 'app/components/ui';
import { MessageDescriptor } from 'react-intl';
import { Color } from 'app/components/ui';
import FormComponent from './FormComponent';
export default class Button extends FormComponent<
{
// TODO: drop MessageDescriptor support. It should be React.ReactNode only
label: string | MessageDescriptor | React.ReactElement;
block?: boolean;
small?: boolean;
loading?: boolean;
className?: string;
color?: Color;
disabled?: boolean;
component?: string | React.ComponentType<any>;
} & React.ButtonHTMLAttributes<HTMLButtonElement>
> {
render() {
const {
color = COLOR_GREEN,
block,
small,
disabled,
className,
loading,
label,
component: ComponentProp = 'button',
...restProps
} = this.props;
return (
<ComponentProp
className={classNames(
buttons[color],
{
[buttons.loading]: loading,
[buttons.block]: block,
[buttons.smallButton]: small,
[buttons.disabled]: disabled,
},
className,
)}
disabled={disabled}
{...restProps}
>
{typeof label === 'object' && React.isValidElement(label)
? label
: this.formatMessage(label)}
</ComponentProp>
);
}
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import classNames from 'classnames';
import { CaptchaID } from 'app/services/captcha';
import { Skin } from 'app/components/ui';
import captcha from 'app/services/captcha';
import logger from 'app/services/logger';
import { ComponentLoader } from 'app/components/ui/loader';
import styles from './form.scss';
import FormInputComponent from './FormInputComponent';
export default class Captcha extends FormInputComponent<
{
delay: number;
skin: Skin;
},
{
code: string;
}
> {
elRef = React.createRef<HTMLDivElement>();
captchaId: CaptchaID;
static defaultProps = {
skin: 'dark',
delay: 0,
};
componentDidMount() {
setTimeout(() => {
const { current: el } = this.elRef;
el &&
captcha
.render(el, {
skin: this.props.skin,
onSetCode: this.setCode,
})
.then(captchaId => {
this.captchaId = captchaId;
})
.catch(error => {
logger.error('Failed rendering captcha', {
error,
});
});
}, this.props.delay);
}
render() {
const { skin } = this.props;
return (
<div className={styles.captchaContainer}>
<div className={styles.captchaLoader}>
<ComponentLoader />
</div>
<div
ref={this.elRef}
className={classNames(styles.captcha, styles[`${skin}Captcha`])}
/>
{this.renderError()}
</div>
);
}
reset() {
captcha.reset(this.captchaId);
}
getValue() {
return this.state && this.state.code;
}
onFormInvalid() {
this.reset();
}
setCode = (code: string) => this.setState({ code });
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { MessageDescriptor } from 'react-intl';
import classNames from 'classnames';
import { SKIN_DARK, COLOR_GREEN, Color, Skin } from 'app/components/ui';
import { omit } from 'app/functions';
import styles from './form.scss';
import FormInputComponent from './FormInputComponent';
export default class Checkbox extends FormInputComponent<{
color: Color;
skin: Skin;
label: string | MessageDescriptor;
}> {
static defaultProps = {
color: COLOR_GREEN,
skin: SKIN_DARK,
};
elRef = React.createRef<HTMLInputElement>();
render() {
const { color, skin } = this.props;
let { label } = this.props;
label = this.formatMessage(label);
const props = omit(this.props, ['color', 'skin', 'label']);
return (
<div
className={classNames(
styles[`${color}MarkableRow`],
styles[`${skin}MarkableRow`],
)}
>
<label className={styles.markableContainer}>
<input
ref={this.elRef}
className={styles.markableInput}
type="checkbox"
{...props}
/>
<div className={styles.checkbox} />
{label}
</label>
{this.renderError()}
</div>
);
}
getValue() {
const { current: el } = this.elRef;
return el && el.checked ? 1 : 0;
}
focus() {
const { current: el } = this.elRef;
el && el.focus();
}
}

View File

@@ -0,0 +1,154 @@
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { omit } from 'app/functions';
import { colors, COLOR_GREEN } from 'app/components/ui';
import styles from './dropdown.scss';
import FormInputComponent from './FormInputComponent';
export default class Dropdown extends FormInputComponent {
static displayName = 'Dropdown';
static propTypes = {
label: PropTypes.oneOfType([
PropTypes.shape({
id: PropTypes.string,
}),
PropTypes.string,
]).isRequired,
items: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
id: PropTypes.string,
}),
]),
).isRequired,
block: PropTypes.bool,
color: PropTypes.oneOf(colors),
};
static defaultProps = {
color: COLOR_GREEN,
};
state = {
isActive: false,
activeItem: null,
};
componentDidMount() {
// listen to capturing phase to ensure, that our event handler will be
// called before all other
document.addEventListener('click', this.onBodyClick, true);
}
componentWillUnmount() {
document.removeEventListener('click', this.onBodyClick);
}
render() {
const { color, block, items } = this.props;
const { isActive } = this.state;
const activeItem = this.getActiveItem();
const label = this.formatMessage(activeItem.label);
const props = omit(this.props, Object.keys(Dropdown.propTypes));
return (
<div>
<div
className={classNames(styles[color], {
[styles.block]: block,
[styles.opened]: isActive,
})}
{...props}
onClick={this.onToggle}
>
<span className={styles.label}>{label}</span>
<span className={styles.toggleIcon} />
<div className={styles.menu}>
{Object.entries(items).map(([value, label]) => (
<div
className={styles.menuItem}
key={value}
onClick={this.onSelectItem({ value, label })}
>
{label}
</div>
))}
</div>
</div>
{this.renderError()}
</div>
);
}
toggle() {
this.setState({
isActive: !this.state.isActive,
});
}
onSelectItem(item) {
return event => {
event.preventDefault();
this.setState({
activeItem: item,
});
};
}
getActiveItem() {
const { items } = this.props;
let { activeItem } = /** @type {any} */ (this.state);
if (!activeItem) {
activeItem = {
label: this.props.label,
value: '',
};
if (!activeItem.label) {
const [[value, label]] = Object.entries(items);
activeItem = {
label,
value,
};
}
}
return activeItem;
}
getValue() {
return this.getActiveItem().value;
}
onToggle = event => {
event.preventDefault();
this.toggle();
};
onBodyClick = event => {
if (this.state.isActive) {
const el = ReactDOM.findDOMNode(this);
if (!el.contains(event.target) && el !== event.taget) {
event.preventDefault();
event.stopPropagation();
this.toggle();
}
}
};
}

View File

@@ -0,0 +1,167 @@
import React from 'react';
import classNames from 'classnames';
import logger from 'app/services/logger';
import FormModel from './FormModel';
import styles from './form.scss';
interface Props {
id: string;
isLoading: boolean;
form?: FormModel;
onSubmit: (form: FormModel | FormData) => void | Promise<void>;
onInvalid: (errors: { [errorKey: string]: string }) => void;
children: React.ReactNode;
}
interface State {
isTouched: boolean;
isLoading: boolean;
}
type InputElement = HTMLInputElement | HTMLTextAreaElement;
export default class Form extends React.Component<Props, State> {
static defaultProps = {
id: 'default',
isLoading: false,
onSubmit() {},
onInvalid() {},
};
state = {
isTouched: false,
isLoading: this.props.isLoading || false,
};
formEl: HTMLFormElement | null;
mounted = false;
componentDidMount() {
if (this.props.form) {
this.props.form.addLoadingListener(this.onLoading);
}
this.mounted = true;
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.id !== this.props.id) {
this.setState({
isTouched: false,
});
}
if (
typeof nextProps.isLoading !== 'undefined' &&
nextProps.isLoading !== this.state.isLoading
) {
this.setState({
isLoading: nextProps.isLoading,
});
}
const nextForm = nextProps.form;
if (nextForm && this.props.form && nextForm !== this.props.form) {
this.props.form.removeLoadingListener(this.onLoading);
nextForm.addLoadingListener(this.onLoading);
}
}
componentWillUnmount() {
if (this.props.form) {
this.props.form.removeLoadingListener(this.onLoading);
}
this.mounted = false;
}
render() {
const { isLoading } = this.state;
return (
<form
className={classNames(styles.form, {
[styles.isFormLoading]: isLoading,
[styles.formTouched]: this.state.isTouched,
})}
onSubmit={this.onFormSubmit}
ref={(el: HTMLFormElement | null) => (this.formEl = el)}
noValidate
>
{this.props.children}
</form>
);
}
submit() {
if (!this.state.isTouched) {
this.setState({
isTouched: true,
});
}
const form = this.formEl;
if (!form) {
return;
}
if (form.checkValidity()) {
const result = this.props.onSubmit(
this.props.form ? this.props.form : new FormData(form),
);
if (result && result.then) {
this.setState({ isLoading: true });
result
.catch((errors: { [key: string]: string }) => {
this.setErrors(errors);
})
.finally(() => this.mounted && this.setState({ isLoading: false }));
}
} else {
const invalidEls: NodeListOf<InputElement> = form.querySelectorAll(
':invalid',
);
const errors = {};
invalidEls[0].focus(); // focus on first error
Array.from(invalidEls).reduce((acc, el: InputElement) => {
if (!el.name) {
logger.warn('Found an element without name', { el });
return acc;
}
let errorMessage = el.validationMessage;
if (el.validity.valueMissing) {
errorMessage = `error.${el.name}_required`;
} else if (el.validity.typeMismatch) {
errorMessage = `error.${el.name}_invalid`;
}
acc[el.name] = errorMessage;
return acc;
}, errors);
this.setErrors(errors);
}
}
setErrors(errors: { [key: string]: string }) {
this.props.form && this.props.form.setErrors(errors);
this.props.onInvalid(errors);
}
onFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
this.submit();
};
onLoading = (isLoading: boolean) => this.setState({ isLoading });
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { MessageDescriptor } from 'react-intl';
import i18n from 'app/services/i18n';
class FormComponent<P, S = {}> extends React.Component<P, S> {
/**
* Formats message resolving intl translations
*
* @param {string|object} message - message string, or intl message descriptor with an `id` field
*
* @returns {string}
*/
formatMessage(message: string | MessageDescriptor): string {
if (!message) {
throw new Error('A message is required');
}
if (typeof message === 'string') {
return message;
}
if (!message.id) {
throw new Error(`Invalid message format: ${JSON.stringify(message)}`);
}
return i18n.getIntl().formatMessage(message);
}
/**
* Focuses this field
*/
focus() {}
/**
* A hook, that called, when the form was submitted with invalid data
* This is useful for the cases, when some field needs to be refreshed e.g. captcha
*/
onFormInvalid() {}
}
export default FormComponent;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import errorsDict from 'app/services/errorsDict';
import styles from './form.scss';
export default function FormError({
error,
}: {
error?:
| string
| React.ReactNode
| {
type: string;
payload: { [key: string]: any };
};
}) {
return error ? (
<div className={styles.fieldError}>{errorsDict.resolve(error)}</div>
) : null;
}
FormError.propTypes = {
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string.isRequired,
payload: PropTypes.object.isRequired,
}),
]),
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { MessageDescriptor } from 'react-intl';
import FormComponent from './FormComponent';
import FormError from './FormError';
type Error = string | MessageDescriptor;
export default class FormInputComponent<P, S = {}> extends FormComponent<
P & {
error?: Error;
},
S & {
error?: Error;
}
> {
componentWillReceiveProps() {
if (this.state && this.state.error) {
Reflect.deleteProperty(this.state, 'error');
this.setState(this.state);
}
}
renderError() {
const error = (this.state && this.state.error) || this.props.error;
return <FormError error={error} />;
}
setError(error: Error) {
// @ts-ignore
this.setState({ error });
}
}

View File

@@ -0,0 +1,213 @@
import FormInputComponent from './FormInputComponent';
type LoadingListener = (isLoading: boolean) => void;
type ValidationError =
| string
| {
type: string;
payload?: { [key: string]: any };
};
export default class FormModel {
fields = {};
errors: {
[fieldId: string]: ValidationError;
} = {};
handlers: LoadingListener[] = [];
renderErrors: boolean;
_isLoading: boolean;
/**
* @param {object} options
* @param {bool} [options.renderErrors=true] - whether the bound filed should
* render their errors
*/
constructor(options: { renderErrors?: boolean } = {}) {
this.renderErrors = options.renderErrors !== false;
}
/**
* Connects form with React's component
*
* Usage:
* <input {...this.form.bindField('foo')} type="text" />
*
* @param {string} name - the name of field
*
* @returns {object} - ref and name props for component
*/
bindField(name: string) {
this.fields[name] = {};
const props: { [key: string]: any } = {
name,
ref: (el: FormInputComponent<any> | null) => {
if (el) {
if (!(el instanceof FormInputComponent)) {
throw new Error('Expected FormInputComponent component');
}
this.fields[name] = el;
} else {
delete this.fields[name];
}
},
};
if (this.renderErrors && this.getError(name)) {
props.error = this.getError(name);
}
return props;
}
/**
* Focuses field
*
* @param {string} fieldId - an id of field to focus
*/
focus(fieldId: string) {
if (!this.fields[fieldId]) {
throw new Error(
`Can not focus. The field with an id ${fieldId} does not exists`,
);
}
this.fields[fieldId].focus();
}
/**
* Get a value of field
*
* @param {string} fieldId - an id of field to get value of
*
* @returns {string}
*/
value(fieldId: string) {
const field = this.fields[fieldId];
if (!field) {
throw new Error(
`Can not get value. The field with an id ${fieldId} does not exists`,
);
}
if (!field.getValue) {
return ''; // the field was not initialized through ref yet
}
return field.getValue();
}
/**
* Add errors to form fields
*
* errorType may be string or object {type: string, payload: object}, where
* payload is additional data for errorType
*
* @param {object} errors - object maping {fieldId: errorType}
*/
setErrors(errors: { [key: string]: ValidationError }) {
if (typeof errors !== 'object' || errors === null) {
throw new Error('Errors must be an object');
}
const oldErrors = this.errors;
this.errors = errors;
Object.keys(this.fields).forEach(fieldId => {
if (this.renderErrors) {
if (oldErrors[fieldId] || errors[fieldId]) {
this.fields[fieldId].setError(errors[fieldId] || null);
}
}
if (this.hasErrors()) {
this.fields[fieldId].onFormInvalid();
}
});
}
getFirstError(): ValidationError | null {
const [error] = Object.values(this.errors);
return error || null;
}
/**
* Get error by id
*
* @param {string} fieldId - an id of field to get error for
*
* @returns {string|object|null}
*/
getError(fieldId: string) {
return this.errors[fieldId] || null;
}
/**
* @returns {bool}
*/
hasErrors() {
return Object.keys(this.errors).length > 0;
}
/**
* Convert form into key-value object representation
*
* @returns {object}
*/
serialize(): { [key: string]: any } {
return Object.keys(this.fields).reduce((acc, fieldId) => {
const field = this.fields[fieldId];
if (field) {
acc[fieldId] = field.getValue();
} else {
console.warn('Can not serialize %s field. Because it is null', fieldId);
}
return acc;
}, {});
}
/**
* Bind handler to listen for form loading state change
*
* @param {Function} fn
*/
addLoadingListener(fn: LoadingListener) {
this.removeLoadingListener(fn);
this.handlers.push(fn);
}
/**
* Remove form loading state handler
*
* @param {Function} fn
*/
removeLoadingListener(fn: LoadingListener) {
this.handlers = this.handlers.filter(handler => handler !== fn);
}
/**
* Switch form in loading state
*/
beginLoading() {
this._isLoading = true;
this.notifyHandlers();
}
/**
* Disable loading state
*/
endLoading() {
this._isLoading = false;
this.notifyHandlers();
}
private notifyHandlers() {
this.handlers.forEach(fn => fn(this._isLoading));
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { mount } from 'enzyme';
import expect from 'app/test/unexpected';
import { IntlProvider } from 'react-intl';
import Input from './Input';
describe('Input', () => {
it('should return input value', () => {
let component: any;
const wrapper = mount(
<IntlProvider locale="en" defaultLocale="en">
<Input
defaultValue="foo"
name="test"
ref={el => {
component = el;
}}
/>
</IntlProvider>,
);
expect(
wrapper.find('input[name="test"]').getDOMNode().value,
'to equal',
'foo',
);
expect(component.getValue(), 'to equal', 'foo');
});
});

View File

@@ -0,0 +1,178 @@
import React from 'react';
import { MessageDescriptor } from 'react-intl';
import classNames from 'classnames';
import { uniqueId, omit } from 'app/functions';
import copy from 'app/services/copy';
import icons from 'app/components/ui/icons.scss';
import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
import styles from './form.scss';
import FormInputComponent from './FormInputComponent';
let copiedStateTimeout: NodeJS.Timeout;
export default class Input extends FormInputComponent<
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'placeholder'> & {
skin: Skin;
color: Color;
center: boolean;
disabled: boolean;
label?: string | MessageDescriptor;
placeholder?: string | MessageDescriptor;
error?: string | { type: string; payload: string };
icon?: string;
copy?: boolean;
},
{
wasCopied: boolean;
}
> {
static defaultProps = {
color: COLOR_GREEN,
skin: SKIN_DARK,
center: false,
disabled: false,
};
state = {
wasCopied: false,
};
elRef = React.createRef<HTMLInputElement>();
render() {
const {
color,
skin,
center,
icon: iconType,
copy: showCopyIcon,
placeholder: placeholderText,
} = this.props;
let { label: labelContent } = this.props;
const { wasCopied } = this.state;
let placeholder: string | undefined;
const props = omit(
{
type: 'text',
...this.props,
},
[
'label',
'placeholder',
'error',
'skin',
'color',
'center',
'icon',
'copy',
],
);
let label: React.ReactElement | null = null;
let copyIcon: React.ReactElement | null = null;
let icon: React.ReactElement | null = null;
if (labelContent) {
if (!props.id) {
props.id = uniqueId('input');
}
labelContent = this.formatMessage(labelContent);
label = (
<label className={styles.textFieldLabel} htmlFor={props.id}>
{labelContent}
</label>
);
}
if (placeholderText) {
placeholder = this.formatMessage(placeholderText);
}
let baseClass = styles.formRow;
if (iconType) {
baseClass = styles.formIconRow;
icon = (
<span className={classNames(styles.textFieldIcon, icons[iconType])} />
);
}
if (showCopyIcon) {
copyIcon = (
<div
className={classNames(styles.copyIcon, {
[icons.clipboard]: !wasCopied,
[icons.checkmark]: wasCopied,
[styles.copyCheckmark]: wasCopied,
})}
onClick={this.onCopy}
/>
);
}
return (
<div className={baseClass}>
{label}
<div className={styles.textFieldContainer}>
<input
ref={this.elRef}
className={classNames(
styles[`${skin}TextField`],
styles[`${color}TextField`],
{
[styles.textFieldCenter]: center,
},
)}
placeholder={placeholder}
{...props}
/>
{icon}
{copyIcon}
</div>
{this.renderError()}
</div>
);
}
getValue() {
const { current: el } = this.elRef;
return el && el.value;
}
focus() {
const { current: el } = this.elRef;
if (!el) {
return;
}
el.focus();
setTimeout(el.focus.bind(el), 10);
}
onCopy = async () => {
const value = this.getValue();
if (!value) {
return;
}
try {
clearTimeout(copiedStateTimeout);
copiedStateTimeout = setTimeout(
() => this.setState({ wasCopied: false }),
2000,
);
await copy(value);
this.setState({ wasCopied: true });
} catch (err) {
// it's okay
}
};
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Button from './Button';
export default function LinkButton(
props: React.ComponentProps<typeof Button> &
React.ComponentProps<typeof Link>,
) {
const { to, ...restProps } = props;
return (
<Button
component={linkProps => <Link {...linkProps} to={to} />}
{...restProps}
/>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { MessageDescriptor } from 'react-intl';
import classNames from 'classnames';
import { SKIN_DARK, COLOR_GREEN } from 'app/components/ui';
import { omit } from 'app/functions';
import { Color, Skin } from 'app/components/ui';
import styles from './form.scss';
import FormInputComponent from './FormInputComponent';
export default class Radio extends FormInputComponent<
{
color: Color;
skin: Skin;
label: string | MessageDescriptor;
} & React.InputHTMLAttributes<HTMLInputElement>
> {
static defaultProps = {
color: COLOR_GREEN,
skin: SKIN_DARK,
};
elRef = React.createRef<HTMLInputElement>();
render() {
const { color, skin } = this.props;
let { label } = this.props;
label = this.formatMessage(label);
const props = omit(this.props, ['color', 'skin', 'label']);
return (
<div
className={classNames(
styles[`${color}MarkableRow`],
styles[`${skin}MarkableRow`],
)}
>
<label className={styles.markableContainer}>
<input
ref={this.elRef}
className={styles.markableInput}
type="radio"
{...props}
/>
<div className={styles.radio} />
{label}
</label>
{this.renderError()}
</div>
);
}
getValue() {
const { current: el } = this.elRef;
return el && el.checked ? 1 : 0;
}
focus() {
const { current: el } = this.elRef;
el && el.focus();
}
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { MessageDescriptor } from 'react-intl';
import TextareaAutosize from 'react-textarea-autosize';
import classNames from 'classnames';
import { uniqueId, omit } from 'app/functions';
import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
import styles from './form.scss';
import FormInputComponent from './FormInputComponent';
type TextareaAutosizeProps = {
onHeightChange?: (number, TextareaAutosizeProps) => void;
useCacheForDOMMeasurements?: boolean;
minRows?: number;
maxRows?: number;
inputRef?: (el?: HTMLTextAreaElement) => void;
};
export default class TextArea extends FormInputComponent<
{
placeholder?: string | MessageDescriptor;
label?: string | MessageDescriptor;
error?: string;
skin: Skin;
color: Color;
} & TextareaAutosizeProps &
React.TextareaHTMLAttributes<HTMLTextAreaElement>
> {
static defaultProps = {
color: COLOR_GREEN,
skin: SKIN_DARK,
};
elRef = React.createRef<HTMLTextAreaElement>();
render() {
const {
color,
skin,
label: labelText,
placeholder: placeholderText,
} = this.props;
let label: React.ReactElement | undefined;
let placeholder: string | undefined;
const props = omit(
{
type: 'text',
...this.props,
},
['label', 'placeholder', 'error', 'skin', 'color'],
);
if (labelText) {
if (!props.id) {
props.id = uniqueId('textarea');
}
label = (
<label className={styles.textFieldLabel} htmlFor={props.id}>
{this.formatMessage(labelText)}
</label>
);
}
if (placeholderText) {
placeholder = this.formatMessage(placeholderText);
}
return (
<div className={styles.formRow}>
{label}
<div className={styles.textAreaContainer}>
<TextareaAutosize
inputRef={this.elRef}
className={classNames(
styles.textArea,
styles[`${skin}TextField`],
styles[`${color}TextField`],
)}
placeholder={placeholder}
{...props}
/>
</div>
{this.renderError()}
</div>
);
}
getValue() {
const { current: el } = this.elRef;
return el && el.value;
}
focus() {
const { current: el } = this.elRef;
if (!el) {
return;
}
el.focus();
setTimeout(el.focus.bind(el), 10);
}
}

View File

@@ -0,0 +1,129 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
$dropdownPadding: 15px;
@mixin dropdown-theme($themeName, $backgroundColor) {
.#{$themeName} {
composes: dropdown;
background-color: $backgroundColor;
.menuItem:hover,
&:hover {
background-color: lighter($backgroundColor);
}
&:active,
&.opened {
background-color: darker($backgroundColor);
}
}
}
.dropdown {
display: inline-block;
box-sizing: border-box;
height: 50px;
// 28px - ширина иконки при заданном размере шрифта
padding: 0 ($dropdownPadding * 2 + 28px) 0 $dropdownPadding;
position: relative;
font-family: $font-family-title;
color: $defaultButtonTextColor;
font-size: 18px;
line-height: 50px;
text-decoration: none;
cursor: pointer;
transition: background-color 0.25s;
&:focus {
outline: none;
}
}
.label {
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.opened {
}
.toggleIcon {
composes: selecter from '~app/components/ui/icons.scss';
position: absolute;
right: $dropdownPadding;
top: 16px;
font-size: 17px;
transition: right 0.3s cubic-bezier(0.23, 1, 0.32, 1); // easeOutQuint
.dropdown:hover & {
right: $dropdownPadding - 5px;
}
.dropdown:active &,
.dropdown.opened & {
right: $dropdownPadding + 5px;
}
}
.block {
display: block;
width: 100%;
.menu {
width: 100%;
}
}
.menu {
position: absolute;
top: 60px;
left: 0;
z-index: 10;
width: 120%;
background: #fff;
border: 1px solid #ddd;
transition: 0.5s ease;
transition-property: opacity, visibility;
opacity: 0;
visibility: hidden;
.dropdown.opened & {
opacity: 1;
visibility: visible;
}
}
.menuItem {
composes: label;
height: 50px;
padding: 0 13px;
overflow: hidden;
text-overflow: ellipsis;
color: #444;
line-height: 50px;
font-size: 18px;
font-family: $font-family-title;
border-bottom: 1px solid #ebe8df;
cursor: pointer;
transition: 0.25s;
&:hover {
color: #fff;
}
}
@include dropdown-theme('green', $green);

View File

@@ -0,0 +1,430 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
@mixin form-transition() {
// Анимация фона должна быть быстрее анимации рамки, т.к. визуально фон заполняется медленнее
transition: border-color 0.25s, background-color 0.2s;
}
/**
* Input
*/
@mixin input-theme($themeName, $color) {
.#{$themeName}TextField {
composes: textField;
&:focus {
border-color: $color;
~ .textFieldIcon {
background: $color;
border-color: $color;
}
&.lightTextField {
color: $color;
}
}
}
}
.formRow {
margin: 10px 0;
}
.formIconRow {
composes: formRow;
.textField {
padding-left: 60px;
}
}
.textFieldContainer {
position: relative;
height: 50px;
max-width: 100%;
}
.textField {
box-sizing: border-box;
height: 50px;
width: 100%;
border: 2px solid;
font-size: 18px;
color: #aaa;
font-family: $font-family-title;
padding: 0 10px;
transition: border-color 0.25s;
&:hover {
&,
~ .textFieldIcon {
border-color: #aaa;
}
}
&:focus {
color: #fff;
outline: none;
~ .textFieldIcon {
color: #fff;
}
}
}
.textFieldIcon {
box-sizing: border-box;
position: absolute;
left: 0;
top: 0;
height: 50px;
width: 50px;
line-height: 46px;
text-align: center;
border: 2px solid;
color: #444;
cursor: default;
@include form-transition();
}
.copyIcon {
position: absolute;
right: 5px;
top: 10px;
padding: 5px;
cursor: pointer;
font-size: 20px;
transition: 0.25s;
}
.copyCheckmark {
color: $green !important;
}
.darkTextField {
background: $black;
&::placeholder {
opacity: 1;
color: #444;
}
&,
~ .textFieldIcon {
border-color: lighter($black);
}
~ .copyIcon {
color: #999;
background: $black;
&:hover {
background: lighter($black);
}
}
}
.lightTextField {
background: #fff;
&:disabled {
background: #dcd8cd;
~ .copyIcon {
background: #dcd8ce;
&:hover {
background: #ebe8e2;
}
}
}
&::placeholder {
opacity: 1;
color: #aaa;
}
&,
~ .textFieldIcon {
border-color: #dcd8cd;
}
~ .copyIcon {
color: #aaa;
background: #fff;
&:hover {
background: #f5f5f5;
}
}
}
.textFieldLabel {
margin: 10px 0;
display: block;
font-family: $font-family-title;
color: #666;
font-size: 18px;
}
.fieldError {
color: $red;
font-size: 12px;
margin: 3px 0;
a {
border-bottom-color: rgba($red, 0.75);
color: $red;
&:hover {
border-bottom-color: transparent;
}
}
}
.textAreaContainer {
height: auto;
}
.textArea {
height: auto; // unset .textField height
min-height: 50px;
padding: 5px 10px;
resize: none;
position: relative;
}
.textFieldCenter {
text-align: center;
}
@include input-theme('green', $green);
@include input-theme('blue', $blue);
@include input-theme('red', $red);
@include input-theme('darkBlue', $dark_blue);
@include input-theme('lightViolet', $light_violet);
@include input-theme('violet', $violet);
/**
* Markable is our common name for checkboxes and radio buttons
*/
@mixin markable-theme($themeName, $color) {
.#{$themeName}MarkableRow {
composes: markableRow;
.markableContainer {
&:hover {
.mark {
border-color: $color;
}
}
}
.markableInput {
&:checked {
+ .mark {
background: $color;
border-color: $color;
}
}
}
}
}
.markableRow {
height: 22px;
}
.markableContainer {
display: inline-block;
position: relative;
padding-left: 27px;
font-family: $font-family-title;
font-size: 16px;
line-height: 24px;
cursor: pointer;
}
.markPosition {
position: absolute;
box-sizing: border-box;
left: 0;
top: 0;
margin: 0;
width: 22px;
height: 22px;
}
.markableInput {
composes: markPosition;
opacity: 0;
&:checked {
+ .mark {
&:before {
opacity: 1;
}
}
}
}
.mark {
composes: markPosition;
composes: checkmark from '~app/components/ui/icons.scss';
border: 2px #dcd8cd solid;
font-size: 10px;
line-height: 18px;
text-align: center;
color: #fff;
@include form-transition();
&:before {
opacity: 0;
transition: opacity 0.3s;
}
}
.checkbox {
composes: mark;
}
.radio {
composes: mark;
border-radius: 50%;
}
.lightMarkableRow {
.markableContainer {
color: #666;
}
}
.darkMarkableRow {
.markableContainer {
color: #fff;
}
}
@include markable-theme('green', $green);
@include markable-theme('blue', $blue);
@include markable-theme('red', $red);
.isFormLoading {
// TODO: надо бы разнести from и input на отдельные модули,
// так как в текущем контексте isLoading немного не логичен,
// пришлось юзать isFormLoading
* {
pointer-events: none;
}
[type='submit'] {
// TODO: duplicate of .loading from components/ui/buttons
background: url('./images/loader_button.gif') #95a5a6 center center !important;
cursor: default;
color: #fff;
transition: 0.25s;
outline: none;
}
}
.captchaContainer {
position: relative;
}
.captcha {
position: relative;
box-sizing: border-box;
width: 100%;
max-width: 302px;
height: 77px;
overflow: hidden;
border: 2px solid;
transition: border-color 0.25s;
> div {
margin: -2px;
}
&:hover {
border-color: #aaa;
}
// minimum captcha width is 302px, which can not be changed
// using transform to scale down to 296px
// transform-origin: 0;
// transform: scaleX(0.98);
}
.darkCaptcha {
border-color: lighter($black);
}
.lightCaptcha {
border-color: #dcd8cd;
}
.captchaLoader {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
}
/**
* Form validation
*/
// Disable any visual error indication
// .formTouched .textField:invalid {
// box-shadow: none;
// &,
// ~ .textFieldIcon {
// border-color: #3e2727;
// }
// ~ .textFieldIcon {
// color: #3e2727;
// }
// &:hover {
// &,
// ~ .textFieldIcon {
// border-color: $red;
// }
// }
// &:focus {
// border-color: $red;
// ~ .textFieldIcon {
// background: $red;
// border-color: $red;
// color: #fff;
// }
// }
// }
// .formTouched .checkboxInput:invalid {
// ~ .checkbox {
// border-color: $red;
// }
// }

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -0,0 +1,25 @@
import Input from './Input';
import TextArea from './TextArea';
import Checkbox from './Checkbox';
import Radio from './Radio';
import Button from './Button';
import LinkButton from './LinkButton';
import Form from './Form';
import FormModel from './FormModel';
import Dropdown from './Dropdown';
import Captcha from './Captcha';
import FormError from './FormError';
export {
Input,
TextArea,
Button,
LinkButton,
Checkbox,
Radio,
Form,
FormModel,
Dropdown,
Captcha,
FormError,
};

View File

@@ -0,0 +1,24 @@
@import '~app/icons.font.json';
// По умолчанию стрелка смотрит вниз, это просто алиас
.arrowBottom {
@extend .arrow;
}
.arrowTop {
@extend .arrow;
transform: rotate(180deg);
}
.arrowRight {
@extend .arrow;
transform: rotate(270deg);
}
.arrowLeft {
@extend .arrow;
transform: rotate(90deg);
}

View File

@@ -0,0 +1,41 @@
export type Color =
| 'green'
| 'blue'
| 'darkBlue'
| 'violet'
| 'lightViolet'
| 'orange'
| 'red'
| 'black'
| 'white';
export type Skin = 'dark' | 'light';
export const COLOR_GREEN: Color = 'green';
export const COLOR_BLUE: Color = 'blue';
export const COLOR_DARK_BLUE: Color = 'darkBlue';
export const COLOR_VIOLET: Color = 'violet';
export const COLOR_LIGHT_VIOLET: Color = 'lightViolet';
export const COLOR_ORANGE: Color = 'orange';
export const COLOR_RED: Color = 'red';
export const COLOR_BLACK: Color = 'black';
export const COLOR_WHITE: Color = 'white';
export const colors: Array<Color> = [
COLOR_GREEN,
COLOR_BLUE,
COLOR_DARK_BLUE,
COLOR_VIOLET,
COLOR_LIGHT_VIOLET,
COLOR_ORANGE,
COLOR_RED,
COLOR_BLACK,
COLOR_WHITE,
];
export const SKIN_DARK: Skin = 'dark';
export const SKIN_LIGHT: Skin = 'light';
export const skins: Array<Skin> = [SKIN_DARK, SKIN_LIGHT];
export { default as RelativeTime } from './RelativeTime';

View File

@@ -0,0 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import { Skin } from 'app/components/ui';
import styles from './componentLoader.scss';
function ComponentLoader({ skin = 'dark' }: { skin?: Skin }) {
return (
<div
className={classNames(
styles.componentLoader,
styles[`${skin}ComponentLoader`],
)}
>
<div className={styles.spins}>
{new Array(5).fill(0).map((_, index) => (
<div
className={classNames(styles.spin, styles[`spin${index}`])}
key={index}
/>
))}
</div>
</div>
);
}
export default ComponentLoader;

View File

@@ -0,0 +1,71 @@
import React from 'react';
import classNames from 'classnames';
import { ComponentLoader } from 'app/components/ui/loader';
import { SKIN_LIGHT } from 'app/components/ui';
import styles from './imageLoader.scss';
export default class ImageLoader extends React.Component<
{
src: string;
alt: string;
ratio: number; // width:height ratio
onLoad?: Function;
},
{
isLoading: boolean;
}
> {
state = {
isLoading: true,
};
componentWillMount() {
this.preloadImage();
}
preloadImage() {
const img = new Image();
img.onload = () => this.imageLoaded();
img.onerror = () => this.preloadImage();
img.src = this.props.src;
}
imageLoaded() {
this.setState({ isLoading: false });
if (this.props.onLoad) {
this.props.onLoad();
}
}
render() {
const { isLoading } = this.state;
const { src, alt, ratio } = this.props;
return (
<div className={styles.container}>
<div
style={{
height: 0,
paddingBottom: `${ratio * 100}%`,
}}
/>
{isLoading && (
<div className={styles.loader}>
<ComponentLoader skin={SKIN_LIGHT} />
</div>
)}
<div
className={classNames(styles.image, {
[styles.imageLoaded]: !isLoading,
})}
>
<img src={src} alt={alt} />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,69 @@
@import '~app/components/ui/colors.scss';
.componentLoader {
width: 100%;
text-align: center;
}
.spins {
height: 40px;
}
.spin {
height: 20px;
width: 20px;
display: inline-block;
margin: 10px 2px;
opacity: 0;
animation: loaderAnimation 1s infinite;
}
.spin1 {
animation-delay: 0s;
}
.spin2 {
animation-delay: 0.1s;
}
.spin3 {
animation-delay: 0.2s;
}
.spin4 {
animation-delay: 0.3s;
}
.spin5 {
animation-delay: 0.4s;
}
/**
* Skins
*/
.lightComponentLoader {
.spin {
background: #aaa;
}
}
.darkComponentLoader {
.spin {
background: #444;
}
}
@keyframes loaderAnimation {
0% {
opacity: 0;
}
50% {
opacity: 1;
transform: scaleY(2);
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,25 @@
.container {
position: relative;
z-index: 1;
}
.loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: 0.4s ease-in;
}
.imageLoaded {
opacity: 1;
}

View File

@@ -0,0 +1,2 @@
export { default as ImageLoader } from './ImageLoader';
export { default as ComponentLoader } from './ComponentLoader';

View File

@@ -0,0 +1,12 @@
<div id="loader" class="loader-overlay is-first-launch">
<div class="loader">
<div class="loader__cube loader__cube--1"></div>
<div class="loader__cube loader__cube--2"></div>
<div class="loader__cube loader__cube--3"></div>
<div class="loader__cube loader__cube--4"></div>
<div class="loader__cube loader__cube--5"></div>
<div class="loader__cube loader__cube--6"></div>
<div class="loader__cube loader__cube--7"></div>
<div class="loader__cube loader__cube--8"></div>
</div>
</div>

View File

@@ -0,0 +1,192 @@
.loader-overlay {
background: rgba(255, 255, 255, 0.3);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100;
visibility: hidden;
opacity: 0;
transition: 0.4s ease;
&.is-first-launch {
// if loader is shown for the first time, we will
// remove opacity and hide the entire site contents
background: #f2efeb;
}
&.is-active {
opacity: 1;
visibility: visible;
}
}
.loader {
position: absolute;
top: 50%;
left: 50%;
margin-top: -75px;
margin-left: -75px;
width: 153px;
height: 153px;
&__cube {
position: absolute;
margin: -1px;
padding: 0;
width: 50px;
height: 50px;
background: rgba(35, 35, 35, 0.6);
display: inline-block;
animation-iteration-count: infinite;
animation-duration: 0.5s;
animation-timing-function: linear;
&--1 {
margin: 0 0 0 0;
animation-name: cube1;
}
&--2 {
margin: 0 0 0 051px;
animation-name: cube2;
}
&--3 {
margin: 0 0 0 102px;
animation-name: cube3;
}
&--4 {
margin: 51px 0 0 0;
animation-name: cube4;
}
&--5 {
margin: 051px 0 0 051px;
}
&--6 {
margin: 51px 0 0 102px;
animation-name: cube6;
}
&--7 {
margin: 102px 0 0 0;
animation-name: cube7;
}
&--8 {
margin: 102px 0 0 051px;
animation-name: cube8;
}
}
}
@keyframes cube1 {
25% {
margin: 0 0 0 0;
}
50% {
margin: 0 0 0 51px;
}
75% {
margin: 0 0 0 51px;
}
100% {
margin: 0 0 0 51px;
}
}
@keyframes cube2 {
25% {
margin: 0 0 0 51px;
}
50% {
margin: 0 0 0 102px;
}
75% {
margin: 0 0 0 102px;
}
100% {
margin: 0 0 0 102px;
}
}
@keyframes cube3 {
25% {
margin: 51px 0 0 102px;
}
50% {
margin: 51px 0 0 102px;
}
75% {
margin: 51px 0 0 102px;
}
100% {
margin: 51px 0 0 102px;
}
}
@keyframes cube4 {
25% {
margin: 51px 0 0 0;
}
50% {
margin: 51px 0 0 0;
}
75% {
margin: 0 0 0 0;
}
100% {
margin: 0 0 0 0;
}
}
@keyframes cube6 {
25% {
margin: 102px 0 0 102px;
}
50% {
margin: 102px 0 0 102px;
}
75% {
margin: 102px 0 0 102px;
}
100% {
margin: 102px 0 0 51px;
}
}
@keyframes cube7 {
25% {
margin: 102px 0 0 0;
}
50% {
margin: 102px 0 0 0;
}
75% {
margin: 51px 0 0 0;
}
100% {
margin: 51px 0 0 0;
}
}
@keyframes cube8 {
25% {
margin: 102px 0 0 51px;
}
50% {
margin: 102px 0 0 51px;
}
75% {
margin: 102px 0 0 51px;
}
100% {
margin: 102px 0 0 0;
}
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Motion, spring } from 'react-motion';
import MeasureHeight from 'app/components/MeasureHeight';
import styles from './slide-motion.scss';
interface State {
[stepHeight: string]: number;
version: number;
}
class SlideMotion extends React.Component<
{
activeStep: number;
children: React.ReactNode;
},
State
> {
state: State = {
version: 0,
};
isHeightMeasured: boolean;
componentWillReceiveProps() {
// mark this view as dirty to re-measure height
this.setState({
version: this.state.version + 1,
});
}
render() {
const { activeStep, children } = this.props;
const { version } = this.state;
const activeStepHeight = this.state[`step${activeStep}Height`] || 0;
// a hack to disable height animation on first render
const { isHeightMeasured } = this;
this.isHeightMeasured = isHeightMeasured || activeStepHeight > 0;
const motionStyle = {
transform: spring(activeStep * 100, {
stiffness: 500,
damping: 50,
precision: 0.5,
}),
height: isHeightMeasured
? spring(activeStepHeight, {
stiffness: 500,
damping: 20,
precision: 0.5,
})
: activeStepHeight,
};
return (
<Motion style={motionStyle}>
{(interpolatingStyle: { height: number; transform: string }) => (
<div
style={{
overflow: 'hidden',
height: `${interpolatingStyle.height}px`,
}}
>
<div
className={styles.container}
style={{
WebkitTransform: `translateX(-${interpolatingStyle.transform}%)`,
transform: `translateX(-${interpolatingStyle.transform}%)`,
}}
>
{React.Children.map(children, (child, index) => (
<MeasureHeight
className={styles.item}
onMeasure={this.onStepMeasure(index)}
state={version}
key={index}
>
{child}
</MeasureHeight>
))}
</div>
</div>
)}
</Motion>
);
}
onStepMeasure(step: number) {
return (height: number) =>
this.setState({
[`step${step}Height`]: height,
});
}
}
export default SlideMotion;

View File

@@ -0,0 +1 @@
export { default as SlideMotion } from './SlideMotion';

View File

@@ -0,0 +1,10 @@
.container {
white-space: nowrap;
}
.item {
display: inline-block;
white-space: normal;
vertical-align: top;
width: 100%;
}

View File

@@ -0,0 +1,138 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.panel {
background: $black;
}
$headerHeight: 60px;
.header {
box-sizing: border-box;
height: $headerHeight;
border-bottom: 1px solid lighter($black);
position: relative;
overflow: hidden;
font-family: $font-family-title;
text-align: center;
// Шрифт Roboto Condensed имеет странную высоту линии, так что компенсируем это
line-height: $headerHeight + 2px;
font-size: 20px;
color: #fff;
}
.headerControl {
composes: black from '~app/components/ui/buttons.scss';
float: left;
overflow: hidden;
height: $headerHeight - 1px;
width: 49px;
padding: 0;
border-right: 1px solid lighter($black);
line-height: 1;
text-align: center;
}
$bodyLeftRightPadding: 20px;
$bodyTopBottomPadding: 15px;
.body {
overflow: hidden;
padding: $bodyTopBottomPadding $bodyLeftRightPadding;
color: #ccc;
font-size: 18px;
b {
font-weight: normal;
color: #fff;
}
a {
color: #fff !important;
border-bottom-color: rgba(#fff, 0.75);
&:hover {
border-bottom-color: rgba(#fff, 0.5);
}
}
}
.footer {
position: relative;
overflow: hidden;
height: 50px;
button {
width: 100%;
}
}
.bodyHeader {
position: relative;
overflow: hidden;
padding: 10px 20px;
margin: (-$bodyTopBottomPadding) (-$bodyLeftRightPadding) 15px;
max-height: 200px;
transition: 0.4s ease;
}
.isClosed {
max-height: 0;
opacity: 0;
padding: 0;
margin: 0;
}
.errorBodyHeader {
composes: bodyHeader;
background: $red;
border: 1px darker($red) solid;
font-size: 14px;
line-height: 1.3;
color: #fff;
a {
&:hover {
text-shadow: 0 0 1px #fff;
border-bottom-color: transparent;
}
}
}
.defaultBodyHeader {
composes: bodyHeader;
background: darker($black);
border: lighter($black) solid;
border-bottom-width: 5px;
border-top-width: 4px;
font-size: 14px;
line-height: 1.3;
color: #fff;
}
.close {
composes: close from '~app/components/ui/icons.scss';
position: absolute;
right: 5px;
top: 5px;
font-size: 10px;
cursor: pointer;
}
.panelIcon {
color: #ccc;
font-size: 100px;
line-height: 1;
margin-bottom: 15px;
}

View File

@@ -0,0 +1,189 @@
import sinon from 'sinon';
import expect from 'app/test/unexpected';
import React from 'react';
import { shallow, mount } from 'enzyme';
import { PopupStack } from 'app/components/ui/popup/PopupStack';
import styles from 'app/components/ui/popup/popup.scss';
function DummyPopup(/** @type {{[key: string]: any}} */ _props) {
return null;
}
describe('<PopupStack />', () => {
it('renders all popup components', () => {
const props = {
destroy: () => {},
popups: [
{
Popup: DummyPopup,
},
{
Popup: DummyPopup,
},
],
};
const component = shallow(<PopupStack {...props} />);
expect(component.find(DummyPopup), 'to satisfy', { length: 2 });
});
it('should pass onClose as props', () => {
const expectedProps = {
foo: 'bar',
};
const props = {
destroy: () => {},
popups: [
{
Popup: (props = {}) => {
// eslint-disable-next-line
expect(props.onClose, 'to be a', 'function');
return <DummyPopup {...expectedProps} />;
},
},
],
};
const component = mount(<PopupStack {...props} />);
const popup = component.find(DummyPopup);
expect(popup, 'to satisfy', { length: 1 });
expect(popup.props(), 'to equal', expectedProps);
});
it('should hide popup, when onClose called', () => {
const props = {
popups: [
{
Popup: DummyPopup,
},
{
Popup: DummyPopup,
},
],
destroy: sinon.stub().named('props.destroy'),
};
const component = shallow(<PopupStack {...props} />);
component
.find(DummyPopup)
.last()
.prop('onClose')();
expect(props.destroy, 'was called once');
expect(props.destroy, 'to have a call satisfying', [
expect.it('to be', props.popups[1]),
]);
});
it('should hide popup, when overlay clicked', () => {
const preventDefault = sinon.stub().named('event.preventDefault');
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
},
],
};
const component = shallow(<PopupStack {...props} />);
const overlay = component.find(`.${styles.overlay}`);
overlay.simulate('click', { target: 1, currentTarget: 1, preventDefault });
expect(props.destroy, 'was called once');
expect(preventDefault, 'was called once');
});
it('should hide popup on overlay click if disableOverlayClose', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
disableOverlayClose: true,
},
],
};
const component = shallow(<PopupStack {...props} />);
const overlay = component.find(`.${styles.overlay}`);
overlay.simulate('click', {
target: 1,
currentTarget: 1,
preventDefault() {},
});
expect(props.destroy, 'was not called');
});
it('should hide popup, when esc pressed', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
},
],
};
mount(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
expect(props.destroy, 'was called once');
});
it('should hide first popup in stack if esc pressed', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup() {
return null;
},
},
{
Popup: DummyPopup,
},
],
};
mount(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
expect(props.destroy, 'was called once');
expect(props.destroy, 'to have a call satisfying', [
expect.it('to be', props.popups[1]),
]);
});
it('should NOT hide popup on esc pressed if disableOverlayClose', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
disableOverlayClose: true,
},
],
};
mount(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
expect(props.destroy, 'was not called');
});
});

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import { browserHistory } from 'app/services/history';
import { connect } from 'react-redux';
import { RootState } from 'app/reducers';
import { PopupConfig } from './reducer';
import { destroy } from './actions';
import styles from './popup.scss';
export class PopupStack extends React.Component<{
popups: PopupConfig[];
destroy: (popup: PopupConfig) => void;
}> {
unlistenTransition: () => void;
componentWillMount() {
document.addEventListener('keyup', this.onKeyPress);
this.unlistenTransition = browserHistory.listen(this.onRouteLeave);
}
componentWillUnmount() {
document.removeEventListener('keyup', this.onKeyPress);
this.unlistenTransition();
}
render() {
const { popups } = this.props;
return (
<TransitionGroup>
{popups.map((popup, index) => {
const { Popup } = popup;
return (
<CSSTransition
key={index}
classNames={{
enter: styles.trEnter,
enterActive: styles.trEnterActive,
exit: styles.trExit,
exitActive: styles.trExitActive,
}}
timeout={500}
>
<div
className={styles.overlay}
onClick={this.onOverlayClick(popup)}
>
<Popup onClose={this.onClose(popup)} />
</div>
</CSSTransition>
);
})}
</TransitionGroup>
);
}
onClose(popup: PopupConfig) {
return this.props.destroy.bind(null, popup);
}
onOverlayClick(popup: PopupConfig) {
return (event: React.MouseEvent) => {
if (event.target !== event.currentTarget || popup.disableOverlayClose) {
return;
}
event.preventDefault();
this.props.destroy(popup);
};
}
popStack() {
const [popup] = this.props.popups.slice(-1);
if (popup && !popup.disableOverlayClose) {
this.props.destroy(popup);
}
}
onKeyPress = (event: KeyboardEvent) => {
if (event.which === 27) {
// ESC key
this.popStack();
}
};
onRouteLeave = nextLocation => {
if (nextLocation) {
this.popStack();
}
};
}
export default connect(
(state: RootState) => ({
...state.popup,
}),
{
destroy,
},
)(PopupStack);

View File

@@ -0,0 +1,17 @@
import { PopupConfig } from './reducer';
export const POPUP_CREATE = 'POPUP_CREATE';
export function create(payload: PopupConfig) {
return {
type: POPUP_CREATE,
payload,
};
}
export const POPUP_DESTROY = 'POPUP_DESTROY';
export function destroy(popup: PopupConfig) {
return {
type: POPUP_DESTROY,
payload: popup,
};
}

View File

@@ -0,0 +1,174 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
$popupPadding: 20px; // Отступ контента внутри попапа
$popupMargin: 20px; // Отступ попапа от краёв окна
@mixin popupBounding($width, $padding: null) {
@if ($padding == null) {
$padding: $popupMargin;
}
@if ($padding != $popupMargin) {
margin: $padding auto;
padding: 0 $padding;
}
max-width: $width;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
background: rgba(#fff, 0.8);
text-align: center;
white-space: nowrap;
overflow-x: hidden;
overflow-y: auto;
perspective: 600px;
&:before {
content: '';
display: inline-block;
vertical-align: middle;
height: 100%;
width: 0;
}
}
.popupWrapper {
box-sizing: border-box;
margin: 0 auto;
padding: $popupMargin;
display: inline-block;
width: 100%;
vertical-align: middle;
transition: max-width 0.3s;
}
.popup {
white-space: normal;
text-align: left;
background: #fff;
box-shadow: 0 0 10px rgba(#000, 0.2);
color: #666;
:invalid {
button {
opacity: 0.3;
pointer-events: none;
}
}
}
.header {
position: relative;
background: $white;
padding: 15px $popupPadding;
border-bottom: 1px solid #dedede;
}
.headerTitle {
font-size: 20px;
font-family: $font-family-title;
text-align: center;
}
.body {
padding: $popupPadding;
}
.close {
position: absolute;
right: 0;
top: 0;
padding: 15px;
cursor: pointer;
font-size: 20px;
color: rgba(#000, 0.4);
background: rgba(#000, 0);
transition: 0.25s;
transform: translateX(0);
&:hover {
color: rgba(#000, 0.6);
background: rgba(#fff, 0.75);
}
}
@media (min-width: 655px) {
.close {
position: fixed;
padding: 35px;
}
}
/**
* Transition states
*/
$popupInitPosition: translateY(10%) rotateX(-8deg);
.trEnter {
opacity: 0;
.popup {
opacity: 0;
transform: $popupInitPosition;
}
&Active {
opacity: 1;
transition: opacity 0.2s ease-in;
.popup {
opacity: 1;
transform: translateY(0) rotateX(0deg);
transition: 0.3s cubic-bezier(0.23, 1, 0.32, 1); // easeOutQuint
}
}
}
.trExit {
opacity: 1;
overflow: hidden;
.popup {
opacity: 1;
transform: translateY(0);
}
&Active {
opacity: 0;
transition: opacity 0.2s ease-in;
.popup {
opacity: 0;
transform: $popupInitPosition;
transition: 0.3s cubic-bezier(0.165, 0.84, 0.44, 1); // easeOutQuart
}
}
}
.trEnter,
.trExit {
.close {
// do not show close during transition, because transform forces position: fixed
// to layout relative container, instead of body
opacity: 0;
transform: translate(100%);
transition: 0s;
}
}

View File

@@ -0,0 +1,98 @@
import expect from 'app/test/unexpected';
import React from 'react';
import reducer from 'app/components/ui/popup/reducer';
import { create, destroy } from 'app/components/ui/popup/actions';
describe('popup/reducer', () => {
it('should have no popups by default', () => {
const actual = reducer(undefined, {
type: 'init',
});
expect(actual.popups, 'to be an', 'array');
expect(actual.popups, 'to be empty');
});
describe('#create', () => {
it('should create popup', () => {
const actual = reducer(
undefined,
create({
Popup: FakeComponent,
}),
);
expect(actual.popups[0], 'to equal', {
Popup: FakeComponent,
});
});
it('should create multiple popups', () => {
let actual = reducer(
undefined,
create({
Popup: FakeComponent,
}),
);
actual = reducer(
actual,
create({
Popup: FakeComponent,
}),
);
expect(actual.popups[1], 'to equal', {
Popup: FakeComponent,
});
});
it('throws when no type provided', () => {
expect(
() =>
reducer(
undefined,
// @ts-ignore
create({}),
),
'to throw',
'Popup is required',
);
});
});
describe('#destroy', () => {
let state;
let popup;
beforeEach(() => {
state = reducer(state, create({ Popup: FakeComponent }));
popup = state.popups[0];
});
it('should remove popup', () => {
expect(state.popups, 'to have length', 1);
state = reducer(state, destroy(popup));
expect(state.popups, 'to have length', 0);
});
it('should not remove something, that it should not', () => {
state = reducer(
state,
create({
Popup: FakeComponent,
}),
);
state = reducer(state, destroy(popup));
expect(state.popups, 'to have length', 1);
expect(state.popups[0], 'not to be', popup);
});
});
});
function FakeComponent() {
return <span />;
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { combineReducers } from 'redux';
import { POPUP_CREATE, POPUP_DESTROY } from './actions';
export interface PopupConfig {
Popup: React.ElementType;
props?: { [key: string]: any };
// do not allow hiding popup
disableOverlayClose?: boolean;
}
export type State = {
popups: PopupConfig[];
};
export default combineReducers<State>({
popups,
});
function popups(state: PopupConfig[] = [], { type, payload }) {
switch (type) {
case POPUP_CREATE:
if (!payload.Popup) {
throw new Error('Popup is required');
}
return state.concat(payload);
case POPUP_DESTROY:
return state.filter(popup => popup !== payload);
default:
return state;
}
}

View File

@@ -0,0 +1,36 @@
import { RouteComponentProps } from 'react-router-dom';
import React from 'react';
import { withRouter } from 'react-router-dom';
import { restoreScroll } from './scroll';
class ScrollIntoView extends React.PureComponent<
RouteComponentProps & {
top?: boolean; // do not touch any DOM and simply scroll to top on location change
}
> {
componentDidMount() {
this.onPageUpdate();
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.onPageUpdate();
}
}
onPageUpdate() {
if (this.props.top) {
restoreScroll();
}
}
render() {
if (this.props.top) {
return null;
}
return <span ref={el => el && restoreScroll(el)} />;
}
}
export default withRouter(ScrollIntoView);

View File

@@ -0,0 +1 @@
export { default as ScrollIntoView } from './ScrollIntoView';

View File

@@ -0,0 +1,165 @@
/**
* Implements scroll to animation with momentum effect
*
* @see http://ariya.ofilabs.com/2013/11/javascript-kinetic-scrolling-part-2.html
*/
const TIME_CONSTANT = 100; // higher numbers - slower animation
const SCROLL_ANCHOR_OFFSET = 80; // 50 + 30 (header height + some spacing)
// Первый скролл выполняется сразу после загрузки страницы, так что чтобы снизить
// нагрузку на рендеринг мы откладываем первый скрол на 200ms
let isFirstScroll = true;
let scrollJob: {
hasAmplitude: boolean;
start: number;
y: number;
amplitude: number;
} | null = null;
export function scrollTo(y: number) {
if (scrollJob) {
// we already scrolling, so simply change the coordinates we are scrolling to
if (scrollJob.hasAmplitude) {
const delta = y - scrollJob.y;
scrollJob.amplitude += delta;
}
scrollJob.y = y;
return;
}
const start = Date.now();
let scrollWasTouched = false;
scrollJob = {
// NOTE: we may use some sort of debounce to wait till we catch all the
// scroll requests after app state changes, but the way with hasAmplitude
// seems to be more reliable
hasAmplitude: false,
start,
y,
amplitude: 0,
};
requestAnimationFrame(() => {
// wrap in requestAnimationFrame to optimize initial reading of scrollTop
if (!scrollJob) {
return;
}
const y = normalizeScrollPosition(scrollJob.y);
scrollJob.hasAmplitude = true;
scrollJob.y = y;
scrollJob.amplitude = y - getScrollTop();
(function animateScroll() {
if (!scrollJob) {
return;
}
const { start, y, amplitude } = scrollJob;
const elapsed = Date.now() - start;
let delta = -amplitude * Math.exp(-elapsed / TIME_CONSTANT);
if (Math.abs(delta) > 0.5 && !scrollWasTouched) {
requestAnimationFrame(animateScroll);
} else {
// the last animation frame
delta = 0;
scrollJob = null;
document.removeEventListener('mousewheel', markScrollTouched);
document.removeEventListener('touchstart', markScrollTouched);
}
if (scrollWasTouched) {
// block any animation visualisation in case, when user touched scroll
return;
}
const newScrollTop = y + delta;
window.scrollTo(0, newScrollTop);
})();
});
document.addEventListener('mousewheel', markScrollTouched);
document.addEventListener('touchstart', markScrollTouched);
function markScrollTouched() {
scrollWasTouched = true;
}
}
/**
* Ensures, that `y` is the coordinate, that can be physically scrolled to
*
* @param {number} y
*
* @returns {number}
*/
function normalizeScrollPosition(y: number): number {
const contentHeight =
(document.documentElement && document.documentElement.scrollHeight) || 0;
const windowHeight: number = window.innerHeight;
const maxY = contentHeight - windowHeight;
return Math.min(y, maxY);
}
/**
* Scrolls to page's top or #anchor link, if any
*
* @param {?HTMLElement} targetEl - the element to scroll to
*/
export function restoreScroll(targetEl: HTMLElement | null = null) {
const { hash } = window.location;
setTimeout(
() => {
isFirstScroll = false;
if (targetEl === null) {
const id = hash.substr(1);
if (!id) {
return;
}
targetEl = document.getElementById(id);
}
const viewPort = document.body;
if (!viewPort) {
console.log('Can not find viewPort element'); // eslint-disable-line
return;
}
let y = 0;
if (targetEl) {
const { top } = targetEl.getBoundingClientRect();
y = getScrollTop() + top - SCROLL_ANCHOR_OFFSET;
}
scrollTo(y);
},
isFirstScroll ? 200 : 0,
);
}
/**
* http://stackoverflow.com/a/3464890/5184751
*
* @returns {number}
*/
export function getScrollTop(): number {
const doc = document.documentElement;
if (doc) {
return (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
}
return 0;
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import classNames from 'classnames';
import { Color, COLOR_GREEN } from 'app/components/ui';
import styles from './stepper.scss';
export default function Stepper({
totalSteps,
activeStep,
color = COLOR_GREEN,
}: {
totalSteps: number;
activeStep: number;
color?: Color;
}) {
return (
<div className={classNames(styles.steps, styles[`${color}Steps`])}>
{new Array(totalSteps).fill(0).map((_, step) => (
<div
className={classNames(styles.step, {
[styles.activeStep]: step <= activeStep,
})}
key={step}
/>
))}
</div>
);
}

View File

@@ -0,0 +1 @@
export { default } from './Stepper';

View File

@@ -0,0 +1,82 @@
@import '~app/components/ui/colors.scss';
.steps {
height: 40px;
display: flex;
align-items: center;
}
.step {
position: relative;
text-align: right;
width: 100%;
height: 4px;
background: #d8d5ce;
&:first-child {
width: 12px;
}
&:before {
content: '';
display: block;
position: absolute;
height: 4px;
left: 0;
right: 100%;
top: 50%;
margin-top: -2px;
background: #aaa;
transition: 0.4s ease 0.1s;
}
&:after {
content: '';
display: inline-block;
position: relative;
top: -7px;
z-index: 1;
width: 12px;
height: 12px;
border-radius: 100%;
box-sizing: border-box;
background: #aaa;
border: 2px solid #aaa;
transition: background 0.4s ease,
border-color 0.4s cubic-bezier(0.19, 1, 0.22, 1); // easeOutExpo
}
}
.activeStep {
&:before {
right: 0;
transition-delay: unset;
}
&:after {
transition-delay: 0.3s;
}
}
.greenSteps {
.activeStep {
&:after {
background: $green;
border-color: darker($green);
}
}
}
.violetSteps {
.activeStep {
&:after {
background: $violet;
border-color: darker($violet);
}
}
}