Initial commit
190
.eslintrc.js
Normal file
@@ -0,0 +1,190 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
`airbnb`,
|
||||
`airbnb/hooks`,
|
||||
`plugin:promise/recommended`,
|
||||
`plugin:sonarjs/recommended`,
|
||||
`plugin:prettier/recommended`,
|
||||
],
|
||||
plugins: [`optimize-regex`, `simple-import-sort`, `styled-components-varname`],
|
||||
ignorePatterns: [`**/dist/**/*.*`, `src/page/Game/*`, `vite.config.ts`, `dev-dist`],
|
||||
rules: {
|
||||
'react/prop-types': `off`,
|
||||
'sonarjs/prefer-single-boolean-return': `off`,
|
||||
'import/no-default-export': `off`,
|
||||
'no-param-reassign': `off`,
|
||||
'react/react-in-jsx-scope': `off`,
|
||||
'import/prefer-default-export': [`off`],
|
||||
'@typescript-eslint/no-unsafe-member-access': `off`,
|
||||
'@typescript-eslint/no-unsafe-assignment': `off`,
|
||||
'import/no-extraneous-dependencies': [`error`, { devDependencies: true }],
|
||||
'react/function-component-definition': [
|
||||
`error`,
|
||||
{
|
||||
namedComponents: `arrow-function`,
|
||||
},
|
||||
],
|
||||
'react/jsx-props-no-spreading': [`off`],
|
||||
'react/require-default-props': [`off`],
|
||||
'no-restricted-syntax': [`error`, `ForInStatement`, `LabeledStatement`, `WithStatement`],
|
||||
'no-plusplus': [`off`],
|
||||
'no-void': [
|
||||
`error`,
|
||||
{
|
||||
allowAsStatement: true,
|
||||
},
|
||||
],
|
||||
'func-names': [`warn`, `as-needed`, { generators: `never` }],
|
||||
curly: [`error`, `all`],
|
||||
'@typescript-eslint/no-use-before-define': [`error`, { functions: false, classes: false }],
|
||||
'simple-import-sort/imports': [
|
||||
`error`,
|
||||
{
|
||||
groups: [[`^react`, `^@?\\w`]],
|
||||
},
|
||||
],
|
||||
'optimize-regex/optimize-regex': `warn`,
|
||||
'@typescript-eslint/no-magic-numbers': [
|
||||
`warn`,
|
||||
{
|
||||
ignoreNumericLiteralTypes: true,
|
||||
ignoreEnums: true,
|
||||
enforceConst: true,
|
||||
ignoreReadonlyClassProperties: true,
|
||||
ignore: [-1, 0, 1],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/quotes': [`error`, `backtick`],
|
||||
'newline-before-return': `error`,
|
||||
'react/self-closing-comp': [
|
||||
`error`,
|
||||
{
|
||||
component: true,
|
||||
html: true,
|
||||
},
|
||||
],
|
||||
'prettier/prettier': [
|
||||
`error`,
|
||||
{
|
||||
printWidth: 120,
|
||||
tabWidth: 4,
|
||||
useTabs: true,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: `all`,
|
||||
bracketSpacing: true,
|
||||
jsxBracketSameLine: false,
|
||||
arrowParens: `avoid`,
|
||||
},
|
||||
{ usePrettierrc: false },
|
||||
],
|
||||
'styled-components-varname/varname': [
|
||||
`error`,
|
||||
{
|
||||
tagStyle: {
|
||||
suffix: `Styled`,
|
||||
},
|
||||
},
|
||||
],
|
||||
'import/no-extraneous-dependencies': [
|
||||
`error`,
|
||||
{
|
||||
devDependencies: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [`*.ts`, `*.tsx`],
|
||||
extends: [
|
||||
`airbnb-typescript`,
|
||||
`plugin:@typescript-eslint/recommended`,
|
||||
`plugin:@typescript-eslint/recommended-requiring-type-checking`,
|
||||
`plugin:prettier/recommended`,
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
`error`,
|
||||
{
|
||||
checksVoidReturn: {
|
||||
attributes: false,
|
||||
returns: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
`error`,
|
||||
{
|
||||
allowExpressions: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': [
|
||||
`error`,
|
||||
{
|
||||
ignoreIIFE: true,
|
||||
ignoreVoid: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unnecessary-condition': [`error`],
|
||||
'import/no-extraneous-dependencies': [
|
||||
`error`,
|
||||
{
|
||||
devDependencies: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/quotes': [`error`, `backtick`],
|
||||
'@typescript-eslint/no-unused-vars': [`error`],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [`*.json`],
|
||||
parser: `@typescript-eslint/parser`,
|
||||
parserOptions: {
|
||||
sourceType: `module`,
|
||||
project: false,
|
||||
},
|
||||
rules: {
|
||||
'no-unused-expressions': `off`,
|
||||
'sonarjs/no-duplicate-string': `off`,
|
||||
quotes: `off`,
|
||||
'@typescript-eslint/quotes': `off`,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [`**/*.spec.ts`, `**/*.spec.tsx`, `**/dsl/**`],
|
||||
extends: [`plugin:jest/recommended`],
|
||||
rules: {
|
||||
'jest/no-focused-tests': `error`,
|
||||
'jest/no-identical-title': `error`,
|
||||
|
||||
'no-shadow': `off`,
|
||||
'max-classes-per-file': `off`,
|
||||
|
||||
'@typescript-eslint/no-magic-numbers': `off`,
|
||||
'@typescript-eslint/indent': `off`,
|
||||
'@typescript-eslint/explicit-function-return-type': `off`,
|
||||
'@typescript-eslint/member-ordering': `off`,
|
||||
'@typescript-eslint/no-non-null-assertion': `off`,
|
||||
'@typescript-eslint/no-empty-function': `off`,
|
||||
|
||||
'sonarjs/no-duplicate-string': `off`,
|
||||
'sonarjs/no-identical-functions': `off`,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [`*.js`],
|
||||
rules: {
|
||||
'@typescript-eslint/no-var-requires': `off`,
|
||||
},
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
project: `tsconfig.json`,
|
||||
sourceType: `module`,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: `detect`,
|
||||
},
|
||||
},
|
||||
}
|
||||
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
build
|
||||
9
.prettierrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"useTabs": true,
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
85
package.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "ridebus-lite",
|
||||
"author": "Xtimms",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "https://ridebus.by",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"lint:fix": "eslint --fix --ext .ts,.tsx ."
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@maptiler/sdk": "^1.0.9",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/styled-components": "^5.1.26",
|
||||
"@types/three": "^0.150.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.2",
|
||||
"@typescript-eslint/parser": "^5.48.2",
|
||||
"@vitejs/plugin-react": "^3.0.1",
|
||||
"eslint": "^8.32.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-better-styled-components": "latest",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jest": "^27.2.1",
|
||||
"eslint-plugin-json": "^3.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-optimize-regex": "^1.2.1",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.1.4",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.32.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^9.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.18.0",
|
||||
"eslint-plugin-styled-components-varname": "^1.0.1",
|
||||
"gh-pages": "^4.0.0",
|
||||
"i18next": "^22.4.9",
|
||||
"leaflet": "^1.9.3",
|
||||
"prettier": "^2.8.3",
|
||||
"query-string": "^8.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-ga": "^3.3.1",
|
||||
"react-i18next": "^12.1.4",
|
||||
"react-inlinesvg": "^3.0.1",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.7.0",
|
||||
"react-select": "^5.7.0",
|
||||
"react-spring-bottom-sheet": "^3.4.1",
|
||||
"react-yandex-metrika": "^2.6.0",
|
||||
"rollup-plugin-visualizer": "^5.9.0",
|
||||
"styled-components": "^6.0.4",
|
||||
"three": "^0.151.3",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.4",
|
||||
"vite-plugin-checker": "^0.5.4",
|
||||
"vite-plugin-mkcert": "^1.11.0",
|
||||
"vite-plugin-pwa": "^0.14.7",
|
||||
"vite-tsconfig-paths": "^4.0.5"
|
||||
},
|
||||
"packageManager": "yarn@3.4.1"
|
||||
}
|
||||
6910
pnpm-lock.yaml
generated
Normal file
40
public/404.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Single Page Apps for GitHub Pages</title>
|
||||
<script type="text/javascript">
|
||||
// Single Page Apps for GitHub Pages
|
||||
// MIT License
|
||||
// https://github.com/rafgraph/spa-github-pages
|
||||
// This script takes the current url and converts the path and query
|
||||
// string into just a query string, and then redirects the browser
|
||||
// to the new url with only a query string and hash fragment,
|
||||
// e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
|
||||
// https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
|
||||
// Note: this 404.html file must be at least 512 bytes for it to work
|
||||
// with Internet Explorer (it is currently > 512 bytes)
|
||||
|
||||
// If you're creating a Project Pages site and NOT using a custom domain,
|
||||
// then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
|
||||
// This way the code will only replace the route part of the path, and not
|
||||
// the real directory in which the app resides, for example:
|
||||
// https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
|
||||
// https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
|
||||
// Otherwise, leave pathSegmentsToKeep as 0.
|
||||
var pathSegmentsToKeep = 0;
|
||||
|
||||
var l = window.location;
|
||||
l.replace(
|
||||
l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') +
|
||||
l.pathname.split('/').slice(0, 1 + pathSegmentsToKeep).join('/') + '/?/' +
|
||||
l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
|
||||
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
|
||||
l.hash
|
||||
);
|
||||
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/logo192.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/logo512.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
36
public/manifest.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "/",
|
||||
"short_name": "RideBus",
|
||||
"name": "RideBus PWA",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "РСУ",
|
||||
"url": "/?stop=5"
|
||||
},
|
||||
{
|
||||
"name": "Швейная фабрика",
|
||||
"url": "/?stop=33"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
7
public/sitemap.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://pwa.ridebus.by</loc>
|
||||
<lastmod>2024-05-26</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
43
src/App/App.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Dev } from 'page/Dev/Dev'
|
||||
import { Home } from 'page/Home'
|
||||
import { configureI18next, initGA, YM } from 'shared/lib'
|
||||
|
||||
import 'react-spring-bottom-sheet/dist/style.css'
|
||||
import 'shared/theme/styles/index.css'
|
||||
|
||||
const Game = React.lazy(() => import(`page/Game/Game`))
|
||||
const Intro = React.lazy(() => import(`page/Game/Intro/Intro`))
|
||||
|
||||
initGA()
|
||||
|
||||
configureI18next()
|
||||
|
||||
export const Root: React.FC = () => (
|
||||
<>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/dev" element={<Dev />} />
|
||||
<Route
|
||||
path="/game"
|
||||
element={
|
||||
<React.Suspense fallback={<>...</>}>
|
||||
<Intro />
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/game/doubles"
|
||||
element={
|
||||
<React.Suspense fallback={<>...</>}>
|
||||
<Game />
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<YM />
|
||||
</>
|
||||
)
|
||||
1
src/App/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
92
src/App/dev-dist/sw.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-47690370'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"revision": null,
|
||||
"url": "index.html"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
3394
src/App/dev-dist/workbox-148cb7e5.js
Normal file
3391
src/App/dev-dist/workbox-47690370.js
Normal file
52
src/App/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#F4F4F2" />
|
||||
<meta name="description"
|
||||
content="Расписание автобуса 2 г. Полоцк" />
|
||||
<meta name="og:title" content="🚌 Расписание автобуса 2" />
|
||||
<meta name="og:description"
|
||||
content="Расписание автобуса 2 г. Полоцк" />
|
||||
<meta property="og:url" content="https://pwa.ridebus.by">
|
||||
<meta name="yandex-verification" content="fbda16fdc76c076c" />
|
||||
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<title>Расписание автобуса 2 | RideBus 🚌</title>
|
||||
|
||||
<script type="text/javascript">
|
||||
// Single Page Apps for GitHub Pages
|
||||
// MIT License
|
||||
// https://github.com/rafgraph/spa-github-pages
|
||||
// This script checks to see if a redirect is present in the query string,
|
||||
// converts it back into the correct url and adds it to the
|
||||
// browser's history using window.history.replaceState(...),
|
||||
// which won't cause the browser to attempt to load the new url.
|
||||
// When the single page app is loaded further down in this file,
|
||||
// the correct url will be waiting in the browser's history for
|
||||
// the single page app to route accordingly.
|
||||
(function(l) {
|
||||
if (l.search[1] === '/' ) {
|
||||
var decoded = l.search.slice(1).split('&').map(function(s) {
|
||||
return s.replace(/~and~/g, '&')
|
||||
}).join('?');
|
||||
window.history.replaceState(null, null,
|
||||
l.pathname.slice(0, -1) + decoded + l.hash
|
||||
);
|
||||
}
|
||||
}(window.location))
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
16
src/App/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { store } from 'shared/store/app/configureStore'
|
||||
|
||||
import { Root } from './App'
|
||||
|
||||
const container = document.getElementById(`root`)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const root = createRoot(container!)
|
||||
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<Root />
|
||||
</Provider>,
|
||||
)
|
||||
5
src/entities/Holiday/Holiday.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HolidayContainerStyled } from './styled'
|
||||
|
||||
export const Holiday: React.FC = () => {
|
||||
return <HolidayContainerStyled>праздничное расписание</HolidayContainerStyled>
|
||||
}
|
||||
1
src/entities/Holiday/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Holiday'
|
||||
20
src/entities/Holiday/styled.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const HolidayContainerStyled = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin: 12px 0 0 20px;
|
||||
color: #454545;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: -17px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background: linear-gradient(180deg, #38347a 0%, #336cff 100%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
`
|
||||
146
src/entities/Logo/img/LogoWithText.svg
Normal file
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 168 34" style="enable-background:new 0 0 168 34;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{display:none;fill:#1C202A;}
|
||||
.st1{fill:#C4C4C4;filter:url(#Adobe_OpacityMaskFilter);}
|
||||
.st2{display:none;mask:url(#mask0_604_3810_1_);}
|
||||
.st3{display:inline;fill:url(#SVGID_2_);}
|
||||
.st4{display:inline;}
|
||||
.st5{fill:#FFFFFF;fill-opacity:6.000000e-02;}
|
||||
.st6{fill:#262626;}
|
||||
.st7{clip-path:url(#SVGID_3_);}
|
||||
.st8{fill:#2082C4;}
|
||||
.st9{display:none;fill:none;stroke:#FFFFFF;stroke-width:1.9625;}
|
||||
.st10{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path class="st0" d="M40.2,27.3c-1.7,0-3.1-0.4-4.3-1.1c-1.2-0.7-2.1-1.7-2.8-3c-0.6-1.3-1-2.7-1-4.4c0-1.7,0.3-3.1,1-4.4
|
||||
c0.7-1.3,1.6-2.3,2.8-3c1.2-0.7,2.6-1.1,4.3-1.1c1.4,0,2.7,0.3,3.8,0.8c1.1,0.5,1.9,1.2,2.5,2.2s1,2,1,3.3h-4.3
|
||||
c-0.1-0.8-0.4-1.5-1-2c-0.5-0.5-1.2-0.8-2-0.8c-0.7,0-1.3,0.2-1.8,0.6c-0.5,0.4-0.9,0.9-1.2,1.7c-0.3,0.7-0.4,1.6-0.4,2.6
|
||||
c0,1,0.1,1.9,0.4,2.7c0.3,0.7,0.7,1.3,1.2,1.7c0.5,0.4,1.1,0.6,1.8,0.6c0.5,0,1-0.1,1.4-0.3c0.4-0.2,0.8-0.5,1-0.9
|
||||
c0.3-0.4,0.5-0.9,0.5-1.5h4.3c-0.1,1.3-0.4,2.4-1,3.3c-0.6,0.9-1.4,1.7-2.5,2.2C42.9,27.1,41.7,27.3,40.2,27.3z M57,27.3
|
||||
c-1.7,0-3.1-0.3-4.3-1c-1.2-0.7-2.1-1.7-2.8-2.9c-0.7-1.3-1-2.8-1-4.5c0-1.7,0.3-3.2,1-4.4s1.6-2.3,2.8-3c1.2-0.7,2.6-1.1,4.2-1.1
|
||||
c1.1,0,2.1,0.2,3,0.5c0.9,0.3,1.8,0.9,2.5,1.5c0.7,0.7,1.3,1.6,1.6,2.6c0.4,1,0.6,2.3,0.6,3.6V20H50.7v-2.8h9.5
|
||||
c0-0.7-0.1-1.2-0.4-1.7s-0.7-0.9-1.2-1.2c-0.5-0.3-1.1-0.4-1.7-0.4c-0.7,0-1.3,0.2-1.8,0.5c-0.5,0.3-0.9,0.7-1.2,1.3
|
||||
c-0.3,0.5-0.5,1.1-0.5,1.8V20c0,0.8,0.1,1.5,0.4,2.1c0.3,0.6,0.7,1,1.3,1.4c0.6,0.3,1.2,0.5,2,0.5c0.5,0,1-0.1,1.4-0.2
|
||||
c0.4-0.1,0.8-0.4,1.1-0.6c0.3-0.3,0.5-0.6,0.7-1l4.2,0.3c-0.2,1-0.6,1.9-1.3,2.6c-0.7,0.7-1.5,1.3-2.5,1.7
|
||||
C59.5,27.1,58.3,27.3,57,27.3z M66.5,27V10.6h7c2,0,3.6,0.4,4.8,1.2c1.2,0.8,1.8,1.9,1.8,3.3c0,0.9-0.3,1.6-1,2.2
|
||||
c-0.7,0.6-1.6,1-2.7,1.2c1,0.1,1.8,0.3,2.5,0.7c0.7,0.4,1.2,0.8,1.6,1.4c0.4,0.6,0.6,1.2,0.6,2c0,0.9-0.2,1.7-0.7,2.4
|
||||
c-0.5,0.7-1.2,1.2-2.2,1.5C77.1,26.8,76,27,74.6,27H66.5z M70.8,23.6h3.8c0.6,0,1.1-0.1,1.5-0.4c0.4-0.3,0.5-0.7,0.5-1.2
|
||||
c0-0.6-0.2-1-0.5-1.4c-0.4-0.3-0.8-0.5-1.5-0.5h-3.8V23.6z M70.8,17.4h2.8c0.5,0,0.8-0.1,1.2-0.2c0.3-0.1,0.6-0.3,0.7-0.6
|
||||
c0.2-0.2,0.3-0.5,0.3-0.9c0-0.5-0.2-0.9-0.6-1.2c-0.4-0.3-1-0.4-1.7-0.4h-2.7V17.4z M90.4,27.3c-1.7,0-3.1-0.3-4.3-1
|
||||
c-1.2-0.7-2.1-1.7-2.8-2.9c-0.7-1.3-1-2.8-1-4.5c0-1.7,0.3-3.2,1-4.4c0.7-1.3,1.6-2.3,2.8-3c1.2-0.7,2.6-1.1,4.2-1.1
|
||||
c1.1,0,2.1,0.2,3,0.5c0.9,0.3,1.8,0.9,2.5,1.5c0.7,0.7,1.3,1.6,1.6,2.6c0.4,1,0.6,2.3,0.6,3.6V20H84.1v-2.8h9.5
|
||||
c0-0.7-0.1-1.2-0.4-1.7c-0.3-0.5-0.7-0.9-1.2-1.2c-0.5-0.3-1.1-0.4-1.7-0.4c-0.7,0-1.3,0.2-1.8,0.5c-0.5,0.3-0.9,0.7-1.2,1.3
|
||||
c-0.3,0.5-0.5,1.1-0.5,1.8V20c0,0.8,0.1,1.5,0.4,2.1c0.3,0.6,0.7,1,1.3,1.4c0.6,0.3,1.2,0.5,2,0.5c0.5,0,1-0.1,1.4-0.2
|
||||
c0.4-0.1,0.8-0.4,1.1-0.6c0.3-0.3,0.5-0.6,0.7-1l4.2,0.3c-0.2,1-0.6,1.9-1.3,2.6c-0.7,0.7-1.5,1.3-2.5,1.7
|
||||
C92.9,27.1,91.7,27.3,90.4,27.3z M100,33.1V10.6h4.5v2.7h0.2c0.2-0.4,0.5-0.9,0.9-1.3c0.4-0.5,0.9-0.8,1.5-1.2
|
||||
c0.6-0.3,1.4-0.5,2.3-0.5c1.2,0,2.3,0.3,3.3,0.9c1,0.6,1.8,1.6,2.4,2.8c0.6,1.2,0.9,2.8,0.9,4.7c0,1.8-0.3,3.4-0.9,4.6
|
||||
c-0.6,1.3-1.4,2.2-2.4,2.8c-1,0.6-2.1,1-3.4,1c-0.9,0-1.6-0.1-2.2-0.4c-0.6-0.3-1.1-0.7-1.5-1.1c-0.4-0.4-0.7-0.9-0.9-1.4h-0.1v8.8
|
||||
H100z M104.4,18.8c0,1,0.1,1.8,0.4,2.5c0.3,0.7,0.7,1.3,1.2,1.7c0.5,0.4,1.1,0.6,1.9,0.6c0.7,0,1.4-0.2,1.9-0.6
|
||||
c0.5-0.4,0.9-1,1.2-1.7c0.3-0.7,0.4-1.6,0.4-2.5c0-0.9-0.1-1.8-0.4-2.5c-0.3-0.7-0.6-1.3-1.2-1.7c-0.5-0.4-1.1-0.6-1.9-0.6
|
||||
c-0.7,0-1.4,0.2-1.9,0.6c-0.5,0.4-0.9,0.9-1.2,1.7C104.5,17,104.4,17.8,104.4,18.8z M131.7,4.6l1.4,3c-0.5,0.4-1,0.7-1.5,0.9
|
||||
c-0.6,0.2-1.2,0.3-2,0.4c-0.8,0.1-1.7,0.1-2.8,0.1c-1.3,0-2.3,0.2-3.1,0.6c-0.8,0.4-1.4,1-1.8,1.8c-0.4,0.8-0.7,1.9-0.8,3.3h0.2
|
||||
c0.5-1,1.2-1.8,2.1-2.3c0.9-0.5,2-0.8,3.3-0.8c1.4,0,2.6,0.3,3.7,0.9c1.1,0.6,1.9,1.5,2.4,2.6c0.6,1.1,0.9,2.5,0.9,4.1
|
||||
c0,1.7-0.3,3.1-1,4.3c-0.6,1.2-1.6,2.2-2.8,2.8c-1.2,0.7-2.6,1-4.3,1c-1.7,0-3.1-0.3-4.3-1c-1.2-0.7-2.1-1.7-2.8-3.1
|
||||
c-0.6-1.4-1-3-1-5v-1.3c0-3.9,0.8-6.9,2.3-8.8c1.5-1.9,3.8-2.9,6.8-2.9c0.8,0,1.5,0,2.2,0c0.7,0,1.2,0,1.7-0.1
|
||||
C130.9,5,131.3,4.9,131.7,4.6z M125.5,23.8c0.7,0,1.3-0.2,1.8-0.5c0.5-0.4,0.9-0.9,1.2-1.6c0.3-0.7,0.4-1.5,0.4-2.4
|
||||
c0-0.9-0.1-1.7-0.4-2.4c-0.3-0.7-0.7-1.2-1.2-1.5c-0.5-0.3-1.1-0.5-1.8-0.5c-0.5,0-1,0.1-1.4,0.3c-0.4,0.2-0.8,0.5-1.1,0.9
|
||||
c-0.3,0.4-0.5,0.8-0.7,1.4c-0.2,0.5-0.2,1.2-0.2,1.9c0,1.4,0.3,2.5,0.9,3.3C123.6,23.4,124.4,23.8,125.5,23.8z M140.2,27.3
|
||||
c-1,0-2-0.2-2.8-0.5c-0.8-0.4-1.5-0.9-1.9-1.6c-0.5-0.7-0.7-1.6-0.7-2.7c0-0.9,0.2-1.7,0.5-2.3c0.3-0.6,0.8-1.1,1.4-1.5
|
||||
c0.6-0.4,1.2-0.7,2-0.9c0.7-0.2,1.5-0.3,2.3-0.4c1-0.1,1.7-0.2,2.3-0.3c0.6-0.1,1-0.2,1.3-0.4c0.3-0.2,0.4-0.4,0.4-0.8v-0.1
|
||||
c0-0.7-0.2-1.2-0.6-1.6c-0.4-0.4-1-0.6-1.8-0.6c-0.8,0-1.5,0.2-1.9,0.5c-0.5,0.4-0.8,0.8-1,1.3l-4.2-0.3c0.2-1,0.6-1.9,1.3-2.6
|
||||
c0.6-0.7,1.4-1.3,2.4-1.7c1-0.4,2.1-0.6,3.5-0.6c0.9,0,1.8,0.1,2.6,0.3c0.8,0.2,1.6,0.5,2.2,1c0.7,0.4,1.2,1,1.5,1.7
|
||||
c0.4,0.7,0.6,1.5,0.6,2.5v11h-4.3v-2.3H145c-0.3,0.5-0.6,1-1.1,1.4c-0.4,0.4-1,0.7-1.6,0.9C141.8,27.2,141.1,27.3,140.2,27.3z
|
||||
M141.5,24.2c0.7,0,1.3-0.1,1.8-0.4c0.5-0.3,0.9-0.6,1.2-1.1c0.3-0.5,0.4-1,0.4-1.5v-1.7c-0.1,0.1-0.3,0.2-0.6,0.3
|
||||
c-0.2,0.1-0.5,0.1-0.8,0.2c-0.3,0.1-0.6,0.1-0.9,0.2c-0.3,0-0.6,0.1-0.8,0.1c-0.5,0.1-1,0.2-1.4,0.4c-0.4,0.2-0.7,0.4-0.9,0.7
|
||||
c-0.2,0.3-0.3,0.6-0.3,1.1c0,0.6,0.2,1.1,0.7,1.4C140.3,24,140.8,24.2,141.5,24.2z M159.5,27.3c-1.7,0-3.1-0.4-4.3-1.1
|
||||
c-1.2-0.7-2.1-1.7-2.8-3c-0.6-1.3-1-2.7-1-4.4c0-1.7,0.3-3.1,1-4.4c0.7-1.3,1.6-2.3,2.8-3c1.2-0.7,2.6-1.1,4.3-1.1
|
||||
c1.4,0,2.7,0.3,3.8,0.8c1.1,0.5,1.9,1.2,2.5,2.2s1,2,1,3.3h-4.3c-0.1-0.8-0.4-1.5-1-2c-0.5-0.5-1.2-0.8-2-0.8
|
||||
c-0.7,0-1.3,0.2-1.8,0.6c-0.5,0.4-0.9,0.9-1.2,1.7c-0.3,0.7-0.4,1.6-0.4,2.6c0,1,0.1,1.9,0.4,2.7c0.3,0.7,0.7,1.3,1.2,1.7
|
||||
c0.5,0.4,1.1,0.6,1.8,0.6c0.5,0,1-0.1,1.4-0.3c0.4-0.2,0.8-0.5,1-0.9c0.3-0.4,0.5-0.9,0.5-1.5h4.3c-0.1,1.3-0.4,2.4-1,3.3
|
||||
c-0.6,0.9-1.4,1.7-2.5,2.2C162.2,27.1,160.9,27.3,159.5,27.3z"/>
|
||||
<defs>
|
||||
<filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="-3" y="-11" width="45" height="50">
|
||||
<feColorMatrix type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<mask maskUnits="userSpaceOnUse" x="-3" y="-11" width="45" height="50" id="mask0_604_3810_1_">
|
||||
<circle class="st1" cx="13" cy="18" r="13"/>
|
||||
</mask>
|
||||
<g class="st2">
|
||||
|
||||
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="19.5" y1="45" x2="19.5" y2="-5" gradientTransform="matrix(1 0 0 -1 0 34)">
|
||||
<stop offset="0" style="stop-color:#FFCE00"/>
|
||||
<stop offset="0.8177" style="stop-color:#03D5FF"/>
|
||||
</linearGradient>
|
||||
<rect x="-3" y="-11" class="st3" width="45" height="50"/>
|
||||
<g class="st4">
|
||||
<circle class="st5" cx="13" cy="18" r="13"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st6" d="M36.6,29.5V10h4.3v4.8l-0.5-0.6c0.3-0.7,0.6-1.3,1-1.8c0.4-0.6,0.9-1,1.5-1.4c0.5-0.3,1-0.5,1.5-0.7
|
||||
c0.6-0.2,1.1-0.3,1.7-0.3s1.2,0,1.8,0v4.6c-0.5-0.2-1.2-0.2-1.9-0.2c-0.7,0.1-1.4,0.2-1.9,0.5c-0.6,0.3-1.1,0.6-1.5,1.1
|
||||
c-0.4,0.4-0.7,1-0.9,1.5c-0.2,0.6-0.3,1.3-0.3,2v9.9H36.6z"/>
|
||||
<path class="st6" d="M51.8,7.3V3h4.9v4.3H51.8z M51.8,29.5V10h4.9v19.4H51.8z"/>
|
||||
<path class="st6" d="M69.9,30c-1.8,0-3.4-0.5-4.7-1.3c-1.3-0.9-2.4-2.1-3.1-3.7c-0.8-1.5-1.1-3.3-1.1-5.2c0-2,0.4-3.7,1.1-5.3
|
||||
s1.8-2.8,3.2-3.6s3-1.3,4.9-1.3c1.8,0,3.4,0.5,4.7,1.4c1.3,0.9,2.2,2.1,2.9,3.7c0.6,1.5,1,3.3,1,5.2s-0.3,3.7-1,5.2
|
||||
s-1.6,2.8-2.9,3.7C73.5,29.6,71.9,30,69.9,30z M70.7,25.7c1.1,0,2-0.2,2.6-0.7c0.7-0.5,1.1-1.2,1.4-2.1c0.3-0.9,0.4-1.9,0.4-3.1
|
||||
s-0.1-2.2-0.4-3.1c-0.3-0.9-0.7-1.6-1.4-2.1c-0.6-0.5-1.5-0.7-2.5-0.7c-1.1,0-2,0.3-2.7,0.8c-0.7,0.5-1.2,1.2-1.6,2.1
|
||||
c-0.3,0.9-0.5,1.9-0.5,3c0,1.1,0.2,2.1,0.5,3c0.3,0.9,0.8,1.6,1.5,2.1C68.8,25.4,69.7,25.7,70.7,25.7z M75.2,29.5V16.2h-0.6V3.6
|
||||
h4.9v25.9H75.2z"/>
|
||||
<path class="st6" d="M93.6,30c-2,0-3.7-0.4-5.3-1.3c-1.5-0.9-2.7-2-3.6-3.5c-0.9-1.5-1.3-3.2-1.3-5.2c0-2.1,0.4-4,1.3-5.5
|
||||
c0.8-1.6,2-2.8,3.5-3.7c1.5-0.9,3.2-1.3,5.1-1.3c2,0,3.8,0.5,5.2,1.4c1.4,1,2.5,2.3,3.2,4c0.7,1.7,0.9,3.8,0.7,6.1h-4.8v-1.8
|
||||
c0-2-0.3-3.4-0.9-4.3s-1.7-1.3-3.1-1.3c-1.7,0-2.9,0.5-3.7,1.5s-1.2,2.5-1.2,4.5c0,1.8,0.4,3.2,1.2,4.2s1.9,1.5,3.5,1.5
|
||||
c1,0,1.8-0.2,2.5-0.6c0.7-0.4,1.2-1,1.6-1.8l4.9,1.4c-0.7,1.8-1.9,3.2-3.5,4.1C97.2,29.5,95.5,30,93.6,30z M87.1,21.1v-3.6H100
|
||||
v3.6H87.1z"/>
|
||||
<path class="st6" d="M106.4,29.5V3.6h4.9v12.6h-0.6v13.3H106.4z M116,30c-1.9,0-3.5-0.5-4.8-1.3c-1.3-0.9-2.3-2.1-2.9-3.7
|
||||
s-1-3.3-1-5.2s0.3-3.7,1-5.2c0.6-1.5,1.6-2.8,2.9-3.7c1.3-0.9,2.8-1.4,4.7-1.4c1.9,0,3.5,0.4,4.9,1.3s2.5,2.1,3.2,3.6
|
||||
s1.1,3.3,1.1,5.3c0,1.9-0.4,3.7-1.1,5.2c-0.8,1.5-1.8,2.8-3.1,3.7C119.3,29.6,117.8,30,116,30z M115.2,25.7c1.1,0,1.9-0.3,2.6-0.8
|
||||
c0.7-0.5,1.2-1.2,1.5-2.1c0.3-0.9,0.5-1.9,0.5-3c0-1.1-0.2-2.1-0.5-3c-0.3-0.9-0.9-1.6-1.6-2.1c-0.7-0.5-1.6-0.8-2.7-0.8
|
||||
c-1,0-1.8,0.2-2.5,0.7c-0.6,0.5-1.1,1.2-1.4,2.1s-0.4,1.9-0.4,3.1s0.1,2.2,0.4,3.1c0.3,0.9,0.8,1.6,1.4,2.1
|
||||
C113.2,25.4,114.1,25.7,115.2,25.7z"/>
|
||||
<path class="st6" d="M136.1,30.1c-1.5,0-2.8-0.3-3.7-0.8c-1-0.5-1.7-1.2-2.2-2s-0.9-1.6-1.1-2.5c-0.2-0.9-0.4-1.6-0.4-2.3
|
||||
c0-0.7-0.1-1.2-0.1-1.5V10h5v9.2c0,0.4,0,1,0.1,1.7s0.2,1.4,0.5,2.1s0.7,1.3,1.2,1.7c0.6,0.5,1.4,0.7,2.4,0.7
|
||||
c0.4,0,0.9-0.1,1.4-0.2c0.5-0.1,0.9-0.4,1.3-0.8s0.8-0.9,1-1.7c0.3-0.7,0.4-1.7,0.4-2.9l2.8,1.3c0,1.5-0.3,3-0.9,4.3
|
||||
c-0.6,1.3-1.6,2.4-2.8,3.3S138,30.1,136.1,30.1z M142.4,29.5V23h-0.6V10h4.9v19.4H142.4z"/>
|
||||
<path class="st6" d="M159.4,30c-2.4,0-4.4-0.6-5.9-1.7c-1.5-1.1-2.4-2.7-2.7-4.7l5-0.8c0.2,0.9,0.7,1.6,1.4,2.1
|
||||
c0.7,0.5,1.6,0.8,2.7,0.8c0.9,0,1.6-0.2,2.1-0.5c0.5-0.3,0.7-0.8,0.7-1.4c0-0.4-0.1-0.7-0.3-0.9c-0.2-0.2-0.6-0.5-1.3-0.7
|
||||
c-0.7-0.2-1.7-0.5-3.1-0.9c-1.6-0.4-2.9-0.9-3.8-1.4c-0.9-0.5-1.6-1.1-2.1-1.8s-0.6-1.6-0.6-2.5c0-1.2,0.3-2.3,1-3.2
|
||||
s1.5-1.6,2.7-2.1c1.2-0.5,2.5-0.7,4.1-0.7c1.5,0,2.9,0.2,4,0.7c1.2,0.5,2.1,1.1,2.9,2c0.7,0.9,1.2,1.9,1.3,3.1l-5,0.9
|
||||
c-0.1-0.7-0.4-1.3-0.9-1.7s-1.3-0.7-2.2-0.7c-0.9-0.1-1.6,0.1-2.2,0.4c-0.5,0.3-0.8,0.7-0.8,1.3c0,0.3,0.1,0.6,0.4,0.8
|
||||
c0.2,0.2,0.7,0.5,1.5,0.7c0.7,0.2,1.9,0.6,3.4,0.9c1.5,0.4,2.7,0.8,3.6,1.3c0.9,0.5,1.5,1.1,2,1.8c0.4,0.7,0.6,1.6,0.6,2.6
|
||||
c0,2-0.7,3.6-2.2,4.7C163.9,29.4,161.9,30,159.4,30z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<defs>
|
||||
<rect id="SVGID_1_" x="-0.1" y="5" width="29" height="29"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_3_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
<g class="st7">
|
||||
<path class="st8" d="M10.6,5.7l9.9,0.8c4.7,0.3,8.2,4.4,7.8,9.1l-0.8,9.9c-0.4,4.7-4.4,8.2-9.1,7.8l-9.9-0.8
|
||||
c-4.6-0.3-8.1-4.5-7.8-9.2l0.8-9.9C1.9,8.8,6,5.3,10.6,5.7z"/>
|
||||
<path class="st9" d="M11.6,21.9c-11.7-7.6,6.7-11.7,8.3-11.3 M11.6,21.9c11.2,7.4,9.1-9.6,8.3-11.3 M11.6,21.9
|
||||
c5.1,1.5,7.6-8.5,8.4-11.3"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<path id="path_1_" class="st10" d="M9.8,27.2c-0.8,0-1.1-0.3-1.1-0.9c0-0.4,0-0.8,0-1.2c0-0.1-0.1-0.1-0.1-0.3
|
||||
c-0.5-0.6-0.8-1.4-0.7-2.1c0.1-2.5,0-5,0-7.6c0-0.4,0.1-0.8,0.1-1.1c0.3-0.9,1-1.5,1.9-1.8c1.1-0.3,2.1-0.5,3.3-0.4
|
||||
c1.4,0,2.8,0,4.1,0.1c1,0.1,2.1,0.3,2.9,1.1c0.1,0.1,0.3,0.3,0.3,0.4c0.4,0.6,0.6,1.5,0.5,2.3c0,1.2,0,2.3,0,3.4l0,0
|
||||
c0,1.3,0,2.5,0,3.9c0,0.6-0.3,1.4-0.7,1.9C20,25,20,25.2,20,25.4c0,0.4,0,0.8,0,1c0,0.3-0.1,0.6-0.4,0.8c-0.1,0.1-0.3,0.1-0.4,0.1
|
||||
c-0.3,0-0.6,0-1,0c-0.5,0-0.8-0.4-0.9-0.8c0-0.2,0-0.4,0-0.6c0-0.1-0.1-0.1-0.1-0.2l0,0h-5.9c-0.1,0-0.2,0-0.1,0.2
|
||||
c0,0.1,0,0.4,0,0.5c0,0.5-0.4,0.9-0.8,0.9 M14.4,15.1L14.4,15.1c-1.6,0-3.1,0-4.6,0c-0.1,0-0.3,0.1-0.3,0.3c0,1.1,0,2.3,0,3.5
|
||||
c0,0.1,0.1,0.3,0.3,0.3c3.1,0,6.1,0,9.2,0c0.2,0,0.3-0.1,0.3-0.3c0-1.1,0-2.3,0-3.4c0-0.1-0.1-0.3-0.3-0.3l0,0
|
||||
C17.4,15.1,15.9,15.1,14.4,15.1z M18.2,24c0.6-0.1,1.1-0.6,1-1.3s-0.6-1.1-1.3-1c-0.1,0-0.1,0-0.3,0c-0.6,0.2-1,0.9-0.8,1.5
|
||||
C17.1,23.6,17.6,24,18.2,24L18.2,24z M10.5,24c0.6,0.1,1.3-0.3,1.4-1c0.1-0.6-0.3-1.3-0.9-1.4c-0.6-0.1-1.3,0.3-1.4,1
|
||||
c-0.1,0.1-0.1,0.1-0.1,0.3C9.6,23.4,10,23.9,10.5,24L10.5,24z M13,23.1c-0.1,0.5,0.3,1,0.7,1.2c0.4,0.3,1.1,0.1,1.5-0.1
|
||||
c0.4-0.3,0.6-0.6,0.5-1.1h-0.6c-0.1,0.5-0.4,0.8-0.8,0.8c-0.4,0-0.6-0.2-0.8-0.8C13.6,23.1,13,23.1,13,23.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
2
src/entities/Logo/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ui/Logo'
|
||||
export * from './ui/styled'
|
||||
15
src/entities/Logo/ui/Logo.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ImageWrapperStyled } from 'shared/ui/ImageWrapper'
|
||||
|
||||
import LogoWithText from '../img/LogoWithText.svg'
|
||||
|
||||
export const Logo: React.FC = () => {
|
||||
return (
|
||||
<Link to="/">
|
||||
<ImageWrapperStyled $w={140} $h={27}>
|
||||
<SVG src={LogoWithText} width={140} height={27} uniquifyIDs />
|
||||
</ImageWrapperStyled>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
41
src/entities/Logo/ui/styled.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const HeaderInnerStyled = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 11px 15px;
|
||||
max-width: 768px;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const HeaderContainerStyled = styled.nav`
|
||||
z-index: 2;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const HeaderContainerBetaStyled = styled.nav`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const HeaderActionsStyled = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`
|
||||
13
src/entities/SelectBusStopText/SelectBusStopText.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const TextContainerStyled = styled.p`
|
||||
opacity: 0.5;
|
||||
font-size: 18px;
|
||||
`
|
||||
|
||||
export const SelectBusStopText: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <TextContainerStyled>{t(`Select bus stop to see schedule`)}</TextContainerStyled>
|
||||
}
|
||||
1
src/entities/SelectBusStopText/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SelectBusStopText'
|
||||
27
src/entities/WriteMe/WriteMe.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { TelegramButton } from 'shared/ui'
|
||||
import { CardHeaderStyled, CardStyled } from 'shared/ui/common'
|
||||
|
||||
import Write from './img/write.svg'
|
||||
import { HandWrapperStyled } from './styled'
|
||||
|
||||
export const WriteMe: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<CardStyled $isOverflow>
|
||||
<CardHeaderStyled>
|
||||
{t(`Did you see an error?`)}
|
||||
<br />
|
||||
{t(`Have a suggestion for improvement?`)}
|
||||
</CardHeaderStyled>
|
||||
|
||||
<TelegramButton />
|
||||
|
||||
<HandWrapperStyled>
|
||||
<SVG src={Write} width={109} height={100} uniquifyIDs />
|
||||
</HandWrapperStyled>
|
||||
</CardStyled>
|
||||
)
|
||||
}
|
||||
1
src/entities/WriteMe/img/write.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
src/entities/WriteMe/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './WriteMe'
|
||||
7
src/entities/WriteMe/styled.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const HandWrapperStyled = styled.div`
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
bottom: 0;
|
||||
`
|
||||
83
src/features/BottomSheet/BottomSheet.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { BottomSheet, BottomSheetRef } from 'react-spring-bottom-sheet'
|
||||
|
||||
import { getSnapPoints, getSnapValue, snapBottom, snapMid, snapTop } from './helpers/getSnapPoints'
|
||||
import {
|
||||
bottomSheetPositionSelector,
|
||||
BottomSheetStates,
|
||||
maxHeightSelector,
|
||||
setBottomSheetPosition,
|
||||
setMaxHeight,
|
||||
} from './model/bottomSheetSlice'
|
||||
|
||||
interface IProps {
|
||||
children: JSX.Element
|
||||
header?: JSX.Element
|
||||
}
|
||||
export const BottomSheetCustom: React.FC<IProps> = ({ children, header }) => {
|
||||
const sheetRef = useRef<BottomSheetRef>(null)
|
||||
const [expandOnContentDrag] = useState<boolean>(true)
|
||||
const focusRef = useRef<HTMLButtonElement>(null)
|
||||
const dispatch = useDispatch()
|
||||
const bottomSheetMaxHeight = useSelector(maxHeightSelector)
|
||||
|
||||
const bottomSheetPostion = useSelector(bottomSheetPositionSelector)
|
||||
|
||||
useEffect(() => {
|
||||
if (sheetRef.current) {
|
||||
sheetRef.current.snapTo(({ maxHeight }) => getSnapValue(bottomSheetPostion, maxHeight))
|
||||
}
|
||||
}, [bottomSheetPostion])
|
||||
|
||||
const getDefaultSnap = ({ maxHeight }: { maxHeight: number }): number => {
|
||||
if (maxHeight !== bottomSheetMaxHeight) {
|
||||
dispatch(setMaxHeight(maxHeight))
|
||||
}
|
||||
|
||||
return snapMid(maxHeight)
|
||||
}
|
||||
|
||||
const handleSpringEnd = (): void => {
|
||||
const currentMaxHeight = sheetRef.current?.height
|
||||
|
||||
if (bottomSheetMaxHeight === undefined || !currentMaxHeight) return
|
||||
|
||||
if (
|
||||
currentMaxHeight >= Math.floor(snapBottom(bottomSheetMaxHeight)) &&
|
||||
currentMaxHeight < Math.floor(snapMid(bottomSheetMaxHeight))
|
||||
) {
|
||||
dispatch(setBottomSheetPosition(BottomSheetStates.BOTTOM))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
currentMaxHeight >= Math.floor(snapMid(bottomSheetMaxHeight)) &&
|
||||
currentMaxHeight < Math.floor(snapTop(bottomSheetMaxHeight))
|
||||
) {
|
||||
dispatch(setBottomSheetPosition(BottomSheetStates.MID))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(setBottomSheetPosition(BottomSheetStates.TOP))
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
open
|
||||
skipInitialTransition
|
||||
ref={sheetRef}
|
||||
initialFocusRef={focusRef}
|
||||
defaultSnap={getDefaultSnap}
|
||||
snapPoints={getSnapPoints}
|
||||
expandOnContentDrag={expandOnContentDrag}
|
||||
blocking={false}
|
||||
onSpringEnd={handleSpringEnd}
|
||||
header={header}
|
||||
>
|
||||
{children}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
22
src/features/BottomSheet/helpers/getSnapPoints.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { BottomSheetStates } from '../model/bottomSheetSlice'
|
||||
|
||||
export const snapTop = (maxHeight: number): number => maxHeight - maxHeight / 5
|
||||
export const snapMid = (maxHeight: number): number => maxHeight / 2
|
||||
export const snapBottom = (maxHeight: number): number => maxHeight * 0.2
|
||||
|
||||
export const getSnapPoints = ({ maxHeight }: { maxHeight: number }): number[] => {
|
||||
return [snapTop(maxHeight), snapMid(maxHeight), snapBottom(maxHeight)]
|
||||
}
|
||||
|
||||
export const getSnapValue = (snap: BottomSheetStates, maxHeight: number): number => {
|
||||
switch (snap) {
|
||||
case BottomSheetStates.TOP:
|
||||
return snapTop(maxHeight)
|
||||
case BottomSheetStates.MID:
|
||||
return snapMid(maxHeight)
|
||||
case BottomSheetStates.BOTTOM:
|
||||
return snapBottom(maxHeight)
|
||||
default:
|
||||
return snapMid(maxHeight)
|
||||
}
|
||||
}
|
||||
38
src/features/BottomSheet/model/bottomSheetSlice.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { RootState } from 'shared/store/app/configureStore'
|
||||
|
||||
export enum BottomSheetStates {
|
||||
'TOP' = 1, // 90% open
|
||||
'MID' = 2, // 50/50
|
||||
'BOTTOM' = 3, // 30% open
|
||||
}
|
||||
export interface BottomSheetState {
|
||||
position: BottomSheetStates
|
||||
maxHeight: number | undefined
|
||||
}
|
||||
|
||||
const initialState: BottomSheetState = {
|
||||
position: BottomSheetStates.MID,
|
||||
maxHeight: undefined,
|
||||
}
|
||||
|
||||
export const bottomSheetSlice = createSlice({
|
||||
name: `bottomSheetSlice`,
|
||||
initialState,
|
||||
reducers: {
|
||||
setBottomSheetPosition: (state, action: PayloadAction<BottomSheetStates>) => {
|
||||
state.position = action.payload
|
||||
},
|
||||
setMaxHeight: (state, action: PayloadAction<number | undefined>) => {
|
||||
state.maxHeight = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const { setBottomSheetPosition, setMaxHeight } = bottomSheetSlice.actions
|
||||
|
||||
export const bottomSheetPositionSelector = (state: RootState): BottomSheetStates => state.bottomSheetSlice.position
|
||||
export const maxHeightSelector = (state: RootState): number | undefined => state.bottomSheetSlice.maxHeight
|
||||
|
||||
export default bottomSheetSlice.reducer
|
||||
54
src/features/Complains/helpers/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { calculateHowMuchIsLeft } from 'shared/lib/time/calculateHowMuchIsLeft'
|
||||
import { Directions } from 'shared/store/busStop/Stops'
|
||||
|
||||
import { ComplainType } from '../model/Complains'
|
||||
|
||||
export const getMinutesString = (amount: number, secondWord = `назад`): string => {
|
||||
if (amount === 0) return ` сейчас`
|
||||
if (amount === 1) return ` минуту ${secondWord}`
|
||||
if ([2, 3, 4].includes(amount)) return ` минуты ${secondWord}`
|
||||
|
||||
return ` минут ${secondWord}`
|
||||
}
|
||||
|
||||
export const getDirectionString = (direction: Directions): string => {
|
||||
return direction === `in` ? `в парк` : `из парка`
|
||||
}
|
||||
|
||||
export const getTypeString = (type: ComplainType): string => {
|
||||
switch (type) {
|
||||
case ComplainType.earlier:
|
||||
return `приехал раньше`
|
||||
case ComplainType.later:
|
||||
return `приехал позже`
|
||||
case ComplainType.not_arrive:
|
||||
return `не приехал`
|
||||
case ComplainType.passed_by:
|
||||
return `проехал мимо`
|
||||
default:
|
||||
return ``
|
||||
}
|
||||
}
|
||||
|
||||
export const getOnString = (on: number, type: ComplainType): string => {
|
||||
if (on === 0) return getTypeString(type)
|
||||
|
||||
const commingType = getTypeString(type)
|
||||
const minutesString = getMinutesString(on, ``)
|
||||
|
||||
return `${commingType} на ${on}${minutesString}`
|
||||
}
|
||||
|
||||
export const getHumanDate = (date: string): string => {
|
||||
const time = calculateHowMuchIsLeft(date)
|
||||
const d = new Date(date)
|
||||
const min = d.getMinutes() < 9 ? `0${d.getMinutes()}` : d.getMinutes()
|
||||
if (time.hours === null || time.hours >= 1) return `в ${d.getHours()}:${min}`
|
||||
|
||||
if (time.minutes === null) return ``
|
||||
|
||||
const timeAmountString = time.minutes === 0 ? `` : time.minutes
|
||||
const minutesString = getMinutesString(time.minutes)
|
||||
|
||||
return `${timeAmountString}${minutesString}`
|
||||
}
|
||||
2
src/features/Complains/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ui/Complains'
|
||||
export * from './model/Complains'
|
||||
6
src/features/Complains/model/Complains.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum ComplainType {
|
||||
'earlier' = `earlier`,
|
||||
'later' = `later`,
|
||||
not_arrive = `not_arrive`,
|
||||
passed_by = `passed_by`,
|
||||
}
|
||||
30
src/features/Complains/model/ComplainsContext.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react'
|
||||
|
||||
import { IComplains, IComplainsResponse, useComplains } from './useComplains'
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
complains: [],
|
||||
addComplain: () => {},
|
||||
}
|
||||
|
||||
export const ComplainsContext = createContext<ContextProps>(DEFAULT_PROPS)
|
||||
|
||||
interface ContextProps {
|
||||
complains: IComplainsResponse[]
|
||||
addComplain: (data: IComplains) => void
|
||||
}
|
||||
|
||||
interface IProviderProps {
|
||||
children: React.ReactElement
|
||||
}
|
||||
export const ComplainsProvider = ({ children }: IProviderProps): JSX.Element => {
|
||||
const { complains, addComplain } = useComplains()
|
||||
|
||||
const values = useMemo(() => ({ complains, addComplain }), [addComplain, complains])
|
||||
|
||||
return <ComplainsContext.Provider value={values}>{children}</ComplainsContext.Provider>
|
||||
}
|
||||
|
||||
export const useComplainsContext = (): ContextProps => {
|
||||
return useContext(ComplainsContext)
|
||||
}
|
||||
100
src/features/Complains/model/useComplains.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { Directions, StopKeys } from 'shared/store/busStop/Stops'
|
||||
|
||||
import { ComplainType } from './Complains'
|
||||
|
||||
export interface IComplains {
|
||||
stop: StopKeys
|
||||
direction: Directions
|
||||
date: string
|
||||
type: ComplainType
|
||||
on: number
|
||||
}
|
||||
|
||||
export interface IComplainsResponse extends IComplains {
|
||||
id: number
|
||||
}
|
||||
interface IReturns {
|
||||
complains: IComplainsResponse[]
|
||||
addComplain: (data: IComplains) => void
|
||||
}
|
||||
|
||||
export const useComplains = (): IReturns => {
|
||||
const [complains, setComplains] = useState<IComplainsResponse[]>([])
|
||||
|
||||
const fetchComplains = (): void => {
|
||||
fetch(`https://popooga.ru/graphql`, {
|
||||
headers: {
|
||||
accept: `*/*`,
|
||||
'accept-language': `ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7`,
|
||||
'content-type': `application/json`,
|
||||
'sec-fetch-dest': `empty`,
|
||||
'sec-fetch-mode': `cors`,
|
||||
'sec-fetch-site': `same-origin`,
|
||||
},
|
||||
referrer: `https://popooga.ru/graphql`,
|
||||
referrerPolicy: `strict-origin-when-cross-origin`,
|
||||
body: `{"operationName":"C","variables":{},"query":"query C {\\n findComplains {\\n id\\n stop\\n direction\\n date\\n type\\n on\\n }\\n}\\n"}`,
|
||||
method: `POST`,
|
||||
mode: `cors`,
|
||||
credentials: `omit`,
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (res?.data?.findComplains) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
||||
setComplains(res.data.findComplains)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchComplains()
|
||||
const interval = setInterval(() => {
|
||||
fetchComplains()
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addComplain = (data: IComplains): void => {
|
||||
AndrewLytics(`addComplainMethod`)
|
||||
const body = {
|
||||
operationName: null,
|
||||
variables: {
|
||||
data,
|
||||
},
|
||||
query: `mutation Complain($data: ComplainsInputDTO!) {createComplain(data: $data) {id}
|
||||
}`,
|
||||
}
|
||||
|
||||
fetch(`https://popooga.ru/graphql`, {
|
||||
headers: {
|
||||
accept: `*/*`,
|
||||
'accept-language': `ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7`,
|
||||
'content-type': `application/json`,
|
||||
'sec-ch-ua': `" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"`,
|
||||
'sec-ch-ua-mobile': `?0`,
|
||||
'sec-ch-ua-platform': `"macOS"`,
|
||||
'sec-fetch-dest': `empty`,
|
||||
'sec-fetch-mode': `cors`,
|
||||
'sec-fetch-site': `same-origin`,
|
||||
},
|
||||
referrer: `https://popooga.ru/graphql`,
|
||||
referrerPolicy: `strict-origin-when-cross-origin`,
|
||||
body: JSON.stringify(body),
|
||||
method: `POST`,
|
||||
mode: `cors`,
|
||||
credentials: `omit`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return { complains, addComplain }
|
||||
}
|
||||
55
src/features/Complains/model/useFastReplay.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { calculateHowMuchIsLeft } from 'shared/lib/time/calculateHowMuchIsLeft'
|
||||
import { leftSelector } from 'shared/store/timeLeft/timeLeftSlice'
|
||||
|
||||
const isToday = (date: Date): boolean => {
|
||||
const today = new Date()
|
||||
|
||||
return (
|
||||
today.getFullYear() === date.getFullYear() &&
|
||||
today.getMonth() === date.getMonth() &&
|
||||
today.getDate() === date.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
const getTodayFirstVisit = (): string => {
|
||||
const item = localStorage.getItem(`TODAY_FIRST_VISIT`)
|
||||
|
||||
if (!item || !isToday(new Date(item))) {
|
||||
const today = new Date().toISOString()
|
||||
localStorage.setItem(`TODAY_FIRST_VISIT`, today)
|
||||
|
||||
return today
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
export const VISIT_TIME = getTodayFirstVisit()
|
||||
|
||||
interface IReturns {
|
||||
shouldShowFastReply: boolean
|
||||
}
|
||||
|
||||
export const useFastReplay = (): IReturns => {
|
||||
const left = useSelector(leftSelector)
|
||||
const [shouldShowFastReply, setShouldShowFastReply] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (left.hours === null) return
|
||||
|
||||
const userTimeLeft = calculateHowMuchIsLeft(VISIT_TIME)
|
||||
|
||||
if (userTimeLeft.minutes === null || (userTimeLeft.hours === 0 && userTimeLeft.minutes <= 0)) return
|
||||
|
||||
AndrewLytics(`frappears`)
|
||||
|
||||
setShouldShowFastReply(true)
|
||||
}, [left, shouldShowFastReply])
|
||||
|
||||
return {
|
||||
shouldShowFastReply,
|
||||
}
|
||||
}
|
||||
81
src/features/Complains/ui/Complains.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { BottomSheet } from 'react-spring-bottom-sheet'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { CardStyled, ContainerStyled, MiniButtonStyled } from 'shared/ui/common'
|
||||
import { HeaderTextStyled } from 'shared/ui/Header/styled'
|
||||
import { BottomSheetBgStyled } from 'shared/ui/MainLayout'
|
||||
|
||||
import { PopupContentStyled } from '../../../shared/ui/Popup/PopupContent'
|
||||
import { getHumanDate } from '../helpers'
|
||||
import { useComplainsContext } from '../model/ComplainsContext'
|
||||
import { ComplainsMessage } from './ComplainsMessage'
|
||||
import {
|
||||
ComplainCountStyled,
|
||||
ComplainsBlockContainerStyled,
|
||||
ComplainsBlockTextStyled,
|
||||
ComplainsContainerStyled,
|
||||
ComplainsLabelStyled,
|
||||
InfoTextStyled,
|
||||
} from './styled'
|
||||
|
||||
export const Complains: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { complains } = useComplainsContext()
|
||||
|
||||
const latestTime = useMemo(() => {
|
||||
if (complains.length === 0) return `сегодня ни одной жалобы`
|
||||
const latest = complains[0]
|
||||
|
||||
const humanDate = getHumanDate(latest.date)
|
||||
|
||||
return `последняя ${humanDate}`
|
||||
}, [complains])
|
||||
|
||||
const handleOpenComplains = (): void => {
|
||||
setIsOpen(true)
|
||||
AndrewLytics(`openComplains`)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContainerStyled>
|
||||
<CardStyled>
|
||||
<BottomSheet
|
||||
open={isOpen}
|
||||
onDismiss={() => setIsOpen(false)}
|
||||
defaultSnap={({ maxHeight }) => maxHeight / 2}
|
||||
snapPoints={({ maxHeight }) => [maxHeight - maxHeight / 10, maxHeight / 4, maxHeight * 0.6]}
|
||||
>
|
||||
<BottomSheetBgStyled $bg="#fff">
|
||||
<PopupContentStyled>
|
||||
<InfoTextStyled>
|
||||
Жалобы попадают автоматически после выбора опции «Приехал раньше» или «Приехал позже».
|
||||
Кнопки появляются в секции «Остановка» при выбранной остановке.
|
||||
</InfoTextStyled>
|
||||
{complains.map(c => (
|
||||
<ComplainsMessage {...c} key={c.id} />
|
||||
))}
|
||||
</PopupContentStyled>
|
||||
</BottomSheetBgStyled>
|
||||
</BottomSheet>
|
||||
|
||||
<ComplainsContainerStyled>
|
||||
<div>
|
||||
<ComplainsBlockContainerStyled>
|
||||
<ComplainsBlockTextStyled>
|
||||
Жалобы <ComplainCountStyled>{complains.length}</ComplainCountStyled>
|
||||
</ComplainsBlockTextStyled>
|
||||
<ComplainsLabelStyled>{latestTime}</ComplainsLabelStyled>
|
||||
</ComplainsBlockContainerStyled>
|
||||
|
||||
<HeaderTextStyled />
|
||||
</div>
|
||||
|
||||
<MiniButtonStyled disabled={complains.length === 0} onClick={handleOpenComplains}>
|
||||
Смотреть
|
||||
</MiniButtonStyled>
|
||||
</ComplainsContainerStyled>
|
||||
</CardStyled>
|
||||
</ContainerStyled>
|
||||
)
|
||||
}
|
||||
22
src/features/Complains/ui/ComplainsMessage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
import { getDirectionString, getHumanDate, getOnString } from '../helpers'
|
||||
import { IComplainsResponse } from '../model/useComplains'
|
||||
import { ComplainsDirectionStyled, ComplainsStopStyled, MessageContainerStyled, MessageDateStyled } from './styled'
|
||||
|
||||
type Props = IComplainsResponse & { isCurrentStop?: boolean }
|
||||
|
||||
export const ComplainsMessage: React.FC<Props> = ({ date, on, type, direction, stop, isCurrentStop = false }) => {
|
||||
const left = useMemo(() => getHumanDate(date), [date])
|
||||
const directionString = getDirectionString(direction)
|
||||
const onString = getOnString(on, type)
|
||||
|
||||
return (
|
||||
<MessageContainerStyled>
|
||||
<ComplainsStopStyled $isCurrentStop={isCurrentStop}>{stop}</ComplainsStopStyled>
|
||||
<ComplainsDirectionStyled>{directionString}</ComplainsDirectionStyled>
|
||||
<p>{onString}</p>
|
||||
<MessageDateStyled>{left}</MessageDateStyled>
|
||||
</MessageContainerStyled>
|
||||
)
|
||||
}
|
||||
92
src/features/Complains/ui/Fastreply.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { busStopNewSelector, directionSelector } from 'shared/store/busStop/busStopInfoSlice'
|
||||
import { leftSelector } from 'shared/store/timeLeft/timeLeftSlice'
|
||||
import { InlineOptions } from 'shared/ui/InlineOptions'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { ComplainType } from '../model/Complains'
|
||||
import { useComplainsContext } from '../model/ComplainsContext'
|
||||
import { useFastReplay } from '../model/useFastReplay'
|
||||
|
||||
const COMPLAIN_DISAPPEAR_MS = 200000
|
||||
const ComplainsOptions = [
|
||||
{
|
||||
value: ComplainType.earlier,
|
||||
label: `Приехал раньше`,
|
||||
},
|
||||
{
|
||||
value: ComplainType.later,
|
||||
label: `Приехал позже`,
|
||||
},
|
||||
{
|
||||
value: ComplainType.not_arrive,
|
||||
label: `Не приехал`,
|
||||
},
|
||||
{
|
||||
value: ComplainType.passed_by,
|
||||
label: `Проехал мимо`,
|
||||
},
|
||||
]
|
||||
|
||||
export const ComplainOptionContainerStyled = styled.div`
|
||||
margin-top: 12px;
|
||||
`
|
||||
|
||||
export const Fastreply: React.FC = () => {
|
||||
const [isComplainClicked, setIsComplainClicked] = useState(false)
|
||||
const [activeComplain, setActiveComplain] = useState<ComplainType | null>(null)
|
||||
const { shouldShowFastReply } = useFastReplay()
|
||||
|
||||
const busStopNew = useSelector(busStopNewSelector)
|
||||
const direction = useSelector(directionSelector)
|
||||
const left = useSelector(leftSelector)
|
||||
|
||||
const { addComplain } = useComplainsContext()
|
||||
|
||||
const handleComplain = (type: ComplainType): void => {
|
||||
if (!busStopNew || left.minutes === null) return
|
||||
|
||||
const date = new Date().toISOString()
|
||||
|
||||
addComplain({
|
||||
stop: busStopNew.label,
|
||||
direction,
|
||||
date,
|
||||
type,
|
||||
on: 0,
|
||||
})
|
||||
|
||||
AndrewLytics(`fastReply`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplainClicked) {
|
||||
setTimeout(() => {
|
||||
setIsComplainClicked(false)
|
||||
setActiveComplain(null)
|
||||
}, COMPLAIN_DISAPPEAR_MS)
|
||||
}
|
||||
}, [isComplainClicked])
|
||||
|
||||
const handleFastReplyClick = (key: ComplainType | null): void => {
|
||||
if (isComplainClicked || !key) return
|
||||
|
||||
setActiveComplain(key)
|
||||
handleComplain(key)
|
||||
setIsComplainClicked(true)
|
||||
}
|
||||
|
||||
if (!shouldShowFastReply) return null
|
||||
|
||||
return (
|
||||
<ComplainOptionContainerStyled>
|
||||
<InlineOptions<ComplainType>
|
||||
list={ComplainsOptions}
|
||||
onClick={handleFastReplyClick}
|
||||
activeId={activeComplain}
|
||||
/>
|
||||
</ComplainOptionContainerStyled>
|
||||
)
|
||||
}
|
||||
72
src/features/Complains/ui/styled.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const ComplainsContainerStyled = styled.div`
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
export const MessageContainerStyled = styled.article`
|
||||
background: #e7edec;
|
||||
border-radius: 14px;
|
||||
padding: 11px 16px;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-rows: 20px 20px;
|
||||
grid-row-gap: 5px;
|
||||
|
||||
& + & {
|
||||
margin-top: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
export const MessageDateStyled = styled.span`
|
||||
color: #737776;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
export const ComplainsStopStyled = styled.p<{ $isCurrentStop?: boolean }>`
|
||||
color: ${props => (props.$isCurrentStop ? `#1191FB` : `black`)};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
export const ComplainsDirectionStyled = styled.p`
|
||||
text-align: right;
|
||||
`
|
||||
|
||||
export const ComplainsLabelStyled = styled.span`
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: #a5a5a5;
|
||||
`
|
||||
|
||||
export const ComplainsBlockContainerStyled = styled.div`
|
||||
padding-left: 6px;
|
||||
`
|
||||
|
||||
export const ComplainsBlockTextStyled = styled.p`
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
`
|
||||
|
||||
export const ComplainCountStyled = styled.span`
|
||||
color: #717171;
|
||||
`
|
||||
|
||||
export const InfoTextStyled = styled.p`
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #808080;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 3px;
|
||||
`
|
||||
1
src/features/DirectionChanger/img/web.svg
Normal file
|
After Width: | Height: | Size: 46 KiB |
1
src/features/DirectionChanger/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/DirectionChanger'
|
||||
70
src/features/DirectionChanger/ui/DirectionChanger.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { AndrewLytics, useTypedSelector } from 'shared/lib'
|
||||
import { isHalloween } from 'shared/store/app/selectors/isHalloween'
|
||||
import { directionSelector, setDirection } from 'shared/store/busStop/busStopInfoSlice'
|
||||
import { DirectionsNew } from 'shared/store/busStop/Stops'
|
||||
import { CardStyled, ContainerStyled } from 'shared/ui'
|
||||
|
||||
import Web from '../img/web.svg'
|
||||
import {
|
||||
DirectionContainerStyled,
|
||||
DirectionPlaceholderStyled,
|
||||
DirectionTextStyled,
|
||||
GoButtonContainerStyled,
|
||||
GoButtonStyled,
|
||||
WebWrapper,
|
||||
} from './styled'
|
||||
|
||||
const SIZE = 43
|
||||
export const DirectionChanger = (): JSX.Element => {
|
||||
const direction = useSelector(directionSelector)
|
||||
const dispatch = useDispatch()
|
||||
const isHalloweenMode = useTypedSelector(isHalloween)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isWebVisible, setIsWebVisible] = useState(true)
|
||||
|
||||
const handleChangeDirection = (value: DirectionsNew): void => {
|
||||
const directionToChange = value
|
||||
|
||||
dispatch(setDirection(directionToChange))
|
||||
|
||||
AndrewLytics(`changeDirection`)
|
||||
}
|
||||
|
||||
const handleWebClick = (): void => {
|
||||
setIsWebVisible(false)
|
||||
}
|
||||
|
||||
const onDirectionClick = (): void => {
|
||||
handleChangeDirection(direction === DirectionsNew.in ? DirectionsNew.out : DirectionsNew.in)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContainerStyled>
|
||||
<CardStyled>
|
||||
{isHalloweenMode && isWebVisible && (
|
||||
<WebWrapper $w={SIZE} $h={SIZE} onClick={handleWebClick}>
|
||||
<SVG src={Web} width={SIZE} height={SIZE} uniquifyIDs />
|
||||
</WebWrapper>
|
||||
)}
|
||||
|
||||
<GoButtonContainerStyled>
|
||||
<DirectionContainerStyled>
|
||||
<DirectionPlaceholderStyled>Направление</DirectionPlaceholderStyled>
|
||||
<DirectionTextStyled>
|
||||
{direction === `in` ? t(`In north park`) : t(`Out of north park`)}
|
||||
</DirectionTextStyled>
|
||||
</DirectionContainerStyled>
|
||||
|
||||
<GoButtonStyled $active={direction === `in`} onClick={onDirectionClick}>
|
||||
{direction === `in` ? t(`Out of north park`) : t(`In north park`)}
|
||||
</GoButtonStyled>
|
||||
</GoButtonContainerStyled>
|
||||
</CardStyled>
|
||||
</ContainerStyled>
|
||||
)
|
||||
}
|
||||
44
src/features/DirectionChanger/ui/styled.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { MAIN_GREY } from 'shared/theme'
|
||||
import { ImageWrapperStyled } from 'shared/ui'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const GoButtonContainerStyled = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
export const DirectionTextStyled = styled.p`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
export const DirectionContainerStyled = styled.div`
|
||||
padding-left: 6px;
|
||||
`
|
||||
export const DirectionPlaceholderStyled = styled.span`
|
||||
font-size: 16px;
|
||||
margin-bottom: 2px;
|
||||
`
|
||||
export const GoButtonStyled = styled.button<{ $active?: boolean }>`
|
||||
width: 100%;
|
||||
border-radius: 13px;
|
||||
background-color: ${MAIN_GREY};
|
||||
color: #000000;
|
||||
padding: 17px 17px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const WebWrapper = styled(ImageWrapperStyled)`
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: -10px;
|
||||
`
|
||||
44
src/features/FAQ/helpers/clickToClipBoard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
/* eslint-disable no-console */
|
||||
/* eslint-disable promise/always-return */
|
||||
/* eslint-disable func-names */
|
||||
/* eslint-disable promise/catch-or-return */
|
||||
export const fallbackCopyTextToClipboard = (text: string): void => {
|
||||
const textArea = document.createElement(`textarea`)
|
||||
textArea.value = text
|
||||
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = `0`
|
||||
textArea.style.left = `0`
|
||||
textArea.style.position = `fixed`
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand(`copy`)
|
||||
const msg = successful ? `successful` : `unsuccessful`
|
||||
console.log(`Fallback: Copying text command was ${msg}`)
|
||||
} catch (err) {
|
||||
console.error(`Fallback: Oops, unable to copy`, err)
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
|
||||
export const copyTextToClipboard = (text: string): void => {
|
||||
if (!navigator.clipboard) {
|
||||
fallbackCopyTextToClipboard(text)
|
||||
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(
|
||||
function () {
|
||||
console.log(`Async: Copying to clipboard was successful!`)
|
||||
},
|
||||
function (err) {
|
||||
console.error(`Async: Could not copy text: `, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
9
src/features/FAQ/img/qr-code-icon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.23034 0.213989H2.81344C1.63555 0.213989 0.637641 0.992216 0.318594 2.05589C0.285599 2.16592 0.259844 2.279 0.241937 2.39459C0.222201 2.52215 0.211914 2.65276 0.211914 2.78573V5.26C0.211914 6.67876 1.37929 7.8325 2.81344 7.8325L4.02189 7.83273L5.23034 7.83243C6.66442 7.83243 7.83187 6.67869 7.83187 5.25993V2.78573C7.83187 1.36765 6.6645 0.213989 5.23034 0.213989ZM6.68888 5.26C6.68888 6.04821 6.03463 6.68951 5.23004 6.68951L4.02189 6.68974L2.81375 6.68943C2.00916 6.68943 1.35491 6.04814 1.35491 5.26V2.78573C1.35491 2.53953 1.41876 2.30773 1.53116 2.10519C1.63936 1.91035 1.79245 1.74278 1.97677 1.61591C2.21375 1.45277 2.50232 1.35698 2.81337 1.35698H5.23034C6.03463 1.35698 6.68888 1.9979 6.68888 2.78573V5.26Z" fill="black"/>
|
||||
<path d="M14.1854 0.213989H11.7685C10.5906 0.213989 9.59272 0.992216 9.27367 2.05589C9.24068 2.16592 9.21492 2.279 9.19701 2.39459C9.17728 2.52215 9.16699 2.65276 9.16699 2.78573V5.26C9.16699 6.67876 10.3344 7.8325 11.7685 7.8325L12.977 7.83273L14.1854 7.83243C15.6195 7.83243 16.787 6.67869 16.787 5.25993V2.78573C16.787 1.36765 15.6196 0.213989 14.1854 0.213989ZM15.644 5.26C15.644 6.04821 14.9897 6.68951 14.1851 6.68951L12.977 6.68974L11.7688 6.68943C10.9642 6.68943 10.31 6.04814 10.31 5.26V2.78573C10.31 2.53953 10.3738 2.30773 10.4862 2.10519C10.5944 1.91035 10.7475 1.74278 10.9319 1.61591C11.1688 1.45277 11.4574 1.35698 11.7684 1.35698H14.1854C14.9897 1.35698 15.644 1.9979 15.644 2.78573V5.26Z" fill="black"/>
|
||||
<path d="M5.23132 9.16687H2.81442C1.63653 9.16687 0.638618 9.9451 0.31957 11.0088C0.286576 11.1188 0.26082 11.2319 0.242913 11.3475C0.223178 11.475 0.212891 11.6056 0.212891 11.7386V14.2129C0.212891 15.6316 1.38027 16.7854 2.81442 16.7854L4.02287 16.7856L5.23132 16.7853C6.6654 16.7853 7.83285 15.6316 7.83285 14.2128V11.7386C7.83285 10.3205 6.66547 9.16687 5.23132 9.16687ZM6.68986 14.2129C6.68986 15.0011 6.03561 15.6424 5.23102 15.6424L4.02287 15.6426L2.81473 15.6423C2.01013 15.6423 1.35588 15.001 1.35588 14.2129V11.7386C1.35588 11.4924 1.41974 11.2606 1.53213 11.0581C1.64034 10.8632 1.79342 10.6957 1.97775 10.5688C2.21473 10.4056 2.5033 10.3099 2.81435 10.3099H5.23132C6.03561 10.3099 6.68986 10.9508 6.68986 11.7386V14.2129Z" fill="black"/>
|
||||
<path d="M10.4491 12.007C10.4491 12.007 10.4491 12.007 10.4491 12.007C10.0808 12.007 9.78125 11.7134 9.78125 11.3527V10.2203C9.78125 10.1077 9.81054 10.0017 9.86181 9.9089C9.91133 9.81962 9.98143 9.74289 10.0658 9.685C10.1743 9.61036 10.3065 9.56641 10.4488 9.56641H11.555C11.923 9.56641 12.2225 9.85972 12.2225 10.2203V11.3527C12.2225 11.7133 11.923 12.007 11.555 12.007H11.0019H10.4491C10.4491 12.007 10.4491 12.007 10.4491 12.007V12.007Z" fill="black"/>
|
||||
<path d="M10.4491 16.0584C10.0808 16.0584 9.78125 15.7647 9.78125 15.4041V14.2717C9.78125 14.1591 9.81054 14.0531 9.86181 13.9603C9.91133 13.871 9.98143 13.7943 10.0658 13.7364C10.1743 13.6618 10.3065 13.6178 10.4488 13.6178H11.555C11.923 13.6178 12.2225 13.9111 12.2225 14.2717V15.4041C12.2225 15.7647 11.923 16.0584 11.555 16.0584H11.0019H10.4491V16.0584Z" fill="black"/>
|
||||
<path d="M14.7128 12.0068C14.3445 12.0068 14.0449 11.7131 14.0449 11.3525V10.2201C14.0449 10.1074 14.0742 10.0014 14.1255 9.90865C14.175 9.81937 14.2451 9.74264 14.3295 9.68475C14.438 9.61012 14.5701 9.56616 14.7124 9.56616H15.8187C16.1866 9.56616 16.4862 9.85947 16.4862 10.2201V11.3525C16.4862 11.7131 16.1866 12.0067 15.8187 12.0067H15.2656H14.7128V12.0068Z" fill="black"/>
|
||||
<path d="M14.7128 16.0584C14.3445 16.0584 14.0449 15.7647 14.0449 15.4041V14.2717C14.0449 14.1591 14.0742 14.0531 14.1255 13.9603C14.175 13.871 14.2451 13.7943 14.3295 13.7364C14.438 13.6618 14.5701 13.6178 14.7124 13.6178H15.8187C16.1866 13.6178 16.4862 13.9111 16.4862 14.2717V15.4041C16.4862 15.7647 16.1866 16.0584 15.8187 16.0584H15.2656H14.7128V16.0584Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
1
src/features/FAQ/img/question-icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path d="M484 809q16 0 27-11t11-27q0-16-11-27t-27-11q-16 0-27 11t-11 27q0 16 11 27t27 11Zm-35-146h59q0-26 6.5-47.5T555 566q31-26 44-51t13-55q0-53-34.5-85T486 343q-49 0-86.5 24.5T345 435l53 20q11-28 33-43.5t52-15.5q34 0 55 18.5t21 47.5q0 22-13 41.5T508 544q-30 26-44.5 51.5T449 663Zm31 313q-82 0-155-31.5t-127.5-86Q143 804 111.5 731T80 576q0-83 31.5-156t86-127Q252 239 325 207.5T480 176q83 0 156 31.5T763 293q54 54 85.5 127T880 576q0 82-31.5 155T763 858.5q-54 54.5-127 86T480 976Zm0-60q142 0 241-99.5T820 576q0-142-99-241t-241-99q-141 0-240.5 99T140 576q0 141 99.5 240.5T480 916Zm0-340Z"/></svg>
|
||||
|
After Width: | Height: | Size: 680 B |
1
src/features/FAQ/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/FAQ'
|
||||
93
src/features/FAQ/ui/FAQ.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import QRCode from 'react-qr-code'
|
||||
import { BottomSheet, BottomSheetRef } from 'react-spring-bottom-sheet'
|
||||
import { AVTOTRANS_LINK, BETA_TESTER_LINK, TG_LINK } from 'shared/common'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { BottomSheetBgStyled } from 'shared/ui/MainLayout'
|
||||
import { PopupContentStyled } from 'shared/ui/Popup/PopupContent'
|
||||
|
||||
import QuestionIcon from '../img/question-icon.svg'
|
||||
import {
|
||||
QABlockStyled,
|
||||
ShareContainerStyled,
|
||||
ShareContentContainerStyled,
|
||||
ShareItemContainerStyled,
|
||||
ShareTitleStyled,
|
||||
} from './styled'
|
||||
|
||||
export const FAQ: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const focusRef = useRef<HTMLButtonElement>(null)
|
||||
const sheetRef = useRef<BottomSheetRef>(null)
|
||||
|
||||
const handleOpenClick = (): void => {
|
||||
setIsOpen(true)
|
||||
AndrewLytics(`shareOpen`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomSheet
|
||||
ref={sheetRef}
|
||||
initialFocusRef={focusRef}
|
||||
open={isOpen}
|
||||
onDismiss={() => setIsOpen(false)}
|
||||
defaultSnap={({ maxHeight }) => maxHeight / 2}
|
||||
snapPoints={({ maxHeight }) => [maxHeight - maxHeight / 10, maxHeight / 4, maxHeight * 0.6]}
|
||||
>
|
||||
<BottomSheetBgStyled $bg="#fff">
|
||||
<PopupContentStyled>
|
||||
<QABlockStyled>
|
||||
<h2>Что такое RideBus PWA</h2>
|
||||
<p>RideBus PWA — это сайт с расписанием автобуса №2.</p>
|
||||
</QABlockStyled>
|
||||
|
||||
<QABlockStyled>
|
||||
<h2>Зачем</h2>
|
||||
<p>
|
||||
Актуальное расписание №2 публикует филиал «Автобусный парк №2 г.Полоцка» ОАО «Витебскоблавтотранс» на своем{` `}
|
||||
<a href={AVTOTRANS_LINK} target="_blank" rel="noreferrer">
|
||||
сайте
|
||||
</a>
|
||||
{` `}в виде таблиц. В Яндекс Картах расписание не совпадает с этими таблицами.
|
||||
Чтобы смотреть актуальное расписание в приятном формате появился этот сайт.
|
||||
</p>
|
||||
</QABlockStyled>
|
||||
|
||||
<QABlockStyled>
|
||||
<h2>Автобус приезжает не по расписанию</h2>
|
||||
<p>
|
||||
Ситуация на дорогах всегда разная, поэтому автобус не может приезжать минута в минуту.
|
||||
Обычно, автобус приезжает +- 10 минут.
|
||||
</p>
|
||||
</QABlockStyled>
|
||||
|
||||
<QABlockStyled>
|
||||
<h2>Предложения, ошибки, сотрудничество</h2>
|
||||
<p>
|
||||
<a href={TG_LINK} target="_blank" rel="noreferrer">
|
||||
Написать в телеграм
|
||||
</a>
|
||||
</p>
|
||||
</QABlockStyled>
|
||||
|
||||
<ShareContentContainerStyled>
|
||||
<ShareItemContainerStyled>
|
||||
<QRCode size={166} value="https://pwa.ridebus.by?utm=scan" bgColor="transparent" />
|
||||
</ShareItemContainerStyled>
|
||||
|
||||
<ShareItemContainerStyled>
|
||||
<ShareTitleStyled>QR код с ссылкой на сайт</ShareTitleStyled>
|
||||
</ShareItemContainerStyled>
|
||||
</ShareContentContainerStyled>
|
||||
</PopupContentStyled>
|
||||
</BottomSheetBgStyled>
|
||||
</BottomSheet>
|
||||
|
||||
<ShareContainerStyled onClick={handleOpenClick}>
|
||||
<SVG src={QuestionIcon} width={20} height={20} uniquifyIDs style={{ display: `flex` }} />
|
||||
</ShareContainerStyled>
|
||||
</>
|
||||
)
|
||||
}
|
||||
59
src/features/FAQ/ui/styled.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const ShareContainerStyled = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 7px;
|
||||
padding: 6px;
|
||||
`
|
||||
|
||||
export const ShareTitleStyled = styled.h3`
|
||||
margin-top: 24px;
|
||||
`
|
||||
|
||||
export const CopyFieldStyled = styled.div<{ $clicked: boolean }>`
|
||||
padding: 13px 18px;
|
||||
color: ${props => (props.$clicked ? `green` : `#336cff`)};
|
||||
font-weight: 500;
|
||||
border-radius: 15px;
|
||||
background: #f2f4f4;
|
||||
text-align: left;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
export const QABlockStyled = styled.article`
|
||||
h2 {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #336cff;
|
||||
|
||||
&:visited {
|
||||
color: #2859d5;
|
||||
}
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-top: 24px;
|
||||
}
|
||||
`
|
||||
export const ShareItemContainerStyled = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
export const ShareContentContainerStyled = styled.div`
|
||||
margin-top: 56px;
|
||||
`
|
||||
@@ -0,0 +1,7 @@
|
||||
import { StopKeys } from 'shared/store/busStop/Stops'
|
||||
|
||||
export const getFavoriteBusStop = (): StopKeys[] => {
|
||||
const localStorageItem = localStorage.getItem(`favoriteStops`)
|
||||
|
||||
return localStorageItem ? (JSON.parse(localStorageItem) as StopKeys[]) : []
|
||||
}
|
||||
3
src/features/FavoriteStops/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ui/FavoriteButton'
|
||||
export * from './ui/FavoriteStops'
|
||||
export * from './model/favoriteStopsSlice'
|
||||
31
src/features/FavoriteStops/model/favoriteStopsSlice.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { RootState } from 'shared/store/app/configureStore'
|
||||
import { StopKeys } from 'shared/store/busStop/Stops'
|
||||
|
||||
import { getFavoriteBusStop } from '../helpers/getFavoriteBusStop'
|
||||
|
||||
export interface FavoriteStopsState {
|
||||
stops: StopKeys[]
|
||||
}
|
||||
|
||||
const initialState: FavoriteStopsState = {
|
||||
stops: getFavoriteBusStop(),
|
||||
}
|
||||
|
||||
export const favoriteStopsSlice = createSlice({
|
||||
name: `favoriteStopsSlice`,
|
||||
initialState,
|
||||
reducers: {
|
||||
saveFavoriteBusStops: (state, action: PayloadAction<StopKeys[]>) => {
|
||||
state.stops = action.payload
|
||||
localStorage.setItem(`favoriteStops`, JSON.stringify(state.stops))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const { saveFavoriteBusStops } = favoriteStopsSlice.actions
|
||||
|
||||
export const favoriteStopsSelector = (state: RootState): StopKeys[] => state.favoriteStops.stops
|
||||
|
||||
export default favoriteStopsSlice.reducer
|
||||
51
src/features/FavoriteStops/ui/FavoriteButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { busStopSelector } from 'shared/store/busStop/busStopInfoSlice'
|
||||
import { StopKeys } from 'shared/store/busStop/Stops'
|
||||
import { CustomButtonStyled } from 'shared/ui'
|
||||
|
||||
import { favoriteStopsSelector, saveFavoriteBusStops } from '../model/favoriteStopsSlice'
|
||||
|
||||
export const FavoriteButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const busStop = useSelector(busStopSelector)
|
||||
const favoriteBusStops = useSelector(favoriteStopsSelector)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const handleAddFavoriteStatus = useCallback(() => {
|
||||
if (!busStop) return
|
||||
|
||||
if (favoriteBusStops.includes(busStop)) return
|
||||
|
||||
const newStops: StopKeys[] = [busStop, ...favoriteBusStops]
|
||||
dispatch(saveFavoriteBusStops(newStops))
|
||||
|
||||
AndrewLytics(`addStop`)
|
||||
}, [busStop, favoriteBusStops, dispatch])
|
||||
|
||||
const handleRemoveFavoriteStatus = useCallback(() => {
|
||||
if (!busStop) return
|
||||
|
||||
if (!favoriteBusStops.includes(busStop)) return
|
||||
|
||||
const newStops: StopKeys[] = favoriteBusStops.filter(stop => stop !== busStop)
|
||||
dispatch(saveFavoriteBusStops(newStops))
|
||||
}, [busStop, favoriteBusStops, dispatch])
|
||||
|
||||
const isBusStopFavorite = useMemo(
|
||||
() => (busStop ? favoriteBusStops.includes(busStop) : false),
|
||||
[busStop, favoriteBusStops],
|
||||
)
|
||||
|
||||
return (
|
||||
<CustomButtonStyled
|
||||
$status={isBusStopFavorite ? `danger` : `primary`}
|
||||
$mt="12px"
|
||||
onClick={isBusStopFavorite ? handleRemoveFavoriteStatus : handleAddFavoriteStatus}
|
||||
>
|
||||
{isBusStopFavorite ? t(`Remove stop from favorite`) : t(`Add stop to favorite`)}
|
||||
</CustomButtonStyled>
|
||||
)
|
||||
}
|
||||
45
src/features/FavoriteStops/ui/FavoriteStops.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { busStopSelector, setBusStop, stopsOptionsSelector } from 'shared/store/busStop/busStopInfoSlice'
|
||||
import { StopKeys } from 'shared/store/busStop/Stops'
|
||||
import { CardStyled, ContainerStyled } from 'shared/ui'
|
||||
import { Header } from 'shared/ui/Header'
|
||||
import { InlineOptions } from 'shared/ui/InlineOptions'
|
||||
|
||||
import { favoriteStopsSelector } from '../model/favoriteStopsSlice'
|
||||
|
||||
export const FavoriteStops: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const favoriteBusStops = useSelector(favoriteStopsSelector)
|
||||
const stopsOptions = useSelector(stopsOptionsSelector)
|
||||
const busStop = useSelector(busStopSelector)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const favoriteList = useMemo(
|
||||
() => stopsOptions.filter(stop => stop.value && favoriteBusStops.includes(stop.value)),
|
||||
[stopsOptions, favoriteBusStops],
|
||||
)
|
||||
|
||||
const handleChangeBusStop = useCallback(
|
||||
(busStopToChange: StopKeys | null) => {
|
||||
if (busStopToChange) dispatch(setBusStop(busStopToChange))
|
||||
},
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
if (favoriteList.length === 0) return null
|
||||
|
||||
const myStops = t(`My stops`)
|
||||
|
||||
return (
|
||||
<ContainerStyled>
|
||||
<CardStyled>
|
||||
<Header text={myStops} />
|
||||
<InlineOptions<StopKeys> list={favoriteList} activeId={busStop} onClick={handleChangeBusStop} />
|
||||
</CardStyled>
|
||||
</ContainerStyled>
|
||||
)
|
||||
}
|
||||
1
src/features/HeaderScore/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/HeaderScore'
|
||||
27
src/features/HeaderScore/ui/HeaderScore.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
|
||||
import { StyledScore } from './styled'
|
||||
|
||||
export const HeaderScore: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [score, setScore] = useState<undefined | string>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const scoreToChange = localStorage.getItem(`score`)
|
||||
if (scoreToChange && !Number.isNaN(Number(scoreToChange))) {
|
||||
setScore(scoreToChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!score) return null
|
||||
|
||||
return (
|
||||
<Link to={`/game?${searchParams.toString()}`} onClick={() => AndrewLytics(`game.headerScore`)}>
|
||||
<StyledScore $selected={false} $destroyed={false}>
|
||||
{score}
|
||||
</StyledScore>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
20
src/features/HeaderScore/ui/styled.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { GameCell } from 'page/Game/styled'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const StyledScore = styled(GameCell)`
|
||||
min-width: 30px;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
|
||||
background: linear-gradient(180.17deg, #723cb7 0.15%, #ff47e2 99.85%);
|
||||
box-shadow: 0px 2px 8px rgba(121, 61, 185, 0.15);
|
||||
|
||||
&::after {
|
||||
height: 1px;
|
||||
margin: 0 2px;
|
||||
top: 2px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
`
|
||||
61
src/features/HowMuchLeft/HowMuchLeft.styled.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const HowMuchLeftContainerStyled = styled.div<{ $isFancy: boolean; $defaultColor: string }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 18px 17px;
|
||||
background: ${props =>
|
||||
props.$isFancy
|
||||
? `linear-gradient(93.72deg, #5020CA 9.83%, #5020CA 9.84%, #8365E0 96.15%);`
|
||||
: props.$defaultColor};
|
||||
color: ${props => (props.$isFancy ? `white` : `black`)};
|
||||
border-radius: 13px;
|
||||
`
|
||||
|
||||
export const NextBusContainerStyled = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
export const FastReplyContainerStyled = styled.div`
|
||||
margin-top: 18px;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const FastReplyButtonStyled = styled.button`
|
||||
background: #e4f5d6;
|
||||
border-radius: 13px;
|
||||
color: black;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
`
|
||||
export const TextWrapperStyled = styled.div``
|
||||
|
||||
export const HighLightedStyled = styled.span`
|
||||
font-weight: bold;
|
||||
/* animation-duration: 2s;
|
||||
animation-name: flashing;
|
||||
animation-iteration-count: infinite;
|
||||
|
||||
@keyframes flashing {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
} */
|
||||
`
|
||||
|
||||
export const BusEstimationStyled = styled.div`
|
||||
font-size: 18px;
|
||||
margin-left: 19px;
|
||||
`
|
||||
113
src/features/HowMuchLeft/HowMuchLeft.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { Holiday } from 'entities/Holiday'
|
||||
import { SelectBusStopText } from 'entities/SelectBusStopText'
|
||||
import { Fastreply } from 'features/Complains/ui/Fastreply'
|
||||
import { useTypedSelector } from 'shared/lib'
|
||||
import { isHalloween } from 'shared/store/app/selectors/isHalloween'
|
||||
import { StopKeys } from 'shared/store/busStop/Stops'
|
||||
import { IHoliday } from 'shared/store/holidays/IHolidays'
|
||||
import { ITime } from 'shared/store/timeLeft/ITime'
|
||||
|
||||
import { ImageWrapperStyled } from '../../shared/ui/ImageWrapper'
|
||||
import {
|
||||
BusEstimationStyled,
|
||||
HighLightedStyled,
|
||||
HowMuchLeftContainerStyled,
|
||||
NextBusContainerStyled,
|
||||
TextWrapperStyled,
|
||||
} from './HowMuchLeft.styled'
|
||||
import Dead from './img/dead.svg'
|
||||
import EvilFace from './img/evil-face.svg'
|
||||
import NextBus from './img/next-bus.svg'
|
||||
import Pumpkin from './img/pumpkin.svg'
|
||||
|
||||
const SIZE = 45
|
||||
|
||||
export const LeftToString: React.FC<{ left: ITime; busStop: StopKeys | null }> = ({ busStop, left }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!busStop) return <SelectBusStopText />
|
||||
|
||||
if (left.hours === null && left.minutes === null)
|
||||
return (
|
||||
<TextWrapperStyled>
|
||||
{t(`Bus on stop`)} <b>{busStop}</b> {t(`today wont arrive`)}
|
||||
</TextWrapperStyled>
|
||||
)
|
||||
|
||||
const leftString = left.hours || `0`
|
||||
|
||||
return (
|
||||
<TextWrapperStyled>
|
||||
{t(`Next bus arriving in`)}
|
||||
{` `}
|
||||
<HighLightedStyled>
|
||||
{left.hours === 0 ? `` : `${leftString}ч `}
|
||||
{left.minutes}м
|
||||
</HighLightedStyled>
|
||||
</TextWrapperStyled>
|
||||
)
|
||||
}
|
||||
|
||||
interface ILeftProps {
|
||||
left: ITime
|
||||
busStopLabel: StopKeys | null
|
||||
holiday: IHoliday | null
|
||||
}
|
||||
|
||||
export const HowMuchLeft: React.FC<ILeftProps> = ({ left, busStopLabel, holiday }) => {
|
||||
const isHalloweenMode = useTypedSelector(isHalloween)
|
||||
const isFancy = isHalloweenMode || !!holiday
|
||||
const [currentIcon, setIcon] = useState(isHalloweenMode ? Pumpkin : NextBus)
|
||||
const [iconClickCounter, setIconClickCounter] = useState(0)
|
||||
|
||||
const getColorByLeftTime = (): string => {
|
||||
if (left.hours === null || left.minutes === null || left.hours >= 1) return `#e7edec`
|
||||
|
||||
if (left.minutes > 15 && left.minutes < 35) return `#E4F5D6`
|
||||
if (left.minutes <= 15) return `#FBDCDC`
|
||||
|
||||
return `#e7edec`
|
||||
}
|
||||
|
||||
const handleIconClick = (): void => {
|
||||
setIconClickCounter(prev => prev + 1)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHalloweenMode) return
|
||||
if (iconClickCounter > 18) {
|
||||
setIcon(Dead)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (iconClickCounter % 2 === 0) {
|
||||
setIcon(Pumpkin)
|
||||
} else {
|
||||
setIcon(EvilFace)
|
||||
}
|
||||
}, [iconClickCounter, isHalloweenMode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<HowMuchLeftContainerStyled $isFancy={isFancy} $defaultColor={getColorByLeftTime()}>
|
||||
<NextBusContainerStyled>
|
||||
<ImageWrapperStyled $w={SIZE} $h={SIZE}>
|
||||
<SVG src={currentIcon} width={SIZE} height={SIZE} uniquifyIDs onClick={handleIconClick} />
|
||||
</ImageWrapperStyled>
|
||||
|
||||
<BusEstimationStyled>
|
||||
<LeftToString busStop={busStopLabel} left={left} />
|
||||
</BusEstimationStyled>
|
||||
</NextBusContainerStyled>
|
||||
</HowMuchLeftContainerStyled>
|
||||
|
||||
<Fastreply />
|
||||
|
||||
{holiday && <Holiday />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/features/HowMuchLeft/img/dead.svg
Normal file
|
After Width: | Height: | Size: 40 KiB |
1
src/features/HowMuchLeft/img/evil-face.svg
Normal file
|
After Width: | Height: | Size: 61 KiB |
9
src/features/HowMuchLeft/img/next-bus.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
9
src/features/HowMuchLeft/img/pumpkin.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
1
src/features/HowMuchLeft/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './HowMuchLeft'
|
||||
1
src/features/Info/img/infoclosecross.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none"><g fill="#000" opacity=".5"><rect width="2.05" height="13.37" rx="1.03" transform="matrix(-.71306 -.7011 -.69903 .71509 11.8 2.36)"/><rect width="2.05" height="13.37" rx="1.03" transform="scale(.99855 -1.00145) rotate(45 19.5 6.7)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
1
src/features/Info/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/Info'
|
||||
20
src/features/Info/model/info.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { FetchInfoResponse, INFO_URL } from 'shared/api'
|
||||
|
||||
import { IInfo } from '../types/IInfo'
|
||||
|
||||
// Define a service using a base URL and expected endpoints
|
||||
export const infoApi = createApi({
|
||||
reducerPath: `infoApi`,
|
||||
baseQuery: fetchBaseQuery({ baseUrl: INFO_URL }),
|
||||
endpoints: builder => ({
|
||||
getInfo: builder.query<IInfo, void>({
|
||||
query: () => ``,
|
||||
transformResponse: (response: FetchInfoResponse) => response.fields,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
// Export hooks for usage in functional components, which are
|
||||
// auto-generated based on the defined endpoints
|
||||
export const { useGetInfoQuery } = infoApi
|
||||
5
src/features/Info/types/IInfo.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface IInfo {
|
||||
message: string | null
|
||||
id: number | null
|
||||
link: string | null
|
||||
}
|
||||
54
src/features/Info/ui/Info.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { ContainerStyled } from 'shared/ui/common'
|
||||
|
||||
import InfoCloseCross from '../img/infoclosecross.svg'
|
||||
import { useGetInfoQuery } from '../model/info'
|
||||
import { InfoCloseButtonStyled, InfoLinkStyled, InfoTextStyled, InfoWrapperStyled } from './styled'
|
||||
|
||||
export const Info: React.FC = () => {
|
||||
const [isInfoShow, setIsInfoShow] = useState(false)
|
||||
const { data: infoMessage } = useGetInfoQuery()
|
||||
|
||||
const onLinkClick = (): void => {
|
||||
AndrewLytics(`infoBlockLinkClick`)
|
||||
}
|
||||
|
||||
const onInfoCrossClick = (): void => {
|
||||
setIsInfoShow(false)
|
||||
if (infoMessage?.id) {
|
||||
localStorage.setItem(`infoMessageId`, String(infoMessage.id))
|
||||
}
|
||||
|
||||
AndrewLytics(`infoBlockHide`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!infoMessage?.id) return
|
||||
|
||||
const infoMessageId = localStorage.getItem(`infoMessageId`)
|
||||
if (Number(infoMessageId) !== Number(infoMessage.id)) setIsInfoShow(true)
|
||||
}, [infoMessage])
|
||||
|
||||
if (!infoMessage || !isInfoShow) return null
|
||||
|
||||
const { message: text, link } = infoMessage
|
||||
|
||||
return (
|
||||
<ContainerStyled>
|
||||
<InfoWrapperStyled>
|
||||
{link ? (
|
||||
<InfoLinkStyled href={link} target="_blank" onClick={onLinkClick}>
|
||||
{text}
|
||||
</InfoLinkStyled>
|
||||
) : (
|
||||
<InfoTextStyled>{text}</InfoTextStyled>
|
||||
)}
|
||||
<InfoCloseButtonStyled data-testid="hide-btn" onClick={onInfoCrossClick}>
|
||||
<SVG className="closebutton" src={InfoCloseCross} />
|
||||
</InfoCloseButtonStyled>
|
||||
</InfoWrapperStyled>
|
||||
</ContainerStyled>
|
||||
)
|
||||
}
|
||||
56
src/features/Info/ui/styled.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const InfoWrapperStyled = styled.div`
|
||||
width: 100%;
|
||||
padding: 15px 30px 15px 15px;
|
||||
|
||||
background: linear-gradient(100.09deg, #0374f9 -39.57%, #35e0ff 124.36%);
|
||||
box-shadow: 0px 1px 22px -4px rgba(14, 139, 251, 0.46);
|
||||
border-radius: 18px;
|
||||
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export const InfoTextStyled = styled.p`
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
|
||||
color: #ffffff;
|
||||
`
|
||||
|
||||
export const InfoLinkStyled = styled.a`
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
|
||||
text-decoration: underline;
|
||||
color: #ffffff;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`
|
||||
|
||||
export const InfoCloseButtonStyled = styled.div`
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
.closebutton {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
}
|
||||
`
|
||||
34
src/features/LeaveFeedbackButton/LeaveFeedbackButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { FEEDBACK_LINK } from 'shared/common'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { ContainerStyled } from 'shared/ui'
|
||||
|
||||
import Bird from './img/bird-zamanuha.svg'
|
||||
import { BirdContainerStyled, BirdWrapperStyled, FeedbackLinkStyled, FeedbackTextStyled } from './styled'
|
||||
|
||||
export const LeaveFeedbackButton: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onFeedbackClick = (): void => {
|
||||
AndrewLytics(`FeedbackClick`)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContainerStyled $doubled>
|
||||
<BirdWrapperStyled>
|
||||
<FeedbackLinkStyled href={FEEDBACK_LINK} onClick={onFeedbackClick}>
|
||||
<FeedbackTextStyled>{t(`Leave feedback`)}</FeedbackTextStyled>
|
||||
</FeedbackLinkStyled>
|
||||
|
||||
<BirdContainerStyled onClick={() => AndrewLytics(`game.birdPlay`)}>
|
||||
<Link to={`/game?${searchParams.toString()}`} onClick={() => AndrewLytics(`game.footerPlay`)}>
|
||||
<SVG src={Bird} width={32} height={48} uniquifyIDs />
|
||||
</Link>
|
||||
</BirdContainerStyled>
|
||||
</BirdWrapperStyled>
|
||||
</ContainerStyled>
|
||||
)
|
||||
}
|
||||
1
src/features/LeaveFeedbackButton/img/bird-zamanuha.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="49" fill="none"><rect width="32" height="32" fill="#000" fill-opacity=".74" rx="5"/><path fill="#fff" d="M8 14.18V20H6.77v-4.65h-.03l-1.33.83V15.1l1.44-.9H8Zm3.54 5.9c-.43 0-.8-.07-1.14-.22a1.93 1.93 0 0 1-.78-.6 1.53 1.53 0 0 1-.3-.9h1.25c0 .14.05.26.14.37.08.1.2.19.34.25.15.06.3.09.49.09s.35-.04.5-.1a.81.81 0 0 0 .33-.28.7.7 0 0 0 .13-.4c0-.16-.05-.3-.13-.42a.83.83 0 0 0-.37-.28c-.16-.07-.35-.1-.57-.1h-.54v-.9h.54c.18 0 .35-.04.49-.1a.8.8 0 0 0 .34-.27.7.7 0 0 0 .11-.4.7.7 0 0 0-.4-.65 1.13 1.13 0 0 0-.89 0 .8.8 0 0 0-.32.25.67.67 0 0 0-.14.39H9.45c0-.34.1-.63.28-.89.19-.26.44-.46.75-.6.32-.14.68-.22 1.07-.22.4 0 .76.08 1.06.22.3.15.53.34.7.6.17.24.25.51.25.82 0 .33-.1.6-.3.82-.2.21-.47.35-.8.41v.05c.43.05.76.2.98.44.22.24.34.54.33.9 0 .34-.09.63-.28.89-.2.26-.46.46-.8.6-.33.16-.72.23-1.15.23Zm3.84-.76a.66.66 0 0 1-.49-.2.66.66 0 0 1-.2-.48c0-.18.07-.34.2-.48.14-.13.3-.2.49-.2.18 0 .34.07.48.2.13.14.2.3.2.48 0 .13-.03.24-.1.35a.73.73 0 0 1-.24.24c-.1.06-.22.1-.34.1Zm0-2.85a.66.66 0 0 1-.49-.2.66.66 0 0 1-.2-.48c0-.19.07-.35.2-.48.14-.13.3-.2.49-.2.18 0 .34.07.48.2.13.13.2.3.2.48 0 .12-.03.24-.1.34a.7.7 0 0 1-.24.25c-.1.06-.22.09-.34.09Zm3.79 3.61c-.4 0-.76-.07-1.08-.22a1.87 1.87 0 0 1-.74-.61 1.57 1.57 0 0 1-.29-.9h1.2c0 .23.1.41.28.55.18.14.38.21.63.21a1 1 0 0 0 .5-.12c.16-.1.27-.21.36-.36.08-.16.13-.34.13-.54s-.05-.38-.14-.54a.92.92 0 0 0-.35-.36 1.05 1.05 0 0 0-.52-.14c-.17 0-.34.04-.5.1a.9.9 0 0 0-.37.3l-1.1-.2.28-3.07h3.56v1h-2.54l-.15 1.46h.03c.1-.14.26-.26.46-.35a1.79 1.79 0 0 1 2.28.78c.16.29.24.62.24.99a1.92 1.92 0 0 1-1.04 1.76c-.32.17-.7.26-1.13.26Zm5.2 0c-.44 0-.83-.07-1.17-.21-.34-.15-.6-.34-.8-.6-.2-.24-.29-.52-.29-.84 0-.24.06-.46.17-.66a1.5 1.5 0 0 1 1.1-.77v-.04a1.3 1.3 0 0 1-1.05-1.29c0-.3.09-.57.26-.8.18-.24.42-.43.73-.56.3-.14.66-.2 1.05-.2.4 0 .74.06 1.05.2.3.13.55.32.72.56.18.23.27.5.27.8a1.3 1.3 0 0 1-1.04 1.29V17c.23.04.44.12.63.26a1.48 1.48 0 0 1 .63 1.17c0 .32-.1.6-.3.85-.19.25-.46.44-.8.59a3 3 0 0 1-1.16.2Zm0-.9c.2 0 .36-.04.51-.1a.84.84 0 0 0 .34-.3.78.78 0 0 0 .13-.44.79.79 0 0 0-.13-.45.88.88 0 0 0-.35-.3 1.08 1.08 0 0 0-.5-.12c-.19 0-.36.04-.5.12a.88.88 0 0 0-.35.3.8.8 0 0 0-.13.45c0 .16.04.3.12.44.08.12.2.22.35.3.14.06.32.1.5.1Zm0-2.6c.16 0 .3-.04.43-.1a.75.75 0 0 0 .4-.68.73.73 0 0 0-.4-.67.92.92 0 0 0-.43-.1.93.93 0 0 0-.44.1.74.74 0 0 0-.3.27.74.74 0 0 0-.1.4c0 .15.03.28.1.4.08.12.18.21.3.28.13.06.28.1.44.1Z"/><path fill="#fff" fill-opacity=".14" d="M3 2.84c0-.46.38-.84.84-.84h23.58a.84.84 0 0 1 0 1.68H3.84A.84.84 0 0 1 3 2.84Z"/><path stroke="#000" d="M24 32v11.5m0 0c-2.5.5-2.5 1-4 1.5-.85.28 1.27-.95 4-1.5Zm0 0c-2 1.5-2.14 4.55-2 4 .5-2 .5-2 2-4Zm0 0c1.84 1.4 1.2 1.2 2 2 .5.5-.02-.5-2-2ZM9 32v8.5l-.5 3m0 0c-2.83.17-4.5.5-6 1-.5.17 1-.7 6-1Zm0 0C6 44.5 3.43 48 4 48c.5 0 .5-2 4.5-4.5Zm0 0c2 1.5 2 3 2 3.5s1-1-2-3.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
src/features/LeaveFeedbackButton/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './LeaveFeedbackButton'
|
||||
38
src/features/LeaveFeedbackButton/styled.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const FeedbackLinkStyled = styled.a`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
padding: 23px 32px;
|
||||
width: 100%;
|
||||
|
||||
background: #caeaff;
|
||||
border-radius: 15px;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const FeedbackTextStyled = styled.div`
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
line-height: 19px;
|
||||
|
||||
color: #000000;
|
||||
`
|
||||
|
||||
export const BirdWrapperStyled = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export const BirdContainerStyled = styled.div`
|
||||
position: absolute;
|
||||
top: -44px;
|
||||
right: 9px;
|
||||
`
|
||||
69
src/features/MyLocation/MyLocation.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { getLocation } from './helpers/getLocation'
|
||||
import LocationSVG from './img/location.svg'
|
||||
import { setLocation, setLocationError, setNoGeolocation } from './model/myLocationSlice'
|
||||
|
||||
export const GeoLocationStyled = styled.div`
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
z-index: 3;
|
||||
|
||||
box-shadow: 0px 1px 13px rgba(0, 0, 0, 0.08);
|
||||
`
|
||||
const SIZE = 16
|
||||
export const MyLocation: React.FC = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const onSuccess = (position: GeolocationPosition): void => {
|
||||
dispatch(
|
||||
setLocation({
|
||||
coords: {
|
||||
accuracy: position.coords.accuracy,
|
||||
altitude: position.coords.altitude,
|
||||
altitudeAccuracy: position.coords.altitudeAccuracy,
|
||||
heading: position.coords.heading,
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
speed: position.coords.speed,
|
||||
},
|
||||
timestamp: position.timestamp,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const onError = (data: GeolocationPositionError): void => {
|
||||
dispatch(
|
||||
setLocationError({
|
||||
code: data.code,
|
||||
message: data.message,
|
||||
PERMISSION_DENIED: data.PERMISSION_DENIED,
|
||||
POSITION_UNAVAILABLE: data.POSITION_UNAVAILABLE,
|
||||
TIMEOUT: data.TIMEOUT,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const onNoLocation = (): void => {
|
||||
dispatch(setNoGeolocation(true))
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
getLocation(onSuccess, onError, onNoLocation)
|
||||
}
|
||||
|
||||
return (
|
||||
<GeoLocationStyled onClick={handleClick}>
|
||||
<SVG src={LocationSVG} width={SIZE} height={SIZE} uniquifyIDs />
|
||||
</GeoLocationStyled>
|
||||
)
|
||||
}
|
||||
13
src/features/MyLocation/helpers/getLocation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const getLocation = (
|
||||
successCallback: PositionCallback,
|
||||
errorCallback: PositionErrorCallback | null | undefined,
|
||||
noLocatonCb: () => void,
|
||||
options?: PositionOptions | undefined,
|
||||
): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(successCallback, errorCallback, options)
|
||||
} else {
|
||||
noLocatonCb()
|
||||
}
|
||||
}
|
||||
3
src/features/MyLocation/img/location.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.1584 16.9749L8.00154 10.467C8.00057 10.4641 7.99829 10.4618 7.99537 10.4607L1.02394 8.00842C1.01538 8.00541 1.01494 7.99346 1.02325 7.98983L16.9769 1.01011C16.9852 1.00648 16.9936 1.01487 16.9901 1.0232L10.177 16.9757C10.1734 16.9842 10.1613 16.9837 10.1584 16.9749Z" stroke="black" stroke-width="1.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
1
src/features/MyLocation/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './MyLocation'
|
||||
38
src/features/MyLocation/model/myLocationSlice.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { RootState } from 'shared/store/app/configureStore'
|
||||
|
||||
export interface MyLocationState {
|
||||
location: GeolocationPosition | undefined
|
||||
error: GeolocationPositionError | undefined
|
||||
noGeolocation: boolean
|
||||
}
|
||||
|
||||
const initialState: MyLocationState = {
|
||||
location: undefined,
|
||||
error: undefined,
|
||||
noGeolocation: false,
|
||||
}
|
||||
|
||||
export const myLocationSlice = createSlice({
|
||||
name: `myLocationSlice`,
|
||||
initialState,
|
||||
reducers: {
|
||||
setLocation: (state, action: PayloadAction<GeolocationPosition>) => {
|
||||
state.location = action.payload
|
||||
},
|
||||
setLocationError: (state, action: PayloadAction<GeolocationPositionError>) => {
|
||||
state.error = action.payload
|
||||
},
|
||||
setNoGeolocation: (state, action: PayloadAction<boolean>) => {
|
||||
state.noGeolocation = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const { setLocation, setLocationError, setNoGeolocation } = myLocationSlice.actions
|
||||
|
||||
export const userLocationSelector = (state: RootState): GeolocationPosition | undefined =>
|
||||
state.myLocationSlice.location
|
||||
|
||||
export default myLocationSlice.reducer
|
||||
36
src/features/NearestStops/NearestStops.styled.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const NearestStopStyled = styled.article`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 9px;
|
||||
background-color: #fff;
|
||||
border-radius: 19px;
|
||||
background: radial-gradient(50% 50% at 50% 50%, #fff 0%, rgba(255, 255, 255, 0) 100%), #f2f4f4;
|
||||
|
||||
& + & {
|
||||
margin-left: 9px;
|
||||
}
|
||||
`
|
||||
|
||||
export const NearestStopsStyled = styled.div`
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
background-color: #fff;
|
||||
`
|
||||
|
||||
export const NearestStopsLabelStyled = styled.span`
|
||||
display: -webkit-box;
|
||||
max-width: 115px;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 9px;
|
||||
`
|
||||
|
||||
export const NearestStopsDirectionStyled = styled.span`
|
||||
opacity: 0.66;
|
||||
`
|
||||
71
src/features/NearestStops/NearestStops.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { getDirectionString } from 'features/Complains/helpers'
|
||||
import { userLocationSelector } from 'features/MyLocation/model/myLocationSlice'
|
||||
import L from 'leaflet'
|
||||
import { setBusStopNew } from 'shared/store/busStop/busStopInfoSlice'
|
||||
import { STOPS } from 'shared/store/busStop/const/stops'
|
||||
import { DirectionsNew, IStops } from 'shared/store/busStop/Stops'
|
||||
import { CardStyled, ContainerStyled } from 'shared/ui'
|
||||
|
||||
import {
|
||||
NearestStopsDirectionStyled,
|
||||
NearestStopsLabelStyled,
|
||||
NearestStopsStyled,
|
||||
NearestStopStyled,
|
||||
} from './NearestStops.styled'
|
||||
|
||||
const MAX_DISTANCE = 300
|
||||
|
||||
interface INearestStopProps extends IStops<DirectionsNew> {
|
||||
onClick: (id: string) => void
|
||||
}
|
||||
|
||||
const NearestStop: React.FC<INearestStopProps> = ({ direction, label, id, onClick }) => {
|
||||
const handleClick = (): void => onClick(id)
|
||||
|
||||
return (
|
||||
<NearestStopStyled onClick={handleClick}>
|
||||
<NearestStopsLabelStyled>{label}</NearestStopsLabelStyled>
|
||||
<NearestStopsDirectionStyled>{getDirectionString(direction)}</NearestStopsDirectionStyled>
|
||||
</NearestStopStyled>
|
||||
)
|
||||
}
|
||||
export const NearestStops: React.FC = () => {
|
||||
const userLocation = useSelector(userLocationSelector)
|
||||
const dispatch = useDispatch()
|
||||
const [stops, setStops] = useState<IStops<DirectionsNew>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (userLocation) {
|
||||
const nearestStops = STOPS.filter(
|
||||
stop =>
|
||||
L.latLng([userLocation.coords.latitude, userLocation.coords.longitude]).distanceTo(stop.latLon) <
|
||||
MAX_DISTANCE,
|
||||
)
|
||||
|
||||
setStops(nearestStops)
|
||||
}
|
||||
}, [userLocation])
|
||||
|
||||
const onStopClick = useCallback(
|
||||
(id: string) => {
|
||||
dispatch(setBusStopNew(id))
|
||||
},
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
if (stops.length === 0) return null
|
||||
|
||||
return (
|
||||
<ContainerStyled>
|
||||
<CardStyled>
|
||||
<NearestStopsStyled>
|
||||
{stops.map(stop => (
|
||||
<NearestStop {...stop} onClick={onStopClick} key={stop.id} />
|
||||
))}
|
||||
</NearestStopsStyled>
|
||||
</CardStyled>
|
||||
</ContainerStyled>
|
||||
)
|
||||
}
|
||||
1
src/features/NearestStops/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './NearestStops'
|
||||
77
src/features/OtherTimeBuses/OtherTimeBuses.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import Select from 'react-select'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { busStopSelector, directionSelector } from 'shared/store/busStop/busStopInfoSlice'
|
||||
import { IOption } from 'shared/store/busStop/Stops'
|
||||
import { nextDaySelector, scheduleSelector } from 'shared/store/schedule/scheduleSlice'
|
||||
import { CardStyled, ContainerStyled } from 'shared/ui'
|
||||
import { Header } from 'shared/ui/Header/Header'
|
||||
import { OtherTimeStyled } from 'shared/ui/OtherTime'
|
||||
import { selectStyles } from 'shared/ui/SelectStyles'
|
||||
import { TimeStampStyled } from 'shared/ui/TimeStamp'
|
||||
|
||||
import { SelectBusStopText } from '../../entities/SelectBusStopText'
|
||||
|
||||
export const OtherTimeBusses: React.FC = () => {
|
||||
const busStop = useSelector(busStopSelector)
|
||||
const nextDay = useSelector(nextDaySelector)
|
||||
const SCHEDULE = useSelector(scheduleSelector)
|
||||
const direction = useSelector(directionSelector)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const DaysOptions = [
|
||||
{
|
||||
label: t(`Tomorrow`),
|
||||
value: nextDay,
|
||||
},
|
||||
{
|
||||
label: t(`Workday`),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: t(`Weekday`),
|
||||
value: 2,
|
||||
},
|
||||
]
|
||||
|
||||
const [busOption, setBusOption] = useState<IOption<number>>(DaysOptions[0])
|
||||
|
||||
const handleChange = (e: IOption<number> | null): void => {
|
||||
if (!e) return
|
||||
AndrewLytics(`otherSchedule`)
|
||||
setBusOption(e)
|
||||
}
|
||||
|
||||
const renderOtherTimeContent = useMemo(() => {
|
||||
return busStop ? (
|
||||
SCHEDULE[direction][busOption.value][busStop].map(timeKeys => (
|
||||
<TimeStampStyled key={`${timeKeys}`}>{timeKeys}</TimeStampStyled>
|
||||
))
|
||||
) : (
|
||||
<SelectBusStopText />
|
||||
)
|
||||
}, [busStop, SCHEDULE, direction, busOption])
|
||||
|
||||
return (
|
||||
<ContainerStyled>
|
||||
<CardStyled>
|
||||
<Header text={t(`Buses for`)}>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
styles={selectStyles}
|
||||
options={DaysOptions}
|
||||
onChange={handleChange}
|
||||
value={busOption}
|
||||
defaultValue={DaysOptions[0]}
|
||||
/>
|
||||
</Header>
|
||||
|
||||
<OtherTimeStyled>{renderOtherTimeContent}</OtherTimeStyled>
|
||||
</CardStyled>
|
||||
</ContainerStyled>
|
||||
)
|
||||
}
|
||||
1
src/features/OtherTimeBuses/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './OtherTimeBuses'
|
||||
5
src/global.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module '*.svg' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
3
src/page/Dev/Dev.styles.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const DevStyled = styled.div``;
|
||||
143
src/page/Dev/Dev.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { FC, useState } from 'react'
|
||||
import { SCHEDULE } from 'shared/common'
|
||||
|
||||
import { DevStyled } from './Dev.styles'
|
||||
|
||||
type TTimeCode = { mins: number; hours: number }
|
||||
const minutesToString = (minutes: number): string => (minutes < 10 ? `0${minutes}` : `${minutes}`)
|
||||
|
||||
const parseTimeCode = (timeCode: string): TTimeCode => {
|
||||
const splitted = timeCode.split(`:`)
|
||||
|
||||
return {
|
||||
hours: Number(splitted[0]),
|
||||
mins: Number(splitted[1]),
|
||||
}
|
||||
}
|
||||
|
||||
const timeCodeToMinutes = (timeCode: TTimeCode): number => timeCode.hours * 60 + timeCode.mins
|
||||
|
||||
const increeseStopTime = (timeCode: string, on: number): string => {
|
||||
const splittedCode = timeCode.split(`:`)
|
||||
const hours = Number(splittedCode[0])
|
||||
const mins = Number(splittedCode[1])
|
||||
|
||||
if (mins + on > 59) {
|
||||
const newMins = mins + on - 60
|
||||
|
||||
return `${hours + 1}:${minutesToString(newMins)}`
|
||||
}
|
||||
|
||||
return `${hours}:${minutesToString(mins + on)}`
|
||||
}
|
||||
|
||||
const increeseTimeCodesArray = (timeCodes: string[], on: number, lessThenTimeCode: string): string[] => {
|
||||
const lessMinutes = timeCodeToMinutes(parseTimeCode(lessThenTimeCode))
|
||||
|
||||
return timeCodes.map(code => {
|
||||
const currentCode = timeCodeToMinutes(parseTimeCode(code))
|
||||
const diff = lessMinutes - currentCode
|
||||
|
||||
if (diff < 0) {
|
||||
return code
|
||||
}
|
||||
|
||||
return increeseStopTime(code, on)
|
||||
})
|
||||
}
|
||||
|
||||
const addStopAfter = ({
|
||||
stopName,
|
||||
afterStop,
|
||||
on,
|
||||
lessThenTimeCode,
|
||||
exceptDays,
|
||||
keys,
|
||||
}: {
|
||||
stopName: string
|
||||
afterStop: string
|
||||
on: number
|
||||
lessThenTimeCode: string
|
||||
exceptDays: number[]
|
||||
keys: string[]
|
||||
}) => {
|
||||
let newSchedule = { in: {}, out: {} }
|
||||
|
||||
;[...new Array(7)].forEach((_, index) => {
|
||||
const useDefault = exceptDays.includes(index)
|
||||
|
||||
let newIn = SCHEDULE.in[index]
|
||||
if (keys.includes(`in`)) {
|
||||
const newInStopsKeys = SCHEDULE.in[index][afterStop]
|
||||
newIn = {
|
||||
...SCHEDULE.in[index],
|
||||
[stopName]: useDefault ? newInStopsKeys : increeseTimeCodesArray(newInStopsKeys, on, lessThenTimeCode),
|
||||
}
|
||||
}
|
||||
|
||||
let newOut = SCHEDULE.out[index]
|
||||
if (keys.includes(`out`)) {
|
||||
const newOutStopsKeys = SCHEDULE.out[index][afterStop]
|
||||
newOut = {
|
||||
...SCHEDULE.out[index],
|
||||
[stopName]: newOutStopsKeys,
|
||||
}
|
||||
}
|
||||
|
||||
newSchedule = {
|
||||
in: {
|
||||
...newSchedule.in,
|
||||
[index]: newIn,
|
||||
},
|
||||
out: {
|
||||
...newSchedule.out,
|
||||
[index]: newOut,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return newSchedule
|
||||
}
|
||||
|
||||
export const Dev: FC = () => {
|
||||
const [newSchedule, setNewSchedule] = useState(SCHEDULE)
|
||||
|
||||
const handleAddStop = (): void => {
|
||||
const schedule = addStopAfter({
|
||||
stopName: `Маяк`,
|
||||
afterStop: `Марины Цветаевой (Торта)`,
|
||||
on: 1,
|
||||
lessThenTimeCode: `12:38`,
|
||||
exceptDays: [0, 6],
|
||||
keys: [`in`],
|
||||
})
|
||||
|
||||
setNewSchedule(schedule)
|
||||
}
|
||||
|
||||
const handleJSON = () => {
|
||||
console.log(JSON.stringify(newSchedule))
|
||||
}
|
||||
|
||||
const handleLog = () => {
|
||||
console.log(newSchedule)
|
||||
}
|
||||
|
||||
return (
|
||||
<DevStyled>
|
||||
<button type="button" onClick={handleAddStop}>
|
||||
Добавить остановку
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={handleJSON}>
|
||||
JSON
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={handleLog}>
|
||||
Log
|
||||
</button>
|
||||
</DevStyled>
|
||||
)
|
||||
}
|
||||
331
src/page/Game/Game.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
MIN_ELEMENTS,
|
||||
MAX_MISS,
|
||||
INIT_SCORE,
|
||||
INIT_MISS,
|
||||
INIT_LEVEL,
|
||||
INIT_GAME_OVER,
|
||||
ONE_ROW,
|
||||
ANIMATION_DURATION,
|
||||
} from './const'
|
||||
import Header from './Header/Header'
|
||||
import { calculateTimeLeft, generateGameLevel } from './helpers'
|
||||
import {
|
||||
GameButton,
|
||||
GameLayout,
|
||||
GameLayoutCentred,
|
||||
GameUIContainer,
|
||||
GAME_OVER_BG,
|
||||
HIGH_SCORE_BG,
|
||||
MainGameLayout,
|
||||
Title,
|
||||
} from './common'
|
||||
import ProgressBar from './ProgressBar/ProgressBar'
|
||||
import * as S from './styled'
|
||||
import RecordTable from './RecordTable/RecordTable'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
|
||||
type ID = number
|
||||
export interface IGameData {
|
||||
id: ID
|
||||
text: string
|
||||
selected: boolean
|
||||
destroyed: boolean
|
||||
}
|
||||
|
||||
const generateAmount = (currentLevel: number) => {
|
||||
if (currentLevel >= 14) return ONE_ROW * 6
|
||||
if (currentLevel >= 12) return ONE_ROW * 5
|
||||
if (currentLevel >= 9) return ONE_ROW * 4
|
||||
if (currentLevel >= 5) return ONE_ROW * 3
|
||||
if (currentLevel >= 2) return ONE_ROW * 2
|
||||
|
||||
if (currentLevel >= 1) return ONE_ROW
|
||||
|
||||
return ONE_ROW
|
||||
}
|
||||
|
||||
const devMode = localStorage.getItem('devMode') === '1'
|
||||
const Game = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [levelData, setLevelData] = useState<IGameData[]>(generateGameLevel(MIN_ELEMENTS))
|
||||
const [score, setScore] = useState(INIT_SCORE)
|
||||
const [miss, setMiss] = useState(INIT_MISS)
|
||||
const [level, setLevel] = useState(INIT_LEVEL)
|
||||
const [isGameOver, setGameOver] = useState(INIT_GAME_OVER)
|
||||
|
||||
const [date, setDate] = useState(new Date().getTime())
|
||||
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft(date))
|
||||
const [isNewLevelWin, setNewLeveWin] = useState(true)
|
||||
const [isMiss, setIsMiss] = useState(true)
|
||||
const [isPairWin, setPairWin] = useState(false)
|
||||
const [shoudlShowplusNumber, setShouldShowplusNumber] = useState(false)
|
||||
const [highScore, setHighScore] = useState(
|
||||
localStorage.getItem('score') ? Number(localStorage.getItem('score')) : 0,
|
||||
)
|
||||
const [isHighScore, setIsHighScore] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isHighScore && isGameOver) {
|
||||
localStorage.setItem('score', score.toString())
|
||||
setHighScore(score)
|
||||
}
|
||||
}, [isHighScore, isGameOver, score])
|
||||
|
||||
useEffect(() => {
|
||||
if (score > highScore) {
|
||||
setIsHighScore(true)
|
||||
}
|
||||
|
||||
if (!isGameOver && miss > MAX_MISS) {
|
||||
setGameOver(true)
|
||||
}
|
||||
}, [miss, isGameOver, score, highScore])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGameOver && timeLeft.seconds === 0) {
|
||||
setGameOver(true)
|
||||
}
|
||||
}, [timeLeft, isGameOver])
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft.seconds === 0) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setTimeLeft(calculateTimeLeft(date, level * 0.11))
|
||||
}, 10)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [timeLeft, level, date])
|
||||
|
||||
const handleNewGame = () => {
|
||||
setGameOver(INIT_GAME_OVER)
|
||||
setTimeLeft(calculateTimeLeft(new Date().getTime(), 0.15))
|
||||
|
||||
setLevelData(generateGameLevel(MIN_ELEMENTS))
|
||||
setScore(INIT_SCORE)
|
||||
setLevel(INIT_LEVEL)
|
||||
setMiss(INIT_MISS)
|
||||
setDate(new Date().getTime())
|
||||
setNewLeveWin(true)
|
||||
setIsMiss(true)
|
||||
setShouldShowplusNumber(false)
|
||||
setIsHighScore(false)
|
||||
|
||||
AndrewLytics('game.newGame')
|
||||
}
|
||||
|
||||
const handleClickTimeCode = (_cell: IGameData) => {
|
||||
if (_cell.destroyed || _cell.selected) return
|
||||
|
||||
setLevelData(prevState => {
|
||||
const firstSelected = prevState.find(l => l.selected)
|
||||
|
||||
const secondSelected = firstSelected
|
||||
? prevState.find(pretendet => {
|
||||
if (
|
||||
firstSelected.text === pretendet.text &&
|
||||
firstSelected.id !== pretendet.id &&
|
||||
_cell.id === pretendet.id
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
: undefined
|
||||
|
||||
const isMissed = firstSelected && !secondSelected
|
||||
const isWin = firstSelected && secondSelected
|
||||
|
||||
if (isMissed) {
|
||||
setMiss(prev => prev + 1)
|
||||
setIsMiss(true)
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
setScore(prev => prev + 1)
|
||||
}
|
||||
|
||||
return prevState.map(cell => {
|
||||
if (isMissed) {
|
||||
return {
|
||||
...cell,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
if (cell.id === firstSelected.id || secondSelected.id === cell.id) {
|
||||
return {
|
||||
...cell,
|
||||
selected: false,
|
||||
destroyed: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cell.id === _cell.id ? { ...cell, selected: true } : cell
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let timer: any = null
|
||||
|
||||
if (isNewLevelWin) {
|
||||
timer = setTimeout(() => {
|
||||
setNewLeveWin(false)
|
||||
!shoudlShowplusNumber && setShouldShowplusNumber(true)
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [isNewLevelWin, shoudlShowplusNumber])
|
||||
|
||||
useEffect(() => {
|
||||
let timer: any = null
|
||||
|
||||
if (isMiss) {
|
||||
timer = setTimeout(() => {
|
||||
setIsMiss(false)
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [isMiss])
|
||||
|
||||
useEffect(() => {
|
||||
let timer: any = null
|
||||
|
||||
if (isPairWin) {
|
||||
timer = setTimeout(() => {
|
||||
setPairWin(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [isPairWin])
|
||||
|
||||
useEffect(() => {
|
||||
// new level
|
||||
const isEveryCellDestroyed = levelData.every(cell => cell.destroyed === true)
|
||||
|
||||
if (isEveryCellDestroyed) {
|
||||
setLevel(prev => prev + 1)
|
||||
const amount = generateAmount(level)
|
||||
setLevelData(generateGameLevel(amount))
|
||||
setNewLeveWin(true)
|
||||
|
||||
AndrewLytics('game.newLevel')
|
||||
}
|
||||
}, [levelData, level])
|
||||
|
||||
useEffect(() => {
|
||||
setPairWin(true)
|
||||
}, [score])
|
||||
|
||||
const getPercentage = () => {
|
||||
if (timeLeft.seconds === 0) return 100
|
||||
if (timeLeft.seconds === 1) return 100
|
||||
if (timeLeft.seconds === 2) return 95
|
||||
if (timeLeft.seconds === 3) return 80
|
||||
if (timeLeft.seconds === 4) return 70
|
||||
if (timeLeft.seconds === 5) return 60
|
||||
if (timeLeft.seconds === 6) return 50
|
||||
if (timeLeft.seconds === 7) return 40
|
||||
if (timeLeft.seconds === 8) return 30
|
||||
if (timeLeft.seconds === 9) return 20
|
||||
if (timeLeft.seconds === 10) return 10
|
||||
|
||||
return (timeLeft.seconds * 60) / 100
|
||||
}
|
||||
|
||||
if (isHighScore && isGameOver) {
|
||||
return (
|
||||
<MainGameLayout isWin={false} bg={HIGH_SCORE_BG}>
|
||||
<GameLayoutCentred>
|
||||
<GameUIContainer>
|
||||
<Title>Новый рекорд</Title>
|
||||
</GameUIContainer>
|
||||
|
||||
<RecordTable
|
||||
plusNumber={''}
|
||||
score={highScore}
|
||||
level={level}
|
||||
isGameOver={true}
|
||||
isNewHighScore={true}
|
||||
/>
|
||||
|
||||
<GameButton onClick={handleNewGame}>Играть еще</GameButton>
|
||||
</GameLayoutCentred>
|
||||
</MainGameLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (isGameOver && !devMode)
|
||||
return (
|
||||
<MainGameLayout isWin={false} bg={GAME_OVER_BG}>
|
||||
<GameLayoutCentred>
|
||||
<S.BackToSchedule>
|
||||
<Link to={`/?${searchParams.toString()}`} onClick={() => AndrewLytics('game.backToSchedule')}>
|
||||
Вернуться к расписанию
|
||||
</Link>
|
||||
</S.BackToSchedule>
|
||||
|
||||
<GameUIContainer>
|
||||
<Title>Продолжим?</Title>
|
||||
</GameUIContainer>
|
||||
|
||||
<RecordTable
|
||||
plusNumber={''}
|
||||
score={score}
|
||||
bestScore={highScore}
|
||||
level={level}
|
||||
isGameOver={isGameOver}
|
||||
/>
|
||||
|
||||
<GameButton onClick={handleNewGame}>Играть еще</GameButton>
|
||||
</GameLayoutCentred>
|
||||
</MainGameLayout>
|
||||
)
|
||||
|
||||
return (
|
||||
<MainGameLayout isWin={shoudlShowplusNumber ? isNewLevelWin : false}>
|
||||
<GameLayout>
|
||||
<ProgressBar completed={getPercentage()} bgcolor={'#F48400'} />
|
||||
<Header
|
||||
plusNumber={isPairWin && shoudlShowplusNumber ? '+1' : ''}
|
||||
score={score}
|
||||
miss={miss}
|
||||
level={level}
|
||||
timeLeft={timeLeft}
|
||||
bestScore={highScore}
|
||||
/>
|
||||
|
||||
<S.GameContainer animate={isNewLevelWin}>
|
||||
{levelData.map(cell => (
|
||||
<S.GameCell
|
||||
key={cell.id}
|
||||
onClick={() => handleClickTimeCode(cell)}
|
||||
$selected={cell.selected}
|
||||
$destroyed={cell.destroyed}
|
||||
>
|
||||
{cell.text}
|
||||
</S.GameCell>
|
||||
))}
|
||||
</S.GameContainer>
|
||||
<S.WhiteTextBlock>Выбирай одинаковые блоки пока все не закончатся</S.WhiteTextBlock>
|
||||
</GameLayout>
|
||||
|
||||
{/* <S.GameReaction
|
||||
animate={shoudlShowplusNumber ? isNewLevelWin || isMiss : false}
|
||||
bg={isNewLevelWin ? GAME_WIN_BG : GAME_MISS_BG}
|
||||
/> */}
|
||||
</MainGameLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Game
|
||||
70
src/page/Game/Header/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
import { LeftToString } from 'features/HowMuchLeft'
|
||||
import { GameLink, GameUIContainer, Title } from 'page/Game/common'
|
||||
import RecordTable from 'page/Game/RecordTable/RecordTable'
|
||||
|
||||
import { HeaderContainer } from './styled'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { busStopSelector } from 'shared/store/busStop/busStopInfoSlice'
|
||||
import { leftSelector } from 'shared/store/timeLeft/timeLeftSlice'
|
||||
|
||||
interface IGameHeaderProps {
|
||||
score: number
|
||||
miss: number
|
||||
level: number
|
||||
timeLeft: { seconds: number } | null
|
||||
title?: string | null
|
||||
isGameOver?: boolean
|
||||
plusNumber?: string
|
||||
bestScore: number
|
||||
}
|
||||
|
||||
const Header: React.FC<IGameHeaderProps> = ({
|
||||
score,
|
||||
level,
|
||||
plusNumber = '',
|
||||
title = null,
|
||||
isGameOver = false,
|
||||
bestScore,
|
||||
}) => {
|
||||
const left = useSelector(leftSelector)
|
||||
const busStop = useSelector(busStopSelector)
|
||||
|
||||
return (
|
||||
<HeaderContainer fullHeight={isGameOver}>
|
||||
{busStop ? (
|
||||
<GameUIContainer>
|
||||
<GameLink to="/" lowLight={!isGameOver}>
|
||||
<LeftToString busStop={'Швейная фабрика'} left={left} />
|
||||
</GameLink>
|
||||
</GameUIContainer>
|
||||
) : null}
|
||||
|
||||
<GameUIContainer>
|
||||
{title && isGameOver ? (
|
||||
<GameUIContainer>
|
||||
<Title>{title}</Title>
|
||||
</GameUIContainer>
|
||||
) : null}
|
||||
|
||||
<GameUIContainer>
|
||||
<RecordTable
|
||||
plusNumber={plusNumber}
|
||||
score={score}
|
||||
bestScore={bestScore}
|
||||
level={level}
|
||||
isGameOver={isGameOver}
|
||||
/>
|
||||
</GameUIContainer>
|
||||
</GameUIContainer>
|
||||
{title && !isGameOver ? (
|
||||
<GameUIContainer>
|
||||
<Title>{title}</Title>
|
||||
</GameUIContainer>
|
||||
) : null}
|
||||
</HeaderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
9
src/page/Game/Header/styled.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const HeaderContainer = styled.div<{ fullHeight: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
/* height: ${props => (props.fullHeight ? '100%' : 'unset')}; */
|
||||
`
|
||||
35
src/page/Game/Intro/Intro.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { GameButton, GameLayoutCentred, GameUIContainer, GAME_OVER_BG, MainGameLayout, Title } from 'page/Game/common'
|
||||
import { AndrewLytics } from 'shared/lib'
|
||||
import { BackToSchedule } from '../styled'
|
||||
|
||||
function Intro() {
|
||||
// const hs = localStorage.getItem('score') ? localStorage.getItem('score') : "???";
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
return (
|
||||
<MainGameLayout isWin={false} bg={GAME_OVER_BG}>
|
||||
<GameLayoutCentred>
|
||||
<BackToSchedule>
|
||||
<Link to={`/?${searchParams.toString()}`} onClick={() => AndrewLytics('game.intro.backToSchedule')}>
|
||||
Вернуться к расписанию
|
||||
</Link>
|
||||
</BackToSchedule>
|
||||
<GameUIContainer>
|
||||
<Title>Помоги найти дубли в расписании</Title>
|
||||
</GameUIContainer>
|
||||
|
||||
<GameUIContainer>
|
||||
<Link
|
||||
to={`/game/doubles?${searchParams.toString()}`}
|
||||
onClick={() => AndrewLytics('game.introPlay')}
|
||||
>
|
||||
<GameButton>Играть!</GameButton>
|
||||
</Link>
|
||||
</GameUIContainer>
|
||||
</GameLayoutCentred>
|
||||
</MainGameLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Intro
|
||||
11
src/page/Game/ProgressBar/ProgressBar.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as P from './styled'
|
||||
|
||||
const ProgressBar: React.FC<{ bgcolor: string; completed: number }> = ({ bgcolor, completed }) => {
|
||||
return (
|
||||
<P.Cotainer>
|
||||
<P.Filler bgcolor={bgcolor} style={{ width: `${completed}%` }}></P.Filler>
|
||||
</P.Cotainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressBar
|
||||
29
src/page/Game/ProgressBar/styled.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const Cotainer = styled.div`
|
||||
height: 5px;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.19);
|
||||
border-radius: 50;
|
||||
margin: 50;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`
|
||||
|
||||
export const Filler = styled.div<{ bgcolor: string }>`
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background-color: ${props => props.bgcolor};
|
||||
border-radius: inherit;
|
||||
text-align: right;
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`
|
||||
|
||||
export const Label = styled.span`
|
||||
padding: 5;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
`
|
||||
70
src/page/Game/RecordTable/RecordTable.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
|
||||
import Score from 'page/Game/Score/Score'
|
||||
import GameRecord from './img/game-record.svg'
|
||||
import { InfoWrapper, RecordItem, RecordTableContainer, RecordTitle, RecordValue, RecordValueContainer } from './styled'
|
||||
|
||||
const RecordTableGameOver: React.FC<{
|
||||
score: number
|
||||
bestScore?: number
|
||||
level: number
|
||||
plusNumber: string
|
||||
isNewHighScore?: boolean
|
||||
}> = ({ score, plusNumber, level, isNewHighScore, bestScore }) => {
|
||||
return (
|
||||
<RecordTableContainer isColumn={true}>
|
||||
<Score score={score} plusNumber={plusNumber} isBig isNewHighScore={isNewHighScore} />
|
||||
|
||||
<InfoWrapper>
|
||||
<RecordItem style={{ marginRight: '8px' }}>
|
||||
<RecordTitle>уровень</RecordTitle>
|
||||
<RecordValue>{level}</RecordValue>
|
||||
</RecordItem>
|
||||
|
||||
<RecordScore score={bestScore} />
|
||||
</InfoWrapper>
|
||||
</RecordTableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const RecordScore: React.FC<{ score?: number }> = ({ score }) => {
|
||||
if (score === undefined || score === null) return <></>
|
||||
|
||||
return (
|
||||
<RecordItem>
|
||||
<RecordTitle>рекорд</RecordTitle>
|
||||
<RecordValueContainer>
|
||||
<RecordValue style={{ marginRight: '2px' }}>{score}</RecordValue>
|
||||
<SVG src={GameRecord} width={19} height={19} uniquifyIDs={true} />
|
||||
</RecordValueContainer>
|
||||
</RecordItem>
|
||||
)
|
||||
}
|
||||
const RecordTable: React.FC<{
|
||||
score: number
|
||||
bestScore?: number
|
||||
level: number
|
||||
plusNumber: string
|
||||
isGameOver?: boolean
|
||||
isNewHighScore?: boolean
|
||||
}> = props => {
|
||||
const { score, plusNumber, level, isGameOver, bestScore, isNewHighScore } = props
|
||||
|
||||
if (isGameOver) return <RecordTableGameOver {...props} />
|
||||
|
||||
return (
|
||||
<RecordTableContainer isColumn={!!isGameOver}>
|
||||
<RecordScore score={bestScore} />
|
||||
|
||||
<Score score={score} plusNumber={plusNumber} isBig={isGameOver} isNewHighScore={!!isNewHighScore} />
|
||||
|
||||
<RecordItem style={{ marginLeft: '2px' }}>
|
||||
<RecordTitle>уровень</RecordTitle>
|
||||
<RecordValue>{level}</RecordValue>
|
||||
</RecordItem>
|
||||
</RecordTableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecordTable
|
||||