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)
+ });
+};