Initial commit

main
Zakhar Timoshenko 2 years ago
commit 2dfd0c1dcc
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -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

@ -0,0 +1,2 @@
node_modules
build

@ -0,0 +1,9 @@
{
"printWidth": 120,
"useTabs": true,
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "avoid"
}

@ -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"
}

File diff suppressed because it is too large Load Diff

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

@ -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"
}

@ -0,0 +1,2 @@
User-agent: *
Allow: /

@ -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>

@ -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 />
</>
)

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

@ -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} didnt 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: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -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>

@ -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>,
)

@ -0,0 +1,5 @@
import { HolidayContainerStyled } from './styled'
export const Holiday: React.FC = () => {
return <HolidayContainerStyled>праздничное расписание</HolidayContainerStyled>
}

@ -0,0 +1 @@
export * from './Holiday'

@ -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%;
}
`

@ -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

@ -0,0 +1,2 @@
export * from './ui/Logo'
export * from './ui/styled'

@ -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>
)
}

@ -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;
`

@ -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>
}

@ -0,0 +1 @@
export * from './SelectBusStopText'

@ -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>
)
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

@ -0,0 +1 @@
export * from './WriteMe'

@ -0,0 +1,7 @@
import styled from 'styled-components'
export const HandWrapperStyled = styled.div`
position: absolute;
right: -10px;
bottom: 0;
`

@ -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>
)
}

@ -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)
}
}

@ -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

@ -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}`
}

@ -0,0 +1,2 @@
export * from './ui/Complains'
export * from './model/Complains'

@ -0,0 +1,6 @@
export enum ComplainType {
'earlier' = `earlier`,
'later' = `later`,
not_arrive = `not_arrive`,
passed_by = `passed_by`,
}

@ -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)
}

@ -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 }
}

@ -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,
}
}

@ -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>
)
}

@ -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>
)
}

@ -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>
)
}

@ -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;
`

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

@ -0,0 +1 @@
export * from './ui/DirectionChanger'

@ -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>
)
}

@ -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;
`

@ -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)
},
)
}

@ -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

@ -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

@ -0,0 +1 @@
export * from './ui/FAQ'

@ -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>
</>
)
}

@ -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[]) : []
}

@ -0,0 +1,3 @@
export * from './ui/FavoriteButton'
export * from './ui/FavoriteStops'
export * from './model/favoriteStopsSlice'

@ -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

@ -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>
)
}

@ -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>
)
}

@ -0,0 +1 @@
export * from './ui/HeaderScore'

@ -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>
)
}

@ -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);
}
`

@ -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;
`

@ -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 />}
</>
)
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

@ -0,0 +1 @@
export * from './HowMuchLeft'

@ -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

@ -0,0 +1 @@
export * from './ui/Info'

@ -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

@ -0,0 +1,5 @@
export interface IInfo {
message: string | null
id: number | null
link: string | null
}

@ -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>
)
}

@ -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;
}
`

@ -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>
)
}

@ -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

@ -0,0 +1 @@
export * from './LeaveFeedbackButton'

@ -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;
`

@ -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>
)
}

@ -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()
}
}

@ -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

@ -0,0 +1 @@
export * from './MyLocation'

@ -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

@ -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;
`

@ -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>
)
}

@ -0,0 +1 @@
export * from './NearestStops'

@ -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>
)
}

@ -0,0 +1 @@
export * from './OtherTimeBuses'

5
src/global.d.ts vendored

@ -0,0 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module '*.svg' {
const content: string
export default content
}

@ -0,0 +1,3 @@
import styled from 'styled-components';
export const DevStyled = styled.div``;

@ -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>
)
}

@ -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

@ -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

@ -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')}; */
`

@ -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

@ -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

@ -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;
`

@ -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

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save