~handlerug/univol

30ace431c5255746e1f251d5da40d6e0d2f2ae05 — handlerug 3 years ago
initial commit
A  => .editorconfig +12 -0
@@ 1,12 @@
root = true

[**]
indent_style = space
end_of_line = lf
insert_final_newline = true

[**.{js,json}]
indent_size = 2

[**.{html,css}]
indent_size = 4

A  => .gitignore +18 -0
@@ 1,18 @@
# Logs
logs
*.log
npm-debug.log*
*.lock

# Dependency directories
package-lock.json
/node_modules

# Optional npm cache directory
.npm

# Prod Bundle
/build

.vscode
.idea

A  => .prettierrc +3 -0
@@ 1,3 @@
{
  "singleQuote": true
}

A  => README.md +49 -0
@@ 1,49 @@
<div align="center">
  <a href="https://github.com/VKCOM">
    <img width="100" height="100" src="https://avatars3.githubusercontent.com/u/1478241?s=200&v=4">
  </a>
  <br>
  <br>

  [![npm][npm]][npm-url]
  [![deps][deps]][deps-url]

</div>

# VK Mini Apps: @vkontakte/create-vk-mini-app

## How to install

### Create VK Mini App with gh-pages deploy

`npx @vkontakte/create-vk-mini-app <app-directory-name>`

### Create VK Mini App with Zeit deploy

Firstly, you have to create Zeit account and connect it with your GitHub profile — https://zeit.co/

`npx @vkontakte/create-vk-mini-app <app-directory-name> --zeit`

### Create VK Mini App with Surge deploy

Firstly, you have to create Surge account and Surge-domain — https://surge.sh/

`npx @vkontakte/create-vk-mini-app <app-directory-name> --surge <surge-domain>`

## Use Connect lib based on promise
Just add `--promise` flag. More info about **vkui-connect-promise** — https://www.npmjs.com/package/@vkontakte/vkui-connect-promise

`npx @vkontakte/create-vk-mini-app <app-directory-name> --promise`

## How to start work with app

Go to created folder and run:
`yarn start` || `npm start` — this will start dev server with hot reload on `localhost:10888`.

`yarn run build` || `npm run build` — this will build production bundle, with tree-shaking, uglify and all this modern fancy stuff

[npm]: https://img.shields.io/npm/v/@vkontakte/create-vk-mini-app.svg
[npm-url]: https://npmjs.com/package/@vkontakte/create-vk-mini-app

[deps]: https://img.shields.io/david/vkcom/create-vk-mini-app.svg
[deps-url]: https://david-dm.org/vkcom/create-vk-mini-app
\ No newline at end of file

A  => package.json +40 -0
@@ 1,40 @@
{
  "name": "univol",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "cross-env PORT=10888 react-scripts start",
    "build": "react-scripts build"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "cross-env": "^5.2.0",
    "prettier": "^1.18.2",
    "react-hot-loader": "^4.9.0",
    "react-scripts": "^2.1.8"
  },
  "dependencies": {
    "@vkontakte/icons": "^1.8.3",
    "@vkontakte/vk-connect": "^1.5.6",
    "@vkontakte/vk-connect-promise": "^0.2.0",
    "@vkontakte/vkui": "^2.30.2",
    "babel-eslint": "^9.0.0",
    "chalk": "^2.4.2",
    "core-js": "^3.1.4",
    "eruda": "^1.5.8",
    "lodash": "^4.17.15",
    "prop-types": "^15.7.2",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "use-constant": "^1.0.0"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

A  => public/index.html +13 -0
@@ 1,13 @@
<!DOCTYPE html>
<html lang="en">
	<head>
			<meta charset="utf-8">
			<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no, viewport-fit=cover">
			<meta name="theme-color" content="#000000">
			<meta content="IE=Edge" http-equiv="X-UA-Compatible">
			<title>UNIVOL VK Mini App</title>
	</head>
	<body>
			<div id="root"></div>
	</body>
</html>

A  => src/App.js +89 -0
@@ 1,89 @@
import React from 'react';
import { Epic, ScreenSpinner, Tabbar, TabbarItem } from '@vkontakte/vkui';
import connect from '@vkontakte/vk-connect-promise';
import '@vkontakte/vkui/dist/vkui.css';
import Icon28Newsfeed from '@vkontakte/icons/dist/28/newsfeed';
import Icon28Profile from '@vkontakte/icons/dist/28/profile';

import AuthContext from './AuthContext';
import Home from './panels/Home';
import Profile from './panels/Profile';
import { callAPI, authUser } from './utils/api';
import RoleChooser from './panels/RoleChooser';

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      activeStory: 'home',
      loading: true,
      vkUser: null,
      user: null,
    };
  }

  componentDidMount() {
    connect.send('VKWebAppGetUserInfo', {})
      .then(({ data: vkUser }) => {
        callAPI('GET', `api1/vk/user/${vkUser.id}`)
          .then((data) => {
            if (!data.id) {
              this.setState({
                loading: false,
                vkUser,
              });
            } else {
              authUser(vkUser)
                .then((user) => {
                  this.setState({
                    loading: false,
                    vkUser,
                    user,
                  });
                });
            }
          });
      });
  }

  go = (e) => {
    this.setState({ activeStory: e.currentTarget.dataset.to })
  };

  render() {
    return (
      this.state.loading
        ? <ScreenSpinner />
        : (<AuthContext.Provider value={{
            user: this.state.user,
            setUser: user => this.setState({ user }),
          }}>
            {this.state.user
              ? <Epic activeStory={this.state.activeStory} tabbar={
                <Tabbar>
                  <TabbarItem
                    onClick={this.go}
                    selected={this.state.activeStory === 'home'}
                    data-to="home"
                    text="Главная"
                  ><Icon28Newsfeed /></TabbarItem>
                  <TabbarItem
                    onClick={this.go}
                    selected={this.state.activeStory === 'profile'}
                    data-to="profile"
                    text="Профиль"
                  ><Icon28Profile /></TabbarItem>
                </Tabbar>
              }>
                <Home id="home" go={this.go} />
                <Profile id="profile" go={this.go} />
              </Epic>
              : <RoleChooser id="role-chooser" vkUser={this.state.vkUser} />}
          </AuthContext.Provider>
        )
    );
  }
}

export default App;

A  => src/AuthContext.js +3 -0
@@ 1,3 @@
import React from 'react';

export default React.createContext();

A  => src/components/CProgress.css +10 -0
@@ 1,10 @@
.CProgress {
    display: flex;
    align-items: center;
}
.CProgress .Progress {
    width: 100%;
}
.CProgress__content {
    margin-left: 8px;
}

A  => src/components/CProgress.js +13 -0
@@ 1,13 @@
import React from 'react';
import { Progress } from '@vkontakte/vkui';

import './CProgress.css';

export default ({ max, value }) => (
  <div className="CProgress">
    <Progress value={value / max * 100} />
    <div className="CProgress__content">
      {value} / {max}
    </div>
  </div>
);

A  => src/components/EventCard.js +32 -0
@@ 1,32 @@
import React from 'react';
import { Cell, CellButton, InfoRow } from '@vkontakte/vkui';
import CProgress from './CProgress';
import './event-block.css';

export default ({ event: { id, image, name, badges = [], about, max_count_peoples: maxPeople, active_helpers_go: peopleGoing }, limited = true, ...other }) => (
  <CellButton
    className={`event-block event-block--card${limited ? ' event-block--limited' : ''}`}
    {...other}
  >
    {image ? (
      <img
        src={`https://motionwebs.pro/api2/img/${image}`}
        alt="Изображение"
        className="event-block__image" />
    ) : null}
    <Cell>
      <h3>{name}</h3>
      {!limited && <div className="badges">
        {badges.map(badge => <span key={badge} className="badge">{badge}</span>)}
      </div>}
      {about && <p>{about}</p>}
    </Cell>
    {maxPeople !== undefined && maxPeople !== null && (
      <Cell className="event-block__people-progress">
        <InfoRow title="Принятые заявки" className="">
          <CProgress value={peopleGoing} max={maxPeople} />
        </InfoRow>
      </Cell>
    )}
  </CellButton>
);

A  => src/components/EventModal.js +114 -0
@@ 1,114 @@
import React, { useContext } from 'react';
import { Button, Cell, HeaderButton, InfoRow, ModalPage, ModalPageHeader } from '@vkontakte/vkui';
import Icon24Cancel from '@vkontakte/icons/dist/24/cancel';
import Icon24View from '@vkontakte/icons/dist/24/view';
import Icon24Write from '@vkontakte/icons/dist/24/write';
import Icon24Delete from '@vkontakte/icons/dist/24/delete';

import AuthContext from '../AuthContext';
import CProgress from './CProgress';

import './event-block.css';
import UserView from './UserView';

export default ({
  event: { id, author_id: ownerID, name, badges = [], image, about, requirements, views,
  max_count_peoples: maxPeople, active_helpers_go: peopleGoing }, onClose,
  showSubmitButton = true, submitRequest, isSubmitting,
  editEvent, deleteEvent, viewRequests, ...other
}) => {
  const { user } = useContext(AuthContext);
  // console.log(user, ownerID);

  return (
    <ModalPage
      onClose={onClose}
      header={
        <ModalPageHeader
          left={
            <HeaderButton onClick={onClose}>
              <Icon24Cancel className="header--icon" />
            </HeaderButton>
          }
        >
          Мероприятие
        </ModalPageHeader>
      }
      {...other}
    >
      <div className="event-block">
        {image ? (
          <div className="image-bg">
            <img
              src={`https://motionwebs.pro/api2/img/${image}`}
              alt="Изображение"
              className="image-bg__image"
            />
            <div className="image-bg__content">
              <h1>{name}</h1>
              {views && <div className="event-block__views">
                <Icon24View /> {views}
              </div>}
            </div>
          </div>
        ) : null}

        <Cell>
          {!image && <h1>{name}</h1>}
          <div className="badges">
            {badges.map(badge => <span key={badge} className="badge">{badge}</span>)}
          </div>
          {about && <p>{about}</p>}
          {!image && views && <div className="event-block__views">
            <Icon24View /> {views}
          </div>}
        </Cell>

        {requirements !== undefined && requirements !== null && (
          <Cell className="event-block__requirements">
            <InfoRow title="Требования к кандидатам">
              {requirements}
            </InfoRow>
          </Cell>
        )}
        {maxPeople !== undefined && maxPeople !== null && (
          <Cell>
            <InfoRow title="Принятые заявки" className="">
              <CProgress value={peopleGoing} max={maxPeople} />
            </InfoRow>
          </Cell>
        )}
        {ownerID !== undefined && ownerID !== null && (
          <UserView id={ownerID} description="Организатор" />
        )}

        {showSubmitButton && (~~user.role === 0 || ~~user.id === ~~ownerID) && <div className="event-block__button-wrapper">
          {(~~user.id === ~~ownerID && ~~user.role === 1)
            ? <Cell
                asideContent={<div style={{ display: 'flex' }}>
                  <Icon24Write onClick={editEvent} style={{ marginRight: 16 }} />
                  <Icon24Delete onClick={deleteEvent} fill="var(--destructive)" />
                </div>}
              >
                <Button
                  size="xl"
                  onClick={viewRequests}
                  level="secondary"
                >
                  Посмотреть заявки
                </Button>
              </Cell>
            : (~~user.role === 0 && <Cell>
                <Button
                  size="xl"
                  onClick={() => submitRequest(id)}
                  disabled={isSubmitting}
                >
                  Отправить заявку
                </Button>
              </Cell>)}
        </div>}
      </div>
    </ModalPage>
  );
}

A  => src/components/RequestView.js +30 -0
@@ 1,30 @@
import React from 'react';
import { Avatar, Cell, Button } from '@vkontakte/vkui';
import Icon16Recent from '@vkontakte/icons/dist/16/recent';
import Icon16Done from '@vkontakte/icons/dist/16/done';

export default ({ order: { id, status, status_name: statusText, event, user }, acceptMode = false, acceptRequest, deleteRequest, ...other }) => (
  <Cell
    id={id}
    size={acceptMode ? 'l' : 'm'}
    before={(
      <Avatar
        size={acceptMode ? 72 : 48}
        style={{ background: status ? 'var(--dynamic_green)' : 'var(--accent)' }}
        src={acceptMode ? user.ava : null}
      >
        {!acceptMode && (status ? <Icon16Done fill="#fff" width={24} height={24} /> : <Icon16Recent fill="#fff" width={24} height={24} />)}
      </Avatar>
    )}
    bottomContent={acceptMode ?
      <div style={{ display: 'flex' }}>
        {acceptRequest && <Button size="m" style={{ marginRight: 8 }} onClick={acceptRequest}>Принять</Button>}
        {deleteRequest && <Button size="m" level="secondary" onClick={deleteRequest}>Отклонить</Button>}
      </div>
    : null}
    description={status ? 'Принята' : 'На модерации'}
    {...other}
  >
    {event ? event.name : user.name}
  </Cell>
);

A  => src/components/UserView.js +25 -0
@@ 1,25 @@
import React, { useState, useEffect } from 'react';
import { Cell, Avatar, Spinner } from '@vkontakte/vkui';
import { callAPI } from '../utils/api';

export default ({ id, description, ...other }) => {
  const [ user, setUser ] = useState(null);

  useEffect(() => {
    callAPI('GET', `api1/vk/users/${id}`)
      .then(user => setUser(user));
  }, [id]);

  return (
    user !== null
      ? <Cell
          before={user.ava ? <Avatar src={user.ava} /> : null}
          description={description ? description : (user.about ? user.about : null)}
          onClick={() => window.location.href = `https://vk.com/id${user.vk_id}`}
          {...other}
        >
          {user.name}
        </Cell>
      : <div style={{ height: 68 }}><Spinner /></div>
  );
};

A  => src/components/event-block.css +98 -0
@@ 1,98 @@
.event-block {
}
.event-block--limited {
    width: 250px;
    flex-shrink: 0;
}
.card--gray .event-block--card {
    width: 200px;
}
.event-block--card .Cell > .Cell__in {
    padding: 0;
}
.event-block--card > .CellButton__in > .CellButton__content {
    width: 100%;
}
.event-block__image {
    width: 100%;
    height: 300px;
    object-fit: cover;
}
.event-block--card .event-block__image {
    height: 150px;
    border-radius: 8px;
}
.card--gray .event-block--card .event-block__image {
    height: 100px;
}
.event-block h1 {
    margin-top: 4px;
}
.event-block h3 {
    margin-top: 8px;
    margin-bottom: 8px;
}
.card--dark .event-block h1, .card--dark .event-block h3 {
    color: #fff;
}
.event-block p {
    margin: 0;
}
.event-block p, .event-block__requirements * {
    white-space: normal;
}
.card--dark .event-block p {
    color: #ccc;
}
.event-block--card p {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    text-overflow: ellipsis;
    overflow: hidden;
    color: #666;
    font-size: 14px;
}
.event-block__people-progress > .Cell__in > .Cell__main {
    padding-top: 0;
}
.card--dark .event-block__people-progress .InfoRow__title {
    color: #ccc;
}
.card--dark .event-block__people-progress .Progress__in {
    background: #fff;
}
.card--dark .event-block__people-progress .CProgress__content {
    color: #fff;
}
.event-block__views {
    display: flex;
    align-items: center;
    margin-top: 8px;
    opacity: .5;
}
.event-block__views .Icon {
    margin-right: 8px;
}
.event-block__button-wrapper {
    position: sticky;
    bottom: 0;
    margin-bottom: 16px;
    background: #fff;
    border-top: 1px solid #eee;
    z-index: 2;
}

.badges {
    margin: 4px 0 12px;
}
.badge {
    display: inline-block;
    margin-right: 8px;
    padding: 4px 12px;
    border-radius: 24px;
    color: #888;
    background: #ddd;
    font-size: 13px;
    font-weight: 500;
}

A  => src/img/hero-bg.jpg +0 -0
A  => src/img/persik.png +0 -0
A  => src/index.css +1 -0
@@ 1,1 @@
@import url('https://fonts.googleapis.com/css?family=Montserrat:700&display=swap&subset=cyrillic');

A  => src/index.js +21 -0
@@ 1,21 @@
import 'core-js/features/map';
import 'core-js/features/set';
import React from 'react';
import ReactDOM from 'react-dom';
import connect from '@vkontakte/vk-connect-promise';
import App from './App';
// import registerServiceWorker from './sw';
import './index.css';

// Init VK  Mini App
connect.send('VKWebAppInit', {});

// Если вы хотите, чтобы ваше веб-приложение работало в оффлайне и загружалось быстрее,
// расскомментируйте строку с registerServiceWorker();
// Но не забывайте, что на данный момент у технологии есть достаточно подводных камней
// Подробнее про сервис воркеры можно почитать тут — https://vk.cc/8MHpmT
// registerServiceWorker();

ReactDOM.render((
  <App />
), document.getElementById('root'));

A  => src/panels/Home.css +176 -0
@@ 1,176 @@
.PanelHeader__content {
    width: 100%;
    left: -12px;
}

.header {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    padding: 8px !important;
    z-index: 10;
    box-sizing: border-box;
}

.header--ios {
    height: 44px;
    padding-top: 4px !important;
    padding-bottom: 4px !important;
}
.header--android {
    height: 56px;
    padding-right: 116px !important;
}

.header__search {
    display: flex;
    align-items: center;
    height: 100%;
    padding-left: 12px;
    background: #fff;
    border-radius: 8px;
    box-shadow: rgba(0, 0, 0, .1) 0 2px 4px;
    overflow: hidden;
}

.header__search__icon {
    color: #888;
    margin-right: 16px;
}

.header__search__input {
    display: block;
    width: 100%;
    height: 24px;
    line-height: 1.4 !important;
    margin: 0;
    padding: 0;
    border: none;
    font: inherit;
    font-size: 16px;
    font-weight: 400;
}
.header__search__input:focus {
    outline: none;
}
.header__search__input::placeholder {
    color: #888;
}

.Panel.without-padding .Panel__in {
    padding-top: 0;
}

.hero {
    position: relative;
    height: 250px;
    background: url('../img/hero-bg.jpg') no-repeat center center;
    background-size: cover;
    z-index: 1;
    will-change: transform;
}
.hero__content {
    position: absolute;
    bottom: 0;
    width: 100%;
    padding: 16px 16px 32px;
    background: linear-gradient(to bottom, transparent 0%, rgb(100, 100, 150) 75%);
    color: #fff;
    font-family: 'Montserrat', sans-serif;
    font-size: 18px;
    font-weight: 700;
    z-index: 1;
    box-sizing: border-box;
}
.hero__content h1 {
    margin-bottom: 8px;
}

.Home__content {
    display: block;
    position: relative;
    margin-top: -16px;
    padding-top: 1px;
    border-radius: 16px;
    background: #fff;
    z-index: 2;
    box-sizing: border-box;
}
.Home__title {
    margin-left: 20px;
    margin-right: 24px;
    margin-bottom: 0;
    padding-top: 24px;
}

.card {
    margin: 16px;
    background: #fff;
    border-radius: 8px;
    box-shadow: rgba(0, 0, 0, .2) 0 1px 4px, rgba(0, 0, 0, .1) 0 4px 8px;
    overflow: hidden;
}
.card--dark {
    /*background: #ffdddd;*/
    background: linear-gradient(135deg, #f24973 0%, #3948e6 100%);
    color: #fff;
}
.card__title {
    padding: 24px 16px 8px;
    margin: 0;
}
.card--dark .card__title {
    /*color: rgb(255, 50, 50);*/
    /*background: linear-gradient(to bottom, rgba(230, 50, 50, .1) 0%, rgba(230, 50, 50, .3) 100%);*/
    /*color: #fff;*/
}

.image-bg {
    position: relative;
    height: 250px;
    z-index: 1;
}
.image-bg__image {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}
.image-bg__content {
    position: absolute;
    bottom: 0;
    width: 100%;
    padding: 16px;
    background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, .5) 75%);
    color: #fff;
    font-family: 'Montserrat', sans-serif;
    font-size: 24px;
    font-weight: 700;
    z-index: 1;
    box-sizing: border-box;
}
.image-bg__content > * {
    margin: 0;
}

.bottom-button {
    display: flex;
    justify-content: flex-end;
    margin: 16px;
    text-align: right;
}
.bottom-button .Avatar {
    width: 48px;
}
.bottom-button .Avatar__children {
    box-shadow: rgba(0, 0, 0, .2) 0 4px 4px;
}

.card--dark .Tappable--ios.Tappable--active:not([disabled]):not(.TabsItem):not(.HeaderButton):not(.Button):not(.PanelHeaderContent__in):not(.ActionSheetItem) {
    background: rgba(255, 255, 255, .3) !important;
    transition: none;
}

A  => src/panels/Home.js +501 -0
@@ 1,501 @@
import React, { useState, useEffect, useRef, useContext } from 'react';
import PropTypes from 'prop-types';
import {
  View,
  Panel,
  PanelHeader,
  Button,
  Group,
  Cell,
  HorizontalScroll,
  classnames,
  usePlatform,
  IOS,
  Spinner,
  ModalRoot,
  Avatar, FixedLayout, PanelHeaderBack, FormLayout, Input, Textarea, File, Alert, PullToRefresh
} from '@vkontakte/vkui';
import Snackbar from '@vkontakte/vkui/dist/components/Snackbar/Snackbar';

import Icon24Back from '@vkontakte/icons/dist/24/back';
import Icon24Search from '@vkontakte/icons/dist/24/search';
import Icon16Done from '@vkontakte/icons/dist/16/done';
import Icon16Cancel from '@vkontakte/icons/dist/16/cancel';
import Icon28AddOutline from '@vkontakte/icons/dist/28/add_outline';
import Icon24Camera from '@vkontakte/icons/dist/24/camera';

import { debounce } from 'lodash';
import useConstant from 'use-constant';

import { callAPI } from '../utils/api';

import './Home.css';
import AuthContext from '../AuthContext';
import EventModal from '../components/EventModal';
import EventCard from '../components/EventCard';
import Requests from './Requests';

const CSearch = ({ displayBack = false, onBack, ...other }) => {
  const platform = usePlatform();
  return (
    <div
      className={classnames(
        'header',
        platform === IOS ? 'header--ios' : 'header--android'
      )}
    >
      <div className="header__search">
        {displayBack
          ? <Icon24Back className="header__search__icon" onClick={onBack} />
          : <Icon24Search className="header__search__icon" />}
        <input type="text" className="header__search__input" placeholder="Поиск" {...other} />
      </div>
    </div>
  );
};

const Home = ({ id }) => {
  const [ events, setEvents ] = useState(null);
  const [ hotEvents, setHotEvents ] = useState(null);

  const [ activePanel, setActivePanel ] = useState('main');
  const [ activeModal, setActiveModal ] = useState(null);
  const [ isSubmitting, setIsSubmitting ] = useState(false);
  const [ succeeded, setSucceeded ] = useState(null);

  const [ nameField, setNameField ] = useState('');
  const [ aboutField, setAboutField ] = useState('');
  const [ skillsField, setSkillsField ] = useState('');
  const [ maxPeopleField, setMaxPeopleField ] = useState(100);
  const [ photoField, setPhotoField ] = useState(null);
  const [ editingEventID, setEditingEventID ] = useState(null);

  const [ popout, setPopout ] = useState(null);

  const [ requestsModalEventID, setRequestsModalEventID ] = useState(null);

  const [ searchQuery, setSearchQuery ] = useState('');
  const [ searchResults, setSearchResults ] = useState([]);

  const allEventsIDs = [];
  const allEvents = [];
  if (events) events.forEach((event) => {
    if (!allEventsIDs.includes(~~event.id)) {
      allEventsIDs.push(~~event.id);
      allEvents.push(event);
    }
  });
  if (hotEvents) hotEvents.forEach((event) => {
    if (!allEventsIDs.includes(~~event.id)) {
      allEventsIDs.push(~~event.id);
      allEvents.push(event);
    }
  });
  if (setSearchResults && searchResults.length > 0) {
    hotEvents.forEach((event) => {
      if (!allEventsIDs.includes(~~event.id)) {
        allEventsIDs.push(~~event.id);
        allEvents.push(event);
      }
    });
  }

  const modalRootRef = useRef();
  const { user } = useContext(AuthContext);

  useEffect(() => {
    if (activeModal) {
      callAPI('POST', 'api2/api.php?addView&id=' + activeModal.replace('event-', ''), null, user.token);
    }
  }, [activeModal]);

  useEffect(() => {
    callAPI('GET', 'api2/api.php?findAll')
      .then(events => setEvents(events));
    callAPI('GET', 'api1/api/events/hot')
      .then(events => setHotEvents(events));
  }, []);

  useEffect(() => {
    modalRootRef.current.initModalsState();
  }, [events, hotEvents]);

  const refetch = () => {
    setNameField('');
    setPhotoField(null);
    setAboutField('');
    setSkillsField('');
    setMaxPeopleField(100);
    setEditingEventID(null);

    setEvents(null);
    callAPI('GET', 'api2/api.php?findAll')
      .then(events => setEvents(events));

    setHotEvents(null);
    callAPI('GET', 'api1/api/events/hot')
      .then(events => setHotEvents(events));

    setActivePanel('main');
  };

  const submitRequest = (id) => {
    setIsSubmitting(true);
    callAPI('POST', `api1/api/orders/events/${id}`, null, user.token)
      .then((data) => {
        setIsSubmitting(false);
        setActiveModal(null);
        if (data.success === true) {
          setSucceeded(true);
        } else {
          setSucceeded(false);
        }
      });
  };

  const editEvent = (event) => {
    setNameField(event.name);
    setAboutField(event.about);
    setSkillsField(event.requirements);
    setMaxPeopleField(event.max_count_peoples);
    setEditingEventID(event.id);
    setActivePanel('edit-event');
    setActiveModal(null);
  };

  const deleteEvent = (event, del = false) => {
    setActiveModal(null);
    if (del) {
      setPopout(null);
      callAPI('POST', 'api2/api.php', {
        deleteProject: 1,
        projectId: event.id,
      }, user.token)
        .then(() => {
          refetch();
        });
      return;
    }
    setPopout((
      <Alert
        actionsLayout="horizontal"
        actions={[{
          title: 'Отмена',
          style: 'cancel',
          autoclose: true,
        }, {
          title: 'Удалить',
          style: 'destructive',
          action: () => deleteEvent(event, true),
        }]}
        onClose={() => setPopout(null)}
      >
        <h2>Удалить объявление</h2>
        Вы уверены, что хотите удалить объявление?
      </Alert>
    ));
  };

  const handleAddEventFormSubmit = (ev) => {
    ev.preventDefault();
    const formData = new FormData();
    formData.set('createProject', 1);
    formData.set('name', nameField);
    formData.set('images', photoField);
    formData.set('about', aboutField);
    formData.set('requirements', skillsField);
    formData.set('max_count_peoples', maxPeopleField);
    callAPI('POST', 'api2/api.php', formData, user.token)
      .then(() => {
        refetch();
      });
  };

  const handleEditEventFormSubmit = (ev) => {
    ev.preventDefault();
    callAPI('POST', 'api2/api.php', {
      updateProject: 1,
      id: editingEventID,
      name: nameField,
      about: aboutField,
      requirements: skillsField,
      max_count_peoples: maxPeopleField,
    }, user.token)
      .then(() => {
        refetch();
      });
  };

  const performSearch = useConstant(() => debounce((query) => {
    callAPI('POST', 'api2/api.php', { findByName: 1, name: query })
      .then((resp) => {
        setSearchResults(resp);
      });
  }, 300));

  useEffect(() => {
    if (searchQuery.trim().length >= 3) {
      performSearch(searchQuery.trim());
    }
  }, [searchQuery]);

  const heroRef = useRef();
  useEffect(() => {
    let stop = false;
    function start() {
      window.requestAnimationFrame(() => {
        if (stop) return;
        if (heroRef && heroRef.current) {
          heroRef.current.style.opacity = Math.max(0, (window.scrollY / 100 - 1) * -1);
        }
        start();
      });
    }
    start();

    return () => {
      stop = true;
    };
  }, []);

  return (
    <View
      id={id}
      popout={popout}
      activePanel={activePanel}
      modal={(
        <ModalRoot ref={modalRootRef} activeModal={activeModal}>
          {allEvents ? allEvents.map((event) => (
            <EventModal
              key={event.id}
              id={`event-${event.id}`}
              onClose={() => setActiveModal(null)}
              submitRequest={submitRequest}
              viewRequests={() => {
                setRequestsModalEventID(event.id);
                setActiveModal(null);
                setActivePanel('requests');
              }}
              editEvent={() => editEvent(event)}
              deleteEvent={() => deleteEvent(event)}
              isSubmitting={isSubmitting}
              event={event}
            />
          )) : []}
        </ModalRoot>
      )}
    >
      <Panel id="main" theme={searchQuery ? 'gray' : 'white'} className="without-padding">
        <PanelHeader transparent noShadow>
          <CSearch
            displayBack={searchQuery.trim()}
            onBack={() => setSearchQuery('')}
            value={searchQuery}
            onChange={ev => setSearchQuery(ev.target.value)}
          />
        </PanelHeader>

        {searchQuery
          ? searchResults && searchResults.length > 0 && (
              <Group style={{ marginTop: 64 }} title="Результаты поиска">
                {searchResults.map((event) => (
                  <Cell
                    key={event.id}
                    before={<Avatar size={64} src={`https://motionwebs.pro/api2/img/${event.image}`} />}
                    description={event.requirements}
                    onClick={() => setActiveModal(`event-${event.id}`)}
                  >
                    {event.name}
                  </Cell>
                ))}
              </Group>
            )
          : <PullToRefresh onRefresh={() => refetch()} isFetching={events === null || hotEvents === null}>
              <div style={{ background: 'var(--accent)' }}>
                <div ref={heroRef} className="hero">
                  <div className="hero__content">
                    <h1>Каталог объявлений</h1>
                    {user.role === 0 ? 'Найди твою любимуо работу и расширь свой опыт!' : 'Опубликуйте объявление о нужных кадрах и получите их в короткие сроки.'}
                  </div>
                </div>
              </div>

              <div className="Home__content">
                <div className="card card--dark">
                  <h1 className="card__title">Горячие объявления</h1>
                  {hotEvents ? (
                    <HorizontalScroll>
                      <div style={{ display: 'flex', alignItems: 'flex-start' }}>
                        {hotEvents.map((event) => (
                          <EventCard
                            key={event.id}
                            event={event}
                            onClick={() => setActiveModal(`event-${event.id}`)}
                          />
                        ))}
                      </div>
                    </HorizontalScroll>
                  ) : <div style={{ padding: 24 }}><Spinner size="large" /></div>}
                </div>

                <h1 className="Home__title">Мероприятия</h1>
                {events ? events.map((event) => (
                  <div key={event.id} className="card">
                    <EventCard
                      event={event}
                      limited={false}
                      onClick={() => setActiveModal(`event-${event.id}`)}
                    />
                  </div>
                )) : <div style={{ padding: 24 }}><Spinner size="large" /></div>}
              </div>

              {user.role ? <FixedLayout vertical="bottom">
                <div className="bottom-button">
                  <Avatar
                    size={48}
                    style={{ background: 'var(--accent)' }}
                  >
                    <Icon28AddOutline fill="#fff" onClick={() => setActivePanel('new-event')} />
                  </Avatar>
                </div>
              </FixedLayout> : null}
            </PullToRefresh>}


        {succeeded === true && <Snackbar
          layout="vertical"
          onClose={() => setSucceeded(null)}
          before={<Avatar size={24} style={{ background: 'var(--accent)' }}>
            <Icon16Done fill="#fff" width={14} height={14} />
          </Avatar>}
        >
          Заявка успешно отправлена!
        </Snackbar>}

        {succeeded === false && <Snackbar
          layout="vertical"
          onClose={() => setSucceeded(null)}
          before={<Avatar size={24} style={{ background: 'var(--destructive)' }}>
            <Icon16Cancel fill="#fff" width={14} height={14} />
          </Avatar>}
        >
          Вы уже отправляли заявку, или лимит заявок превышен.
        </Snackbar>}
      </Panel>
      <Panel id="new-event">
        <PanelHeader
          left={<PanelHeaderBack onClick={() => setActivePanel('main')} />}
        >
          Добавить объявление
        </PanelHeader>

        <FormLayout onSubmit={handleAddEventFormSubmit}>
          <Input
            required
            type="text"
            name="name"
            top="Название мероприятия"
            value={nameField}
            onChange={(ev) => setNameField(ev.target.value)}
          />
          <File
            required
            name="photo"
            top="Фото мероприятия"
            before={<Icon24Camera />}
            size="xl"
            level="secondary"
            onChange={(ev) => setPhotoField(ev.target.files[0])}
          >
            Открыть галерею
          </File>
          <Textarea
            required
            name="about"
            top="Описание"
            value={aboutField}
            onChange={(ev) => setAboutField(ev.target.value)}
          />
          <Input
            required
            type="text"
            name="skills"
            top="Требования к кандидатам (через запятую)"
            value={skillsField}
            onChange={(ev) => setSkillsField(ev.target.value)}
          />
          <Input
            required
            type="number"
            name="max_people_count"
            top="Максимальное кол-во заявок"
            value={maxPeopleField.toString()}
            onChange={(ev) => setMaxPeopleField(ev.target.value)}
          />

          <Button size="xl" type="submit">Добавить</Button>
        </FormLayout>
      </Panel>
      <Panel id="edit-event">
        <PanelHeader
          left={<PanelHeaderBack onClick={() => setActivePanel('main')} />}
        >
          Редактировать объявление
        </PanelHeader>

        <FormLayout onSubmit={handleEditEventFormSubmit}>
          <Input
            required
            type="text"
            name="name"
            top="Название мероприятия"
            value={nameField}
            onChange={(ev) => setNameField(ev.target.value)}
          />
          <Textarea
            required
            name="about"
            top="Описание"
            value={aboutField}
            onChange={(ev) => setAboutField(ev.target.value)}
          />
          <Input
            required
            type="text"
            name="skills"
            top="Требования к кандидатам (через запятую)"
            value={skillsField}
            onChange={(ev) => setSkillsField(ev.target.value)}
          />
          <Input
            required
            type="number"
            name="max_people_count"
            top="Максимальное кол-во заявок"
            value={maxPeopleField.toString()}
            onChange={(ev) => setMaxPeopleField(ev.target.value)}
          />

          <Button size="xl" type="submit">Сохранить</Button>
        </FormLayout>
      </Panel>
      <Requests id="requests" eventID={requestsModalEventID} goBack={() => setActivePanel('main')} />
    </View>
  );
};

Home.propTypes = {
  id: PropTypes.string.isRequired,
  go: PropTypes.func.isRequired,
  fetchedUser: PropTypes.shape({
    photo_200: PropTypes.string,
    first_name: PropTypes.string,
    last_name: PropTypes.string,
    city: PropTypes.shape({
      title: PropTypes.string
    })
  })
};

export default Home;

A  => src/panels/Profile.css +6 -0
@@ 1,6 @@
.Persik {
  display: block;
  width: 30%;
  max-width: 240px;
  margin: 20px auto;
}

A  => src/panels/Profile.js +180 -0
@@ 1,180 @@
import React, { useState, useEffect, useContext, useRef } from 'react';
import PropTypes from 'prop-types';
import {
  View,
  Button,
  Panel,
  PanelHeader,
  PanelHeaderBack,
  Cell,
  Avatar,
  Group,
  InfoRow,
  ModalRoot,
  FormLayout, Textarea, Spinner
} from '@vkontakte/vkui';
import Icon24Write from '@vkontakte/icons/dist/24/write';

import AuthContext from '../AuthContext';
import EventModal from '../components/EventModal';

import { callAPI } from '../utils/api';

import persik from '../img/persik.png';
import './Profile.css';
import RequestView from '../components/RequestView';

const Profile = ({ id, ...props }) => {
  const [ activePanel, setActivePanel ] = useState('view');
  const [ activeModal, setActiveModal ] = useState(null);
  const [ profile, setProfile ] = useState(null);

  const [ aboutMeText, setAboutMeText ] = useState('');
  const [ skillsText, setSkillsText ] = useState('');
  const [ contactsText, setContactsText ] = useState('');

  const [ isSaving, setIsSaving ] = useState(false);

  const { user } = useContext(AuthContext);
  const modalRootRef = useRef();

  useEffect(() => {
    fetchProfile();
  }, []);

  useEffect(() => {
    modalRootRef.current.initModalsState();
  }, [profile]);

  const fetchProfile = (forceUpdateFormFields = false) => {
    callAPI('POST', 'api1/profile', null, user.token)
      .then((profile) => {
        setProfile(profile);
        if (aboutMeText === '' || forceUpdateFormFields) setAboutMeText(profile.info.about_me.trim());
        if (skillsText === '' || forceUpdateFormFields) setSkillsText(profile.info.skills.trim());
        if (contactsText === '' || forceUpdateFormFields) setContactsText(profile.info.contacts.trim());
      });
  };

  const saveChanges = () => {
    setIsSaving(true);
    const request = {
      contacts: contactsText,
      skills: skillsText,
      about_me: aboutMeText,
    };
    callAPI('PATCH', 'api1/profile', request, user.token)
      .then((profile) => {
        setIsSaving(false);
        setActivePanel('view');
        fetchProfile(true);
      });
  };

  return (
    <View id={id} activePanel={activePanel} modal={
      <ModalRoot ref={modalRootRef} activeModal={activeModal}>
        {profile ? profile.orders.map(order => order.event).map((event) => (
          <EventModal
            key={id}
            id={`event-${event.id}`}
            onClose={() => setActiveModal(null)}
            showSubmitButton={false}
            event={event}
          />
        )) : []}
      </ModalRoot>
    }>
      <Panel id="view">
        <PanelHeader>
          Профиль
        </PanelHeader>

        {!profile && <Spinner size="large" style={{ marginTop: 64 }} />}

        {profile && (
          <Group>
            <Cell
              before={
                profile.info.ava ? (
                  <Avatar src={profile.info.ava} />
                ) : null
              }
              asideContent={<Icon24Write onClick={() => setActivePanel('edit')} />}
              description={
                profile.info.role === 1 ? 'Организатор' : 'Волонтёр'
              }
            >
              {profile.info.name}
            </Cell>
            <Cell>
              <InfoRow title="О себе">
                {profile.info.about_me.trim() || 'Пусто'}
              </InfoRow>
            </Cell>
            <Cell>
              <InfoRow title="Навыки">
                {profile.info.skills.trim() || 'Пусто'}
              </InfoRow>
            </Cell>
            <Cell>
              <InfoRow title="Контакты">
                {profile.info.contacts.trim() || 'Пусто'}
              </InfoRow>
            </Cell>
          </Group>
        )}

        {profile && (profile.orders.length > 0 ? (
          <Group title="Отправленные заявки">
            {profile.orders.map((order) => (
              <RequestView
                key={order.id}
                order={order}
                onClick={() => setActiveModal(`event-${order.event.id}`)}
              />
            ))}
          </Group>
        ) : <Cell>Заявок нет :P</Cell>)}

        <img className="Persik" src={persik} alt="Persik The Cat" />
      </Panel>
      <Panel id="edit" theme="white">
        <PanelHeader left={<PanelHeaderBack onClick={() => setActivePanel('view')} />}>
          Редактирование
        </PanelHeader>

        <FormLayout>
          <Textarea
            top="О себе"
            value={aboutMeText}
            onChange={(ev) => setAboutMeText(ev.target.value)}
          />

          <Textarea
            top="Мои навыки"
            value={skillsText}
            onChange={(ev) => setSkillsText(ev.target.value)}
          />

          <Textarea
            top="Контакты"
            value={contactsText}
            onChange={(ev) => setContactsText(ev.target.value)}
          />

          <Button size="xl" onClick={() => saveChanges()} disabled={isSaving}>
            Сохранить
          </Button>
        </FormLayout>
      </Panel>
    </View>
  );
};

Profile.propTypes = {
  id: PropTypes.string.isRequired,
  go: PropTypes.func.isRequired,
};

export default Profile;

A  => src/panels/Requests.js +80 -0
@@ 1,80 @@
import React, { useState, useEffect, useContext } from 'react';
import { Div, Group, Panel, PanelHeader, Spinner, Cell, PanelHeaderBack } from '@vkontakte/vkui';
import { callAPI } from '../utils/api';
import AuthContext from '../AuthContext';
import RequestView from '../components/RequestView';

export default ({ id, eventID, goBack }) => {
  const [ requests, setRequests ] = useState(null);
  const { user } = useContext(AuthContext);

  useEffect(() => {
    refetch();
  }, []);

  const refetch = () => {
    callAPI('GET', `api1/api/orders/events/${eventID}`, null, user.token)
      .then((resp) => {
        if (resp === null) {
          setRequests(-1);
        } else {
          setRequests(resp);
        }
      });
  };

  const acceptRequest = (id) => {
    callAPI('PUT', `api1/api/orders/events/${id}/1`, null, user.token)
      .then(() => {
        refetch();
      });
  };

  const deleteRequest = (id) => {
    callAPI('DELETE', `api1/api/orders/events/${id}`, null, user.token)
      .then(() => {
        refetch();
      });
  };

  return (
    <Panel id={id}>
      <PanelHeader left={<PanelHeaderBack onClick={goBack} />}>
        Заявки
      </PanelHeader>
      {requests === null ? <Spinner size="large" /> : (requests === -1 ? <Div>Нет заявок.</Div> : (
        <>
          <Group title="Конверсия">
            <Cell>
              <h1 style={{ margin: 0, marginBottom: 8 }}>
                {parseFloat(requests.analytics.convercion).toFixed(2)}% конверсии
              </h1>
            </Cell>
          </Group>
          <Group title="Заявки">
            {requests.orders.filter(order => order.status === 0).map((order) => (
              <RequestView
                key={order.id}
                order={order}
                acceptMode={true}
                acceptRequest={() => acceptRequest(order.id)}
                deleteRequest={() => deleteRequest(order.id)}
                // onClick={() => window.location.href = `https://vk.com/id${order.user.vk_id}`}
              />
            ))}
          </Group>
          <Group title="Принятые заявки">
            {requests.orders.filter(order => order.status === 1).map((order) => (
              <RequestView
                key={order.id}
                order={order}
                acceptMode={true}
                deleteRequest={() => deleteRequest(order.id)}
              />
            ))}
          </Group>
        </>
      ))}
    </Panel>
  )
};

A  => src/panels/RoleChooser.css +23 -0
@@ 1,23 @@
.Panel.role-chooser > .Panel__in {
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 8px;
    background: linear-gradient(135deg, #f24973 0%, #3948e6 100%) !important;
    color: #fff;
}
.role-chooser .title {
    font-family: 'Montserrat', sans-serif;
    font-size: 48px;
    font-weight: 700;
    margin-bottom: 16px;
}
.role-chooser p {
    max-width: 300px;
    font-size: 18px;
    line-height: 1.4;
}
.role-chooser .Button {
    background: #fff;
    color: var(--accent);
}

A  => src/panels/RoleChooser.js +40 -0
@@ 1,40 @@
import React, { useContext } from 'react';
import { View, Panel, Div, Button } from '@vkontakte/vkui';
import './RoleChooser.css';
import { authUser } from '../utils/api';
import AuthContext from '../AuthContext';

export default ({ id, vkUser }) => {
  const { setUser } = useContext(AuthContext);

  const registerUser = (role) => {
    authUser(vkUser, role)
      .then(() => {
        authUser(vkUser)
          .then((user) => {
            user.id = ~~user.id;
            user.role = ~~user.role;
            setUser(user);
          });
      });
  };

  return (
    <View id={id} activePanel={id}>
      <Panel id={id} className="role-chooser">
        <div>
          <Div>
            <h1 className="title">Привет!</h1>
            <p>Выберите свою роль, чтобы начать пользоваться приложением.</p>
          </Div>
          <Div>
            <Button size="xl" onClick={() => registerUser(0)}>Я волонтёр</Button>
          </Div>
          <Div>
            <Button size="xl" onClick={() => registerUser(1)}>Я организатор</Button>
          </Div>
        </div>
      </Panel>
    </View>
  )
}

A  => src/sw.js +111 -0
@@ 1,111 @@
// In production, we register a service worker to serve assets from local cache.

// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.

// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.

function registerValidSW(swUrl) {
	navigator.serviceWorker
		.register(swUrl)
		.then((registration) => {
			registration.onupdatefound = () => {
				const installingWorker = registration.installing;
				installingWorker.onstatechange = () => {
					if (installingWorker.state === 'installed') {
						if (navigator.serviceWorker.controller) {
							// At this point, the old content will have been purged and
							// the fresh content will have been added to the cache.
							// It's the perfect time to display a "New content is
							// available; please refresh." message in your web app.
							console.log('New content is available; please refresh.');
						} else {
							// At this point, everything has been precached.
							// It's the perfect time to display a
							// "Content is cached for offline use." message.
							console.log('Content is cached for offline use.');
						}
					}
				};
			};
		})
		.catch((error) => {
			console.error('Error during service worker registration:', error);
		});
}

function checkValidServiceWorker(swUrl) {
	// Check if the service worker can be found. If it can't reload the page.
	fetch(swUrl)
		.then((response) => {
			// Ensure service worker exists, and that we really are getting a JS file.
			if (
				response.status === 404 ||
				response.headers.get('content-type').indexOf('javascript') === -1
			) {
				// No service worker found. Probably a different app. Reload the page.
				navigator.serviceWorker.ready.then((registration) => {
					registration.unregister().then(() => {
						window.location.reload();
					});
				});
			} else {
				// Service worker found. Proceed as normal.
				registerValidSW(swUrl);
			}
		})
		.catch(() => {
			console.log('No internet connection found. App is running in offline mode.');
		});
}

const isLocalhost = Boolean(window.location.hostname === 'localhost' ||
	// [::1] is the IPv6 localhost address.
	window.location.hostname === '[::1]' ||
	// 127.0.0.1/8 is considered localhost for IPv4.
	window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/));

export default function register() {
	if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
		// The URL constructor is available in all browsers that support SW.
		const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
		if (publicUrl.origin !== window.location.origin) {
			// Our service worker won't work if PUBLIC_URL is on a different origin
			// from what our page is served on. This might happen if a CDN is used to
			// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
			return;
		}

		window.addEventListener('load', () => {
			const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;

			if (isLocalhost) {
				// This is running on localhost. Lets check if a service worker still exists or not.
				checkValidServiceWorker(swUrl);

				// Add some additional logging to localhost, pointing developers to the
				// service worker/PWA documentation.
				navigator.serviceWorker.ready.then(() => {
					console.log(
						'This web app is being served cache-first by a service ' +
						'worker. To learn more, visit https://goo.gl/SC7cgQ'
					);
				});
			} else {
				// Is not local host. Just register service worker
				registerValidSW(swUrl);
			}
		});
	}
}

export function unregister() {
	if ('serviceWorker' in navigator) {
		navigator.serviceWorker.ready.then((registration) => {
			registration.unregister();
		});
	}
}

A  => src/utils/api.js +34 -0
@@ 1,34 @@
export const callAPI = async (method, url, params = null, auth = null) => {
  // url = 'http://webstage.beget.tech/' + url;
  url = 'https://motionwebs.pro/' + url;
  const config = { method, headers: new Headers() };
  if (['POST', 'PUT', 'PATCH'].includes(method) && params) {
    if (params instanceof FormData) {
      config.body = params;
    } else {
      config.body = Object.entries(params)
        .map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
      config.headers.set('Content-Type', 'application/x-www-form-urlencoded');
    }
  }
  if (auth) config.headers.set('Authorization', 'Bearer ' + auth);

  return await fetch(url, config)
    .then(resp => resp.json())
    .then((resp) => {
      if (resp.data !== undefined) {
        return resp.data;
      } else return resp;
    });
};

export const authUser = (vkUser, role = 0) => {
  return callAPI('POST', `api1/vk/auth/${role}`, {
    vk_user_id: vkUser.id,
    name: `${vkUser.first_name} ${vkUser.last_name}`,
    country: vkUser.country.title,
    sex: vkUser.sex,
    photo: vkUser.photo_200,
    vk_startup_params: window.location.search.slice(1)
  });
};