Merge pull request #2 from dm1sh/userPage

User page
This commit is contained in:
Dmitriy Shishkov 2023-08-08 09:22:58 +00:00 committed by GitHub
commit 5bdad31dae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
94 changed files with 2564 additions and 644 deletions

View File

@ -32,5 +32,16 @@ module.exports = {
}
],
'jsx-quotes': [2, 'prefer-single'],
'comma-dangle': 'off',
'@typescript-eslint/comma-dangle': ['warn', {
'arrays': 'always-multiline',
'objects': 'always-multiline',
'imports': 'always-multiline',
'exports': 'always-multiline',
'functions': 'only-multiline',
'enums': 'always-multiline',
'generics': 'always-multiline',
'tuples': 'always-multiline',
}],
},
}

View File

@ -8,8 +8,8 @@
"name": "front",
"version": "0.0.0",
"dependencies": {
"@types/leaflet": "^1.9.3",
"bootstrap": "^5.3.0",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-bootstrap": "^2.8.0",
@ -20,6 +20,9 @@
"react-router-dom": "^6.14.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/leaflet": "^1.9.3",
"@types/lodash": "^4.14.196",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.61.0",
@ -28,6 +31,7 @@
"eslint": "^8.44.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"lodash": "^4.17.21",
"typescript": "^5.0.2",
"vite": "^4.4.0"
}
@ -817,6 +821,22 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@faker-js/faker": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.0.2.tgz",
"integrity": "sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0",
"npm": ">=6.14.13"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
@ -1041,7 +1061,8 @@
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.12",
@ -1053,10 +1074,17 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.14.196",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.196.tgz",
"integrity": "sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==",
"dev": true
},
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@ -2361,6 +2389,11 @@
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@ -2394,6 +2427,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",

View File

@ -12,8 +12,8 @@
"addFetchApiRoute": "bash utils/addFetchApiRoute.sh"
},
"dependencies": {
"@types/leaflet": "^1.9.3",
"bootstrap": "^5.3.0",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-bootstrap": "^2.8.0",
@ -24,6 +24,9 @@
"react-router-dom": "^6.14.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/leaflet": "^1.9.3",
"@types/lodash": "^4.14.196",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.61.0",
@ -32,6 +35,7 @@
"eslint": "^8.44.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"lodash": "^4.17.21",
"typescript": "^5.0.2",
"vite": "^4.4.0"
}

315
front/prototype.html Normal file
View File

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<nav>
<a href="#back" class="back">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
</svg>
</a>
<h1 class="heading">Иванов Иван, с нами с 17.07.2023</h1>
</nav>
<div id="root"></div>
<div class="poemContainer">
<h1>Поэзия</h1>
<div class="poemText">
<div class="eleven" style="position:relative;left:-60px">"Fury said to</div>
<div class="ten" style="position:relative;left:-40px">a mouse, That</div>
<div class="ten" style="position:relative;left:0px">he met</div>
<div class="ten" style="position:relative;left:10px">in the</div>
<div class="ten" style="position:relative;left:20px">house,</div>
<div class="ten" style="position:relative;left:17px">'Let us</div>
<div class="ten" style="position:relative;left:5px">both go</div>
<div class="ten" style="position:relative;left:-7px">to law:</div>
<div class="ten" style="position:relative;left:-23px"><i>I</i> will</div>
<div class="ten" style="position:relative;left:-26px">prosecute</div>
<div class="nine" style="position:relative;left:-40px"><i>you.</i></div>
<div class="nine" style="position:relative;left:-30px">Come, I'll</div>
<div class="nine" style="position:relative;left:-20px">take no</div>
<div class="nine" style="position:relative;left:-7px">denial;</div>
<div class="nine" style="position:relative;left:19px">We must</div>
<div class="nine" style="position:relative;left:45px">have a</div>
<div class="nine" style="position:relative;left:67px">trial:</div>
<div class="nine" style="position:relative;left:80px">For</div>
<div class="eight" style="position:relative;left:70px">really</div>
<div class="eight" style="position:relative;left:57px">this</div>
<div class="eight" style="position:relative;left:75px">morning</div>
<div class="eight" style="position:relative;left:95px">I've</div>
<div class="eight" style="position:relative;left:77px">nothing</div>
<div class="eight" style="position:relative;left:57px">to do.'</div>
<div class="seven" style="position:relative;left:38px">Said the</div>
<div class="seven" style="position:relative;left:30px">mouse to</div>
<div class="seven" style="position:relative;left:18px">the cur,</div>
<div class="seven" style="position:relative;left:22px">'Such a</div>
<div class="seven" style="position:relative;left:37px">trial,</div>
<div class="seven" style="position:relative;left:27px">dear sir,</div>
<div class="seven" style="position:relative;left:9px">With no</div>
<div class="seven" style="position:relative;left:-8px">jury or</div>
<div class="seven" style="position:relative;left:-18px">judge,</div>
<div class="seven" style="position:relative;left:-6px">would be</div>
<div class="seven" style="position:relative;left:7px">wasting</div>
<div class="seven" style="position:relative;left:25px">our breath.'</div>
<div class="six" style="position:relative;left:30px">'I'll be</div>
<div class="six" style="position:relative;left:24px">judge,</div>
<div class="six" style="position:relative;left:15px">I'll be</div>
<div class="six" style="position:relative;left:2px">jury,'</div>
<div class="six" style="position:relative;left:-4px">Said</div>
<div class="six" style="position:relative;left:17px">cunning</div>
<div class="six" style="position:relative;left:29px">old Fury;</div>
<div class="six" style="position:relative;left:37px">'I'll try</div>
<div class="six" style="position:relative;left:51px">the whole</div>
<div class="six" style="position:relative;left:70px">cause,</div>
<div class="six" style="position:relative;left:65px">and</div>
<div class="six" style="position:relative;left:60px">condemn</div>
<div class="six" style="position:relative;left:60px">you</div>
<div class="six" style="position:relative;left:68px">to</div>
<div class="six" style="position:relative;left:82px">death.' "</div>
</div>
<img src="uploads/mouse.jpg" class="poemImg">
</div>
<script>
const categoryGraphics = {
'PORRIDGE': 'dist/PORRIDGE.jpg',
'conspects': 'dist/conspects.jpg',
'milk': 'dist/milk.jpg',
'bred': 'dist/bred.jpg',
'wathing': 'dist/wathing.jpg',
'cloth': 'dist/cloth.jpg',
'fruits_vegatables': 'dist/fruits_vegatables.jpg',
'soup': 'dist/soup.jpg',
'dinner': 'dist/dinner.jpg',
'conserves': 'dist/conserves.jpg',
'pens': 'dist/pens.jpg',
'other_things': 'dist/other_things.jpg',
}
const categoryNames = {
'PORRIDGE': 'PORRIDGE',
'conspects': 'Конспекты',
'milk': 'Молочные продукты',
'bred': 'Хлебобулочные изделия',
'wathing': 'Моющие средства',
'cloth': 'Одежда',
'fruits_vegatables': 'Фрукты и овощи',
'soup': 'Супы',
'dinner': 'Ужин',
'conserves': 'Консервы',
'pens': 'Канцелярия',
'other_things': 'Всякая всячина',
}
const cats = ["Раздача", "Бронь", "История"]
const props = ["Годен до 01.09.2023", "Бронь ещё 5 чел.", "Забрал 16.07.2023"]
const stories = [2, 4, 1].map(
(n) => (new Array(n)).fill(1).map(
() => (
{
title: (Math.random() * Math.pow(10, Math.random() * 100)).toString(36),
category: Object.keys(categoryGraphics)[Math.round(Math.random() * (Object.keys(categoryGraphics).length - 1))]
}
)
)
)
console.log(stories)
const render = () => {
const root = document.getElementById('root')
root.innerHTML = ''
stories.forEach((c, i) => {
const section = document.createElement('section')
section.className = 'section'
section.innerHTML = `<h1>${cats[i]}</h1>`
const ul = document.createElement('ul')
c.forEach((v, j) => {
const story = document.createElement('li')
story.className = 'story'
story.innerHTML = `
<a href="#${i},${j}">
<img class="storyPic" src="${categoryGraphics[v.category]}" />
<p class="storyTitle">${v.title}</p>
<p class="storyTitle">${props[i]}</p>
`.trim()
ul.appendChild(story)
})
console.log(window.innerWidth, (window.innerHeight * 0.25 * 9 / 16), (window.innerWidth * 0.25 * 9 / 16) * c.length)
if ((window.innerWidth - 60) < (window.innerHeight * 0.25 * 9 / 16 + 10) * c.length) {
const seeMore = document.createElement('a')
seeMore.href = "#more"
seeMore.innerHTML = `<svg fill="currentColor" width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><!--! Font Awesome Free 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M64 448c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L178.8 256L41.38 118.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l160 160c12.5 12.5 12.5 32.75 0 45.25l-160 160C80.38 444.9 72.19 448 64 448z"/></svg>`
seeMore.className = 'seeMore'
ul.appendChild(seeMore)
ul.classList.add('grad')
}
section.appendChild(ul)
root.appendChild(section)
})
}
// window.addEventListener('resize', render)
document.addEventListener('DOMContentLoaded', render)
</script>
<style>
* {
padding: 0;
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
a {
color: rgb(185, 179, 170);
text-decoration: none;
}
body {
background-color: #111;
color: rgb(185, 179, 170);
width: 100%;
max-width: calc(100vh*9/16);
margin: auto;
}
.section {
padding: 30px;
padding-bottom: 0;
}
ul {
display: flex;
list-style-type: none;
padding-top: 20px;
width: 100%;
overflow: hidden;
position: relative;
}
.grad::after {
content: '';
background: linear-gradient(to right, rgba(17, 17, 17, 0) 0%, rgba(17, 17, 17, 255) 100%);
display: block;
height: 100%;
width: 10%;
position: absolute;
right: 0;
}
li {
padding-right: 10px;
width: calc(25vh*9/16);
}
.storyPic {
max-height: 25vh;
border-radius: 12px;
}
.storyTitle {
padding-top: 5px;
text-overflow: ellipsis;
overflow: hidden;
/* max-width: 100%; */
}
.seeMore {
position: absolute;
left: calc(100% - 5% / 3 - 30px + 8px);
top: calc(50% - 15px);
z-index: 100;
width: 24px;
height: 24px;
padding: 3px;
color: rgb(185, 179, 170);
/* background-color: #111; */
border-radius: 100%;
}
nav {
padding: 30px;
padding-bottom: 0;
display: flex;
}
.back {
color: rgb(185, 179, 170);
display: flex;
align-items: center;
}
.back svg {
display: block;
height: 24px;
width: 24px;
padding: 3px;
}
.heading {
padding-left: 7px;
}
.poemContainer {
padding: 30px;
}
.poemText {
padding: 0 60px;
padding-top: 10px;
}
.eleven {
font-size: 105%;
margin: 0px;
}
.ten {
font-size: 100%;
margin: 0px;
}
.nine {
font-size: 90%;
margin: 0px;
}
.eight {
font-size: 80%;
margin: 0px;
}
.seven {
font-size: 70%;
margin: 0px;
}
.six {
font-size: 60%;
margin: 0px;
}
.poemImg {
max-width: 100%;
padding-top: 10px;
}
</style>
</body>
</html>

View File

@ -1,16 +1,9 @@
body {
height: 100vh;
overflow: hidden;
color: white;
font-family: sans-serif;
}
.modal-content, .modal-content .form-select {
background-color: rgb(17, 17, 17) !important;
:root {
--bs-body-bg: rgb(17, 17, 17) !important;
}
/* В связи со сложившейся политической обстановкой */
.leaflet-attribution-flag {
position: absolute;
right: -100px;
}
}

View File

@ -0,0 +1,12 @@
import { Announcement, AnnouncementResponse } from './types'
const processAnnouncement = (data: AnnouncementResponse): Announcement => ({
...data,
lat: data.latitude,
lng: data.longtitude,
bestBy: data.best_by,
bookedBy: data.booked_by,
userId: data.user_id,
})
export { processAnnouncement }

View File

@ -6,7 +6,7 @@ type AnnouncementResponse = {
user_id: number,
name: string,
category: Category,
best_by: number,
best_by: string,
address: string,
longtitude: number,
latitude: number,
@ -14,7 +14,7 @@ type AnnouncementResponse = {
src: string | null,
metro: string,
trashId: number | null,
booked_by: number
booked_by: number,
}
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
@ -23,7 +23,7 @@ const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
'user_id': 'number',
'name': 'string',
'category': isCategory,
'best_by': 'number',
'best_by': 'string',
'address': 'string',
'longtitude': 'number',
'latitude': 'number',
@ -31,7 +31,7 @@ const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => (
'src': 'string?',
'metro': 'string',
'trashId': 'number?',
'booked_by': 'number'
'booked_by': 'number',
})
)
@ -48,7 +48,7 @@ type Announcement = {
src: string | null,
metro: string,
trashId: number | null,
bookedBy: number
bookedBy: number,
}
export type {

View File

@ -1,25 +1,17 @@
import { API_URL } from '../../config'
import { FiltersType, URLEncodeFilters } from '../../utils/filters'
import { FiltersType, URLEncodeFilters, convertFilterNames } from '../../utils/filters'
import { processAnnouncement } from '../announcement'
import { Announcement } from '../announcement/types'
import { AnnouncementsResponse } from './types'
const initialAnnouncements: Announcement[] = []
const composeAnnouncementsURL = (filters: FiltersType) => (
API_URL + '/announcements?' + new URLSearchParams(URLEncodeFilters(filters)).toString()
API_URL + '/announcements?' + new URLSearchParams(convertFilterNames(URLEncodeFilters(filters))).toString()
)
const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => {
const annList = data.list_of_announcements
return annList.map(ann => ({
...ann,
lat: ann.latitude,
lng: ann.longtitude,
bestBy: ann.best_by,
bookedBy: ann.booked_by,
userId: ann.user_id
}))
}
const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => (
data.map(processAnnouncement)
)
export { initialAnnouncements, composeAnnouncementsURL, processAnnouncements }

View File

@ -1,16 +1,10 @@
import { isArrayOf, isObject } from '../../utils/types'
import { isArrayOf } from '../../utils/types'
import { AnnouncementResponse, isAnnouncementResponse } from '../announcement/types'
type AnnouncementsResponse = {
list_of_announcements: AnnouncementResponse[],
Success: boolean
}
type AnnouncementsResponse = AnnouncementResponse[]
const isAnnouncementsResponse = (obj: unknown): obj is AnnouncementsResponse => (
isObject(obj, {
'list_of_announcements': obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse),
'Success': 'boolean'
})
isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse)
)
export type {

View File

@ -0,0 +1,12 @@
import { API_URL } from '../../config'
import { Book, BookResponse } from './types'
const composeBookURL = () => (
API_URL + '/book?'
)
const processBook = (data: BookResponse): Book => {
return data.Success
}
export { composeBookURL, processBook }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type BookResponse = {
Success: boolean,
}
const isBookResponse = (obj: unknown): obj is BookResponse => (
isObject(obj, {
'Success': 'boolean',
})
)
type Book = boolean
export type { BookResponse, Book }
export { isBookResponse }

View File

@ -0,0 +1,19 @@
import { API_URL } from '../../config'
import { TrashboxDispose, DisposeResponse } from './types'
const composeDisposeURL = () => (
API_URL + '/announcement/dispose?'
)
const composeDisposeBody = (ann_id: number, trashbox: TrashboxDispose) => (
JSON.stringify({
ann_id,
trashbox,
})
)
const processDispose = (data: DisposeResponse): boolean => {
return data.Success
}
export { composeDisposeURL, composeDisposeBody, processDispose }

View File

@ -0,0 +1,23 @@
import { composeDisposeBody } from '.'
import { isObject } from '../../utils/types'
import { Trashbox } from '../trashbox/types'
type TrashboxDispose = Omit<Trashbox, 'Categories' | 'Address'> & { Category: string }
type DisposeParams = Parameters<typeof composeDisposeBody>
type DisposeAnnParams = DisposeParams extends [ann_id: number, ...args: infer P] ? P : never
type DisposeResponse = {
Success: boolean,
}
const isDisposeResponse = (obj: unknown): obj is DisposeResponse => (
isObject(obj, {
'Success': 'boolean',
})
)
export type { TrashboxDispose, DisposeParams, DisposeAnnParams, DisposeResponse }
export { isDisposeResponse }

View File

@ -1,4 +1,5 @@
import { LatLng } from 'leaflet'
import { OsmAddressResponse } from './types'
const initialOsmAddress = ''

View File

@ -1,7 +1,7 @@
import { isObject } from '../../utils/types'
type OsmAddressResponse = {
display_name: string
display_name: string,
}
const isOsmAddressResponse = (obj: unknown): obj is OsmAddressResponse => (

View File

@ -0,0 +1,18 @@
import { API_URL } from '../../config'
import { PoetryResponse, Poetry } from './types'
const initialPoetry: Poetry = {
title: '',
text: '',
author: '',
}
const composePoetryURL = () => (
API_URL + '/poetry?'
)
const processPoetry = (data: PoetryResponse): Poetry => {
return data
}
export { initialPoetry, composePoetryURL, processPoetry }

View File

@ -0,0 +1,23 @@
import { isObject } from '../../utils/types'
type PoetryResponse = {
title: string,
text: string,
author: string,
}
const isPoetryResponse = (obj: unknown): obj is PoetryResponse => (
isObject(obj, {
'title': 'string',
'text': 'string',
'author': 'string',
})
)
type Poetry = PoetryResponse
const isPoetry = isPoetryResponse
export type { PoetryResponse, Poetry }
export { isPoetryResponse, isPoetry }

View File

@ -1,12 +1,12 @@
import { isObject } from '../../utils/types'
type PutAnnouncementResponse = {
Answer: boolean
Answer: boolean,
}
const isPutAnnouncementResponse = (obj: unknown): obj is PutAnnouncementResponse => (
isObject(obj, {
'Answer': 'boolean'
'Answer': 'boolean',
})
)

View File

@ -0,0 +1,16 @@
import { API_URL } from '../../config'
import { RemoveAnnouncement, RemoveAnnouncementResponse } from './types'
const composeRemoveAnnouncementURL = () => (
API_URL + '/announcement?'
)
function processRemoveAnnouncement(data: RemoveAnnouncementResponse): RemoveAnnouncement {
if (!data.Answer) {
throw new Error('Не удалось закрыть объявление')
}
return data.Answer
}
export { composeRemoveAnnouncementURL, processRemoveAnnouncement }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type RemoveAnnouncementResponse = {
Answer: boolean,
}
const isRemoveAnnouncementResponse = (obj: unknown): obj is RemoveAnnouncementResponse => (
isObject(obj, {
'Answer': 'boolean',
})
)
type RemoveAnnouncement = boolean
export type { RemoveAnnouncementResponse, RemoveAnnouncement }
export { isRemoveAnnouncementResponse }

View File

@ -0,0 +1,12 @@
import { API_URL } from '../../config'
import { SendRateResponse, SendRate } from './types'
const composeSendRateURL = () => (
API_URL + '/user/rating?'
)
const processSendRate = (data: SendRateResponse): SendRate => {
return data.Success
}
export { composeSendRateURL, processSendRate }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type SendRateResponse = {
Success: boolean
}
const isSendRateResponse = (obj: unknown): obj is SendRateResponse => (
isObject(obj, {
'Success': 'boolean',
})
)
type SendRate = boolean
export type { SendRateResponse, SendRate }
export { isSendRateResponse }

View File

@ -0,0 +1,22 @@
import { API_URL } from '../../config'
import { SignUp, SignUpResponse } from './types'
const composeSignUpURL = () => (
API_URL + '/signup?'
)
const composeSignUpBody = (formData: FormData) => {
formData.append('nickname', formData.get('username') ?? '')
return formData
}
const processSignUp = (data: SignUpResponse): SignUp => {
if (!data.Success) {
throw new Error(data.Message)
}
return true
}
export { composeSignUpURL, composeSignUpBody, processSignUp }

View File

@ -0,0 +1,23 @@
import { isConst, isObject } from '../../utils/types'
type SignUpResponse = {
Success: true,
} | {
Success: false,
Message: string,
}
const isSignUpResponse = (obj: unknown): obj is SignUpResponse => (
isObject(obj, {
'Success': isConst(true),
}) || isObject(obj, {
'Success': isConst(false),
'Message': 'string',
})
)
type SignUp = boolean
export type { SignUpResponse, SignUp }
export { isSignUpResponse }

View File

@ -0,0 +1,12 @@
import { API_URL } from '../../config'
import { Token, TokenResponse } from './types'
const composeTokenURL = () => (
API_URL + '/token?'
)
const processToken = (data: TokenResponse): Token => {
return data.access_token
}
export { composeTokenURL, processToken }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type TokenResponse = {
access_token: string,
}
const isTokenResponse = (obj: unknown): obj is TokenResponse => (
isObject(obj, {
'access_token': 'string',
})
)
type Token = string
export type { TokenResponse, Token }
export { isTokenResponse }

View File

@ -6,11 +6,12 @@ import { Trashbox, TrashboxResponse } from './types'
const composeTrashboxURL = (position: LatLng) => (
API_URL + '/trashbox?' + new URLSearchParams({
lat: position.lat.toString(),
lng: position.lng.toString()
lng: position.lng.toString(),
}).toString()
)
const processTrashbox = (data: TrashboxResponse): Trashbox[] =>
const processTrashbox = (data: TrashboxResponse): Trashbox[] => (
data
)
export { composeTrashboxURL, processTrashbox }

View File

@ -1,18 +1,20 @@
import { isArrayOf, isObject, isString } from '../../utils/types'
type Trashbox = {
Name: string,
Lat: number,
Lng: number,
Address: string,
Categories: string[]
Categories: string[],
}
const isTrashbox = (obj: unknown): obj is Trashbox => (
isObject(obj, {
'Name': 'string',
'Lat': 'number',
'Lng': 'number',
'Address': 'string',
'Categories': obj => isArrayOf<string>(obj, isString)
'Categories': obj => isArrayOf<string>(obj, isString),
})
)

View File

@ -0,0 +1,27 @@
import { API_URL } from '../../config'
import { UserResponse, User } from './types'
import { faker } from '@faker-js/faker/locale/ru'
const initialUser: User = import.meta.env.DEV ? { // Temporary, until api is realized
id: Math.random() * 100,
name: faker.person.firstName() + ' ' + faker.person.lastName(),
regDate: faker.date.anytime().getTime(),
points: Math.round(Math.random() * 1000),
} : {
id: 1,
name: 'Вася пупкин',
regDate: 0,
points: 100,
}
const composeUserURL = () => (
API_URL + '/users/me?'
)
const processUser = (data: UserResponse): User => {
return data
}
export { initialUser, composeUserURL, processUser }

View File

@ -0,0 +1,31 @@
import { isObject } from '../../utils/types'
type User = {
id: number,
name: string,
regDate: number,
points: number,
}
const isUser = (obj: unknown): obj is User => (
isObject(obj, {
'id': 'number',
'name': 'string',
'regDate': 'number',
'points': 'number',
})
)
type UserResponse = User
// const isUserResponse = (obj: unknown): obj is UserResponse => (
// isObject(obj, {
// })
// )
const isUserResponse = isUser
export type { UserResponse, User }
export { isUserResponse, isUser }

View File

@ -0,0 +1,14 @@
import { API_URL } from '../../config'
import { UserRatingResponse, UserRating } from './types'
const initialUserRating: UserRating = 0
const composeUserRatingURL = (userId: number) => (
API_URL + '/user/rating?' + (new URLSearchParams({ user_id: userId.toString() })).toString()
)
const processUserRating = (data: UserRatingResponse): UserRating => {
return data.rating
}
export { initialUserRating, composeUserRatingURL, processUserRating }

View File

@ -0,0 +1,17 @@
import { isObject } from '../../utils/types'
type UserRatingResponse = {
rating: number
}
const isUserRatingResponse = (obj: unknown): obj is UserRatingResponse => (
isObject(obj, {
'rating': 'number',
})
)
type UserRating = number
export type { UserRatingResponse, UserRating }
export { isUserRatingResponse }

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M20 2H4c-1.103 0-2 .897-2 2v18l4-4h14c1.103 0 2-.897 2-2V4c0-1.103-.897-2-2-2zm-3 9h-4v4h-2v-4H7V9h4V5h2v4h4v2z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M20 2H4c-1.103 0-2 .897-2 2v18l4-4h14c1.103 0 2-.897 2-2V4c0-1.103-.897-2-2-2zm-3 9h-4v4h-2v-4H7V9h4V5h2v4h4v2z" />
</svg>

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 317 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="rgb(185, 179, 170)" width="24" height="24" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M13 20v-4.586L20.414 8c.375-.375.586-.884.586-1.415V4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v2.585c0 .531.211 1.04.586 1.415L11 15.414V22l2-2z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M13 20v-4.586L20.414 8c.375-.375.586-.884.586-1.415V4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v2.585c0 .531.211 1.04.586 1.415L11 15.414V22l2-2z" />
</svg>

Before

Width:  |  Height:  |  Size: 337 B

After

Width:  |  Height:  |  Size: 338 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M20.5 5A1.5 1.5 0 0 0 19 6.5V11h-1V4.5a1.5 1.5 0 0 0-3 0V11h-1V3.5a1.5 1.5 0 0 0-3 0V11h-1V5.5a1.5 1.5 0 0 0-3 0v10.81l-2.22-3.6a1.5 1.5 0 0 0-2.56 1.58l3.31 5.34A5 5 0 0 0 9.78 22H17a5 5 0 0 0 5-5V6.5A1.5 1.5 0 0 0 20.5 5z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M20.5 5A1.5 1.5 0 0 0 19 6.5V11h-1V4.5a1.5 1.5 0 0 0-3 0V11h-1V3.5a1.5 1.5 0 0 0-3 0V11h-1V5.5a1.5 1.5 0 0 0-3 0v10.81l-2.22-3.6a1.5 1.5 0 0 0-2.56 1.58l3.31 5.34A5 5 0 0 0 9.78 22H17a5 5 0 0 0 5-5V6.5A1.5 1.5 0 0 0 20.5 5z" />
</svg>

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 429 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="rgb(185, 179, 170)" xmlns="http://www.w3.org/2000/svg">
<path d="M6.25993 21.3884H6C5.05719 21.3884 4.58579 21.3884 4.29289 21.0955C4 20.8026 4 20.3312 4 19.3884V18.2764C4 17.7579 4 17.4987 4.13318 17.2672C4.26636 17.0356 4.46727 16.9188 4.8691 16.6851C7.51457 15.1464 11.2715 14.2803 13.7791 15.7759C13.9475 15.8764 14.0991 15.9977 14.2285 16.1431C14.7866 16.77 14.746 17.7161 14.1028 18.2775C13.9669 18.396 13.8222 18.486 13.6764 18.5172C13.7962 18.5033 13.911 18.4874 14.0206 18.4699C14.932 18.3245 15.697 17.8375 16.3974 17.3084L18.2046 15.9433C18.8417 15.462 19.7873 15.4619 20.4245 15.943C20.9982 16.3762 21.1736 17.0894 20.8109 17.6707C20.388 18.3487 19.7921 19.216 19.2199 19.7459C18.6469 20.2766 17.7939 20.7504 17.0975 21.0865C16.326 21.4589 15.4738 21.6734 14.6069 21.8138C12.8488 22.0983 11.0166 22.0549 9.27633 21.6964C8.29253 21.4937 7.27079 21.3884 6.25993 21.3884Z"/>
<path d="M10.8613 3.36335C11.3679 2.45445 11.6213 2 12 2C12.3787 2 12.6321 2.45445 13.1387 3.36335L13.2698 3.59849C13.4138 3.85677 13.4858 3.98591 13.598 4.07112C13.7103 4.15633 13.8501 4.18796 14.1296 4.25122L14.3842 4.30881C15.3681 4.53142 15.86 4.64273 15.977 5.01909C16.0941 5.39546 15.7587 5.78763 15.088 6.57197L14.9144 6.77489C14.7238 6.99777 14.6285 7.10922 14.5857 7.24709C14.5428 7.38496 14.5572 7.53365 14.586 7.83102L14.6122 8.10176C14.7136 9.14824 14.7644 9.67148 14.4579 9.90409C14.1515 10.1367 13.6909 9.92462 12.7697 9.50047L12.5314 9.39074C12.2696 9.27021 12.1387 9.20994 12 9.20994C11.8613 9.20994 11.7304 9.27021 11.4686 9.39074L11.2303 9.50047C10.3091 9.92462 9.84847 10.1367 9.54206 9.90409C9.23565 9.67148 9.28635 9.14824 9.38776 8.10176L9.41399 7.83102C9.44281 7.53364 9.45722 7.38496 9.41435 7.24709C9.37147 7.10922 9.27617 6.99777 9.08557 6.77489L8.91204 6.57197C8.2413 5.78763 7.90593 5.39546 8.02297 5.01909C8.14001 4.64273 8.63194 4.53142 9.61581 4.30881L9.87035 4.25122C10.1499 4.18796 10.2897 4.15633 10.402 4.07112C10.5142 3.98591 10.5862 3.85677 10.7302 3.59849L10.8613 3.36335Z"/>
<path d="M19.4306 7.68167C19.684 7.22722 19.8106 7 20 7C20.1894 7 20.316 7.22723 20.5694 7.68167L20.6349 7.79925C20.7069 7.92839 20.7429 7.99296 20.799 8.03556C20.8551 8.07817 20.925 8.09398 21.0648 8.12561L21.1921 8.15441C21.684 8.26571 21.93 8.32136 21.9885 8.50955C22.047 8.69773 21.8794 8.89381 21.544 9.28598L21.4572 9.38744C21.3619 9.49889 21.3143 9.55461 21.2928 9.62354C21.2714 9.69248 21.2786 9.76682 21.293 9.91551L21.3061 10.0509C21.3568 10.5741 21.3822 10.8357 21.229 10.952C21.0758 11.0683 20.8455 10.9623 20.3849 10.7502L20.2657 10.6954C20.1348 10.6351 20.0694 10.605 20 10.605C19.9306 10.605 19.8652 10.6351 19.7343 10.6954L19.6151 10.7502C19.1545 10.9623 18.9242 11.0683 18.771 10.952C18.6178 10.8357 18.6432 10.5741 18.6939 10.0509L18.707 9.91551C18.7214 9.76682 18.7286 9.69248 18.7072 9.62354C18.6857 9.55461 18.6381 9.49889 18.5428 9.38744L18.456 9.28598C18.1207 8.89381 17.953 8.69773 18.0115 8.50955C18.07 8.32136 18.316 8.26571 18.8079 8.15441L18.9352 8.12561C19.075 8.09398 19.1449 8.07817 19.201 8.03556C19.2571 7.99296 19.2931 7.92839 19.3651 7.79925L19.4306 7.68167Z"/>
<path d="M3.43063 7.68167C3.68396 7.22722 3.81063 7 4 7C4.18937 7 4.31604 7.22723 4.56937 7.68167L4.63491 7.79925C4.7069 7.92839 4.74289 7.99296 4.79901 8.03556C4.85513 8.07817 4.92503 8.09398 5.06482 8.12561L5.19209 8.15441C5.68403 8.26571 5.93 8.32136 5.98852 8.50955C6.04704 8.69773 5.87935 8.89381 5.54398 9.28598L5.45722 9.38744C5.36191 9.49889 5.31426 9.55461 5.29283 9.62354C5.27139 9.69248 5.27859 9.76682 5.293 9.91551L5.30612 10.0509C5.35682 10.5741 5.38218 10.8357 5.22897 10.952C5.07576 11.0683 4.84547 10.9623 4.38487 10.7502L4.2657 10.6954C4.13481 10.6351 4.06937 10.605 4 10.605C3.93063 10.605 3.86519 10.6351 3.7343 10.6954L3.61513 10.7502C3.15454 10.9623 2.92424 11.0683 2.77103 10.952C2.61782 10.8357 2.64318 10.5741 2.69388 10.0509L2.707 9.91551C2.72141 9.76682 2.72861 9.69248 2.70717 9.62354C2.68574 9.55461 2.63809 9.49889 2.54278 9.38744L2.45602 9.28598C2.12065 8.89381 1.95296 8.69773 2.01148 8.50955C2.07 8.32136 2.31597 8.26571 2.80791 8.15441L2.93518 8.12561C3.07497 8.09398 3.14487 8.07817 3.20099 8.03556C3.25711 7.99296 3.29311 7.92839 3.36509 7.79925L3.43063 7.68167Z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -21,7 +21,7 @@ const stations: Record<Lines, Set<string>> = {
'Кировский завод',
'Автово',
'Ленинский проспект',
'Проспект Ветеранов'
'Проспект Ветеранов',
]),
blue: new Set([
'Парнас',
@ -41,7 +41,7 @@ const stations: Record<Lines, Set<string>> = {
'Парк Победы',
'Московская',
'Звёздная',
'Купчино'
'Купчино',
]),
green: new Set([
'Приморская',
@ -54,7 +54,7 @@ const stations: Record<Lines, Set<string>> = {
'Ломоносовская',
'Пролетарская',
'Обухово',
'Рыбацкое'
'Рыбацкое',
]),
orange: new Set([
'Спасская',
@ -64,7 +64,7 @@ const stations: Record<Lines, Set<string>> = {
'Новочеркасская',
'Ладожская',
'Проспект Большевиков',
'Улица Дыбенко'
'Улица Дыбенко',
]),
violet: new Set([
'Комендантский проспект',
@ -81,7 +81,7 @@ const stations: Record<Lines, Set<string>> = {
'Международная',
'Проспект славы',
'Дунайскай',
'Шушары'
'Шушары',
]),
}

View File

@ -10,4 +10,4 @@
<animate attributeName="stroke-opacity" begin="-0.9s" dur="1.8s" values="1; 0" calcMode="spline" keyTimes="0; 1" keySplines="0.3, 0.61, 0.355, 1" repeatCount="indefinite"/>
</circle>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,4 @@
<svg fill="rgb(185, 179, 170)" width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512">
<!--! Font Awesome Free 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. -->
<path d="M64 448c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L178.8 256L41.38 118.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l160 160c12.5 12.5 12.5 32.75 0 45.25l-160 160C80.38 444.9 72.19 448 64 448z"/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 20H6C4.89543 20 4 19.1046 4 18L4 6C4 4.89543 4.89543 4 6 4H14M10 12H21M21 12L18 15M21 12L18 9" stroke="rgb(185, 179, 170)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@ -0,0 +1,35 @@
import { Announcement } from '../api/announcement/types'
import { getId } from '../utils/auth'
import { FiltersType } from '../utils/filters'
const userCategories = ['givingOut', 'needDispose'] as const
type UserCategory = typeof userCategories[number]
const UserCategoriesNames: Record<UserCategory, string> = {
givingOut: 'Раздача',
needDispose: 'Нужно утилизировать',
}
const userCategoriesInfos: Record<UserCategory, (ann: Announcement) => string> = {
givingOut: (ann: Announcement) => (
`Годен до ${new Date(ann.bestBy).toLocaleDateString('ru')}`
),
needDispose: (ann: Announcement) => (
`Были заинтересованы: ${ann.bookedBy} чел.`
),
}
const composeUserCategoriesFilters: Record<UserCategory, () => FiltersType> = {
givingOut: () => ({
userId: getId(),
expired: false,
}),
needDispose: () => ({
userId: getId(),
expired: true,
}),
}
export type { UserCategory }
export { userCategories, UserCategoriesNames, userCategoriesInfos, composeUserCategoriesFilters }

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill=""><path d="M7.5 6.5C7.5 8.981 9.519 11 12 11s4.5-2.019 4.5-4.5S14.481 2 12 2 7.5 4.019 7.5 6.5zM20 21h1v-1c0-3.859-3.141-7-7-7h-4c-3.86 0-7 3.141-7 7v1h17z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: rgb(17, 17, 17); --darkreader-inline-fill: #e8e6e3;" data-darkreader-inline-fill="">
<path d="M7.5 6.5C7.5 8.981 9.519 11 12 11s4.5-2.019 4.5-4.5S14.481 2 12 2 7.5 4.019 7.5 6.5zM20 21h1v-1c0-3.859-3.141-7-7-7h-4c-3.86 0-7 3.141-7 7v1h17z" />
</svg>

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 350 B

View File

@ -1,16 +1,21 @@
import { Modal, Button } from 'react-bootstrap'
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
import { CSSProperties, useState } from 'react'
import { LatLng } from 'leaflet'
import LineDot from './LineDot'
import Rating from './Rating'
import { categoryNames } from '../assets/category'
import { useBook } from '../hooks/api'
import { useBook, useRemoveAnnouncement } from '../hooks/api'
import { Announcement } from '../api/announcement/types'
import { iconItem } from '../utils/markerIcons'
import { CSSProperties } from 'react'
import { useId } from '../hooks'
import SelectDisposalTrashbox from './SelectDisposalTrashbox'
type AnnouncementDetailsProps = {
close: () => void,
announcement: Announcement
refresh: () => void,
announcement: Announcement,
}
const styles = {
@ -19,17 +24,93 @@ const styles = {
alignItems: 'center',
justifyContent: 'center',
} as CSSProperties,
map: {
width: '100%',
minHeight: 300,
} as CSSProperties,
}
function AnnouncementDetails({ close, announcement: { id, name, category, bestBy, description, lat, lng, address, metro } }: AnnouncementDetailsProps) {
const { handleBook, status: bookStatus } = useBook(id)
const View = ({
announcement: { name, category, bestBy, description, lat, lng, address, metro, userId },
}: { announcement: Announcement }) => (
<>
<h1>{name}</h1>
<span>{categoryNames[category]}</span>
<span className='m-2'>&#x2022;</span>{/* dot */}
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
<p className='mb-0'>{description}</p>
<Rating userId={userId} className='mb-3' />
<MapContainer style={styles.map} center={[lat, lng]} zoom={16} >
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<Marker icon={iconItem} position={[lat, lng]}>
<Popup>
{address}
<br />
<LineDot station={metro} /> {metro}
</Popup>
</Marker>
</MapContainer>
</>
)
type ControlProps = {
closeRefresh: () => void,
announcement: Announcement,
showDispose: () => void
}
function Control({
closeRefresh,
announcement: { bookedBy, id, userId },
showDispose,
}: ControlProps) {
const { handleBook, bookButton } = useBook()
const { handleRemove, removeButton } = useRemoveAnnouncement(closeRefresh)
const myId = useId()
return (
<>
<p>Забронировали {bookedBy} чел.</p>
{(myId === userId) ? (
<>
<Button variant='success' onClick={showDispose}>Утилизировать</Button>
<Button variant='success' onClick={() => void handleRemove(id)} {...removeButton} />
</>
) : (
<Button variant='success' onClick={() => void handleBook(id)} {...bookButton} />
)}
</>
)
}
function AnnouncementDetails({
close,
refresh,
announcement,
}: AnnouncementDetailsProps) {
const closeRefresh = () => {
close()
refresh()
}
const [disposeShow, setDisposeShow] = useState(false)
return (
<div
className='modal'
style={styles.container}
>
<Modal.Dialog style={{ minWidth: '50vw' }}>
<Modal.Dialog centered className='modal-dialog'>
<Modal.Header closeButton onHide={close}>
<Modal.Title>
Подробнее
@ -37,36 +118,26 @@ function AnnouncementDetails({ close, announcement: { id, name, category, bestBy
</Modal.Header>
<Modal.Body>
<h1>{name}</h1>
<span>{categoryNames[category]}</span>
<span className='m-2'>&#x2022;</span>{/* dot */}
<span>Годен до {new Date(bestBy).toLocaleString('ru-RU')}</span>
<p className='mb-3'>{description}</p>
<MapContainer style={{ width: '100%', minHeight: 300 }} center={[lat, lng]} zoom={16} >
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<Marker icon={iconItem} position={[lat, lng]}>
<Popup>
{address}
<br />
<LineDot station={metro} /> {metro}
</Popup>
</Marker>
</MapContainer>
<View announcement={announcement} />
</Modal.Body>
<Modal.Footer>
<Button variant='success' onClick={() => void handleBook()}>
{bookStatus || 'Забронировать'}
</Button>
<Control closeRefresh={closeRefresh} showDispose={() => setDisposeShow(true)} announcement={announcement} />
</Modal.Footer>
</Modal.Dialog>
<Modal centered show={disposeShow} onHide={() => setDisposeShow(false)} style={{ zIndex: 100000 }}>
<Modal.Header closeButton>
<Modal.Title>
Утилизация
</Modal.Title>
</Modal.Header>
<SelectDisposalTrashbox
annId={announcement.id}
category={announcement.category}
address={new LatLng(announcement.lat, announcement.lng)}
closeRefresh={closeRefresh}
/>
</Modal>
</div>
)
}

View File

@ -1,54 +1,77 @@
import { FormEventHandler } from 'react'
import { Button, Form } from 'react-bootstrap'
import { FormEventHandler, useCallback } from 'react'
import { Button, ButtonGroup, Form } from 'react-bootstrap'
import { useSignIn, useSignUp } from '../hooks/api'
import { composeSignUpBody } from '../api/signup'
type AuthFormProps = {
register: boolean
handleAuth: FormEventHandler<HTMLFormElement>,
loading: boolean,
error: string
goBack: () => void,
}
function AuthForm ({ handleAuth, register, loading, error }: AuthFormProps) {
const buttonText = loading ? 'Загрузка...' : (error || (register ? 'Зарегистрироваться' : 'Войти'))
const AuthForm = ({ goBack }: AuthFormProps) => {
const { handleSignUp, signUpButton } = useSignUp()
const { handleSignIn, signInButton } = useSignIn()
const handleAuth: FormEventHandler<HTMLFormElement> = useCallback((e) => {
e.preventDefault()
e.stopPropagation()
const formData = new FormData(e.currentTarget)
const register = (e.nativeEvent as SubmitEvent).submitter?.id === 'register'
void (async () => {
const accountCreated = register ? (
await handleSignUp(composeSignUpBody(formData))
) : true
if (accountCreated) {
if (await handleSignIn(formData)) {
goBack()
}
}
})()
}, [goBack, handleSignUp, handleSignIn])
return (
<Form onSubmit={handleAuth}>
<Form.Group className='mb-3' controlId='email'>
<Form.Label>Почта</Form.Label>
<Form.Control type='email' required />
<Form.Group className='mb-3' controlId='username'>
<Form.Label>Как меня называть</Form.Label>
<Form.Control placeholder='Имя или псевдоним' name='username' type='text' required />
</Form.Group>
{register && <>
<Form.Group className='mb-3' controlId='name'>
<Form.Label>Имя</Form.Label>
<Form.Control type='text' required />
</Form.Group>
<Form.Group className='mb-3' controlId='surname'>
<Form.Label>Фамилия</Form.Label>
<Form.Control type='text' required />
</Form.Group>
</>}
<Form.Group className='mb-3' controlId='password'>
<Form.Label>Пароль</Form.Label>
<Form.Control type='password' required />
<Form.Label>И я могу доказать, что это я</Form.Label>
<Form.Control placeholder='Пароль' name='password' type='password' required />
</Form.Group>
{register &&
<Form.Group className='mb-3' controlId='privacyPolicyConsent'>
<Form.Check>
<Form.Check.Input type='checkbox' required />
<Form.Check.Label>
Я согласен с <a href={`${document.location.origin}/privacy_policy.pdf`} target='_blank' rel='noopener noreferrer'>условиями обработки персональных данных</a>
</Form.Check.Label>
</Form.Check>
</Form.Group>
}
<Form.Group className='mb-3' controlId='privacyPolicyConsent'>
<Form.Check>
<Form.Check.Input type='checkbox' required />
<Form.Check.Label>
Я согласен с <a href={`${document.location.origin}/privacy_policy.pdf`} target='_blank'>условиями обработки персональных данных</a>
</Form.Check.Label>
</Form.Check>
</Form.Group>
<Button variant='success' type='submit'>
{buttonText}
</Button>
<ButtonGroup className='d-flex'>
<Button
className='w-100'
id='register'
variant='success'
type='submit'
{...signUpButton}
/>
<Button
className='w-100'
id='login'
variant='success'
type='submit'
{...signInButton}
/>
</ButtonGroup>
</Form>
)
}

View File

@ -0,0 +1,27 @@
import { Link } from 'react-router-dom'
import { Navbar } from 'react-bootstrap'
import { PropsWithChildren } from 'react'
import BackButton from '../assets/backArrow.svg'
type BackHeaderProps = {
text: string,
}
function BackHeader({ text, children }: PropsWithChildren<BackHeaderProps>) {
return (
<Navbar>
<Navbar.Brand as={Link} to='/'>
<img src={BackButton} alt='Назад' />
</Navbar.Brand>
<Navbar.Text className='me-auto'>
<h4 className='mb-0'>
{text}
</h4>
</Navbar.Text>
{children}
</Navbar>
)
}
export default BackHeader

View File

@ -1,9 +1,9 @@
import { Link } from 'react-router-dom'
import { CSSProperties } from 'react'
import addIcon from '../assets/addIcon.svg'
import filterIcon from '../assets/filterIcon.svg'
import userIcon from '../assets/userIcon.svg'
import { CSSProperties } from 'react'
const styles = {
navBar: {
@ -15,22 +15,20 @@ const styles = {
display: 'flex',
flexDirection: 'row',
height: '100%',
margin: 'auto'
margin: 'auto',
} as CSSProperties,
navBarElement: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
} as CSSProperties,
}
type BottomNavBarProps = {
width: number,
toggleFilters: (p: boolean) => void
toggleFilters: (state: boolean) => void,
}
function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
@ -38,7 +36,7 @@ function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
<div style={styles.navBar}>
<div style={{ ...styles.navBarGroup, width: width }}>
<a style={styles.navBarElement} onClick={() => toggleFilters(true)}>
<a href='#' style={styles.navBarElement} onClick={() => toggleFilters(true)}>
<img src={filterIcon} alt='Фильтровать объявления' title='Фильтровать объявления' />
</a>
@ -46,7 +44,7 @@ function BottomNavBar({ width, toggleFilters }: BottomNavBarProps) {
<img src={addIcon} alt='Опубликовать объявление' title='Опубликовать объявление' />
</Link>
<Link style={styles.navBarElement} to={'/user'} >
<Link style={styles.navBarElement} to='/user' >
<img src={userIcon} alt='Личный кабинет' title='Личный кабинет' />
</Link>

View File

@ -0,0 +1,23 @@
import { PropsWithChildren } from 'react'
import { Card } from 'react-bootstrap'
import { BackHeader } from '.'
type CardLayoutProps = {
text: string,
}
const CardLayout = ({ text, children }: PropsWithChildren<CardLayoutProps>) => (
<>
<div className='mx-4 px-3'>
<BackHeader text={text} />
</div>
<Card className='m-4 mt-0'>
<Card.Body>
{children}
</Card.Body>
</Card>
</>
)
export default CardLayout

View File

@ -0,0 +1,29 @@
import { StoriesPreview } from '.'
import { UserCategoriesNames, UserCategory, composeUserCategoriesFilters } from '../assets/userCategories'
import { useAnnouncements } from '../hooks/api'
import { gotError, gotResponse } from '../hooks/useFetch'
type CategoryPreviewProps = {
category: UserCategory,
}
function CategoryPreview({ category }: CategoryPreviewProps) {
const announcements = useAnnouncements(composeUserCategoriesFilters[category]())
return (
<section>
<h4 className='fw-bold'>{UserCategoriesNames[category]}</h4>
{gotError(announcements) ? (
<p className='text-danger'>{announcements.error}</p>
) : (
gotResponse(announcements) ? (
<StoriesPreview announcements={announcements.data} category={category} />
) : (
'Загрузка...'
)
)}
</section>
)
}
export default CategoryPreview

View File

@ -10,21 +10,21 @@ type FiltersProps = {
filter: FiltersType,
setFilter: SetState<FiltersType>,
filterShown: boolean,
setFilterShown: SetState<boolean>
setFilterShown: SetState<boolean>,
}
function Filters({ filter, setFilter, filterShown, setFilterShown }: FiltersProps) {
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
event.stopPropagation();
event.preventDefault()
event.stopPropagation()
const formData = new FormData(event.currentTarget)
setFilter(prev => ({
...prev,
category: (formData.get('category') as (FiltersType['category'] | null)) || undefined,
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined
metro: (formData.get('metro') as (FiltersType['metro'] | null)) || undefined,
}))
setFilterShown(false)

View File

@ -3,8 +3,9 @@ import { colors, lineNames, lineByName } from '../assets/metro'
function LineDot({ station }: { station: string }) {
const line = lineByName(station)
if (line == undefined)
if (line == undefined) {
return <></>
}
const lineTitle = lineNames[line]
const color = colors[line]

View File

@ -7,7 +7,7 @@ import { iconItem } from '../utils/markerIcons'
type LocationMarkerProps = {
address: string,
position: LatLng,
setPosition: SetState<LatLng>
setPosition: SetState<LatLng>,
}
function LocationMarker({ address, position, setPosition }: LocationMarkerProps) {
@ -21,7 +21,7 @@ function LocationMarker({ address, position, setPosition }: LocationMarkerProps)
},
resize: () => {
setPosition(map.getCenter())
}
},
})
return (

View File

@ -0,0 +1,39 @@
import { CSSProperties } from 'react'
import { usePoetry } from '../hooks/api'
import { gotError, gotResponse } from '../hooks/useFetch'
const styles = {
container: {
paddingBottom: 8,
} as CSSProperties,
}
function Poetry() {
const poetry = usePoetry()
return (
<div style={styles.container}>
<h4 className='fw-bold'>Поэзия</h4> {
gotResponse(poetry) ? (
gotError(poetry) ? (
<div className='text-danger'>
<h5>Ошибка получения стиха</h5>
<p>{poetry.error}</p>
</div>
) : (
<>
<h5>{poetry.data.title}</h5>
<p dangerouslySetInnerHTML={{ __html: poetry.data.text }} />
<p><em>{poetry.data.author}</em></p>
</>
)
) : (
<h5>Загрузка...</h5>
)
}
</div>
)
}
export default Poetry

View File

@ -0,0 +1,40 @@
import { CSSProperties } from 'react'
import handStarsIcon from '../assets/handStars.svg'
type PointsProps = {
points: number | string,
}
const styles = {
container: {
paddingBottom: 8,
} as CSSProperties,
points: {
float: 'right',
} as CSSProperties,
icon: {
height: 24,
paddingBottom: 5,
marginRight: 5,
} as CSSProperties,
}
function Points({ points }: PointsProps) {
return (
<div style={styles.container}>
<h5>
Набрано очков:
<span style={styles.points}>
<img
style={styles.icon}
src={handStarsIcon}
alt='Иконка руки, дающей звёзды' />
{points}
</span>
</h5>
</div>
)
}
export default Points

View File

@ -0,0 +1,78 @@
import { useState } from 'react'
import { gotError, gotResponse } from '../hooks/useFetch'
import { useUserRating, useSendRate } from '../hooks/api'
import styles from '../styles/Rating.module.css'
type StarProps = {
filled: boolean,
selected: boolean,
setMyRate: () => void,
sendMyRate: () => void,
}
function Star({ filled, selected, setMyRate, sendMyRate }: StarProps) {
return (
<button
className={`${styles.star} ${filled ? styles.starFilled : ''} ${selected ? styles.starSelected : ''}`}
onMouseEnter={setMyRate}
onFocus={setMyRate}
onClick={sendMyRate}
>&#9733;</button>
)
}
type RatingProps = {
userId: number,
className: string | undefined,
}
function Rating({ userId, className }: RatingProps) {
const rating = useUserRating(userId)
const [myRate, setMyRate] = useState(0)
const { doSendRate } = useSendRate()
async function sendMyRate() {
const res = await doSendRate(myRate)
if (res) {
rating.refetch()
}
}
return (
<p className={className}>
Рейтинг пользователя:{' '}
{gotResponse(rating) ? (
gotError(rating) ? (
<span className='text-danger'>{rating.error}</span>
) : (
<span
className={styles.starContainer}
onMouseLeave={() => setMyRate(0)}
>
{...Array(5).fill(5).map(
(_, i) =>
<Star
key={i}
filled={i < rating.data}
selected={i < myRate}
setMyRate={() => setMyRate(i + 1)}
sendMyRate={() => void sendMyRate()}
/>
)}
</span>
)
) : (
<span>Загрузка...</span>
)
}
</p>
)
}
export default Rating

View File

@ -0,0 +1,119 @@
import { Button, Modal } from 'react-bootstrap'
import { MapContainer, TileLayer } from 'react-leaflet'
import { CSSProperties, useState } from 'react'
import { LatLng } from 'leaflet'
import { useDispose, useTrashboxes } from '../hooks/api'
import { UseFetchReturn, gotError, gotResponse } from '../hooks/useFetch'
import TrashboxMarkers from './TrashboxMarkers'
import { Category } from '../assets/category'
import { Trashbox } from '../api/trashbox/types'
type SelectDisposalTrashboxProps = {
annId: number,
category: Category,
address: LatLng,
closeRefresh: () => void,
}
type SelectedTrashbox = {
index: number,
category: string,
}
const styles = {
map: {
width: '100%',
height: 400,
} as CSSProperties,
}
function SelectDisposalTrashbox({ annId, category, address, closeRefresh }: SelectDisposalTrashboxProps) {
const trashboxes = useTrashboxes(address, category)
const [selectedTrashbox, setSelectedTrashbox] = useState<SelectedTrashbox>({ index: -1, category: '' })
const { handleDispose, disposeButton } = useDispose(closeRefresh)
return (
<>
<Modal.Body>
<div className='mb-3'>
{gotResponse(trashboxes)
? (
gotError(trashboxes) ? (
<p
style={styles.map}
className='text-danger'
>{trashboxes.error}</p>
) : (
<MapContainer
scrollWheelZoom={false}
style={styles.map}
center={address}
zoom={13}
className=''
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<TrashboxMarkers
trashboxes={trashboxes.data}
selectTrashbox={setSelectedTrashbox}
/>
</MapContainer>
)
) : (
<div style={styles.map}>
<p>Загрузка...</p>
</div>
)
}
</div>
<DisplaySelected trashboxes={trashboxes} selectedTrashbox={selectedTrashbox} />
</Modal.Body>
<Modal.Footer>
<Button
{...disposeButton}
disabled={disposeButton.disabled || gotError(trashboxes) || !gotResponse(trashboxes) || selectedTrashbox.index < 0}
variant='success'
onClick={() => {
if (gotResponse(trashboxes) && !gotError(trashboxes)) {
const { Lat, Lng, Name } = trashboxes.data[selectedTrashbox.index]
void handleDispose(annId, {
Category: selectedTrashbox.category,
Lat,
Lng,
Name,
})
}
}}
/>
</Modal.Footer>
</>
)
}
type DisplaySelectedProps = {
trashboxes: UseFetchReturn<Trashbox[]>,
selectedTrashbox: SelectedTrashbox,
}
function DisplaySelected({ trashboxes, selectedTrashbox }: DisplaySelectedProps) {
if (gotResponse(trashboxes) && !gotError(trashboxes) && selectedTrashbox.index > -1) {
return (
<>
<p className='mb-0'>Выбран пункт сбора мусора на {trashboxes.data[selectedTrashbox.index].Address}</p>
<p className='mb-0'>с категорией "{selectedTrashbox.category}"</p>
</>
)
}
return (
<p className='mb-0'>Выберите пункт сбора мусора и категорию</p>
)
}
export default SelectDisposalTrashbox

View File

@ -0,0 +1,22 @@
import { Navbar } from 'react-bootstrap'
import { Link } from 'react-router-dom'
import { CSSProperties } from 'react'
import { clearToken } from '../utils/auth'
import signOutIcon from '../assets/signOut.svg'
const styles = {
rightIcon: {
marginLeft: '1rem',
marginRight: 0,
} as CSSProperties,
}
const SignOut = () => (
<Navbar.Brand style={styles.rightIcon} as={Link} to='/'>
<img onClick={clearToken} src={signOutIcon} alt='Выйти' />
</Navbar.Brand>
)
export default SignOut

View File

@ -0,0 +1,144 @@
import { Link } from 'react-router-dom'
import { CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from 'react-bootstrap'
import { UserCategory, composeUserCategoriesFilters, userCategoriesInfos } from '../assets/userCategories'
import { Announcement } from '../api/announcement/types'
import { categoryGraphics, categoryNames } from '../assets/category'
import { URLEncodeFilters } from '../utils/filters'
import rightAngleIcon from '../assets/rightAngle.svg'
type StoriesPreviewProps = {
announcements: Announcement[],
category: UserCategory,
}
const styles = {
container: {
transform: 'translateX(0)',
} as CSSProperties,
ul: {
display: 'flex',
gap: 10,
listStyleType: 'none',
overflow: 'scroll',
paddingLeft: 0,
scrollBehavior: 'smooth',
} as CSSProperties,
link: {
textDecoration: 'none',
color: 'var(--bs-body-color)',
maxWidth: 'calc(25vh * 9 / 16)',
display: 'inline-block',
} as CSSProperties,
image: {
height: '25vh',
objectFit: 'contain',
borderRadius: 12,
marginBottom: 5,
} as CSSProperties,
title: {
overflow: 'hidden',
textOverflow: 'ellipsis',
marginBottom: 5,
} as CSSProperties,
scrollButton: {
position: 'fixed',
right: 0,
top: 0,
zIndex: 100,
background: 'linear-gradient(to right, rgba(17, 17, 17, 0) 0%, rgba(17, 17, 17, 255) 100%)',
display: 'block',
height: '100%',
width: '10%',
border: 'none',
cursor: 'default',
borderRadius: 0,
} as CSSProperties,
leftScrollButton: {
left: 0,
transform: 'scaleX(-1)',
} as CSSProperties,
rightScrollButton: {
right: 0,
} as CSSProperties,
}
function StoriesPreview({ announcements, category }: StoriesPreviewProps) {
const ulElement = useRef<HTMLUListElement | null>(null)
const [showScrollButtons, setShowScrollButtons] = useState({ left: false, right: false })
const determineShowScrollButtons = (ul: HTMLUListElement) => (
setShowScrollButtons({
left: ul.scrollLeft > 0,
right: ul.scrollLeft < (ul.scrollWidth - ul.clientWidth),
})
)
useEffect(() => {
const ul = ulElement.current
if (ul) {
determineShowScrollButtons(ul)
const f = () => determineShowScrollButtons(ul)
ul.addEventListener('scroll', f)
return () => ul.removeEventListener('scroll', f)
}
}, [])
useEffect(() => {
const ul = ulElement.current
if (ul) {
determineShowScrollButtons(ul)
}
}, [announcements])
const doScroll = (forward: boolean) => () => {
const ul = ulElement.current
if (ul) {
const storyWidth = window.innerHeight * 0.25 * 9 / 16 + 10
ul.scrollLeft += forward ? storyWidth : -storyWidth
}
}
return <div style={styles.container}>
{showScrollButtons.left &&
<Button onClick={doScroll(false)} style={{ ...styles.scrollButton, ...styles.leftScrollButton }}>
<img src={rightAngleIcon} alt='Показать ещё' />
</Button>
}
<ul style={styles.ul} className='StoriesPreview_ul' ref={ulElement}>
{useMemo(() => announcements.map((ann, i) => (
<li key={`${category}${i}`}>
<Link to={'/?' + new URLSearchParams({
...URLEncodeFilters(composeUserCategoriesFilters[category]()),
storyIndex: i.toString(),
}).toString()} style={styles.link}>
{ann.src?.endsWith('mp4') ? (
<video src={ann.src} style={styles.image} />
) : (
<img
src={ann.src || categoryGraphics[ann.category]}
alt={'Изображение' + (ann.src ? 'предмета' : categoryNames[ann.category])}
style={styles.image}
/>
)}
<p style={styles.title}>{ann.name}</p>
<p style={styles.title}>{userCategoriesInfos[category](ann)}</p>
</Link>
</li>
)), [announcements, category])}
</ul>
{showScrollButtons.right &&
<Button onClick={doScroll(true)} style={{ ...styles.scrollButton, ...styles.rightScrollButton }}>
<img src={rightAngleIcon} alt='Показать ещё' />
</Button>
}
</div>
}
export default StoriesPreview

View File

@ -5,30 +5,38 @@ import { iconTrash } from '../utils/markerIcons'
type TrashboxMarkersProps = {
trashboxes: Trashbox[],
selectTrashbox: ({ index, category }: { index: number, category: string }) => void
selectTrashbox: ({ index, category }: {
index: number,
category: string,
}) => void,
}
function TrashboxMarkers({ trashboxes, selectTrashbox }: TrashboxMarkersProps) {
return (
<>{trashboxes.map((trashbox, index) => (
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
<Popup>
<p>{trashbox.Address}</p>
<p>Тип мусора: <>
{trashbox.Categories.map((category, j) =>
<span key={trashbox.Address + category}>
<a href='#' onClick={() => selectTrashbox({ index, category })}>
{category}
</a>
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
</span>
)}
</></p>
<p>{trashbox.Lat.toFixed(4)}, {trashbox.Lng.toFixed(4)}</p>
</Popup>
</Marker>
))}</>
)
}
const TrashboxMarkers = ({ trashboxes, selectTrashbox }: TrashboxMarkersProps) => (
<>{trashboxes.map((trashbox, index) => (
<Marker icon={iconTrash} key={`${trashbox.Lat}${trashbox.Lng}`} position={[trashbox.Lat, trashbox.Lng]}>
<Popup>
<p className='fw-bold m-0'>{trashbox.Name}</p>
<p className='m-0'>{trashbox.Address}</p>
<p>Тип мусора:{' '}
{trashbox.Categories.map((category, j) =>
<span key={trashbox.Address + category}>
<a href='#' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
selectTrashbox({ index, category })
}}>
{category}
</a>
{(j < trashbox.Categories.length - 1) ? ', ' : ''}
</span>
)}
</p>
<p className='m-0'>
{trashbox.Lat.toFixed(4)}, {trashbox.Lng.toFixed(4)}
</p>
</Popup>
</Marker>
))}</>
)
export default TrashboxMarkers

View File

@ -7,3 +7,11 @@ export { default as TrashboxMarkers } from './TrashboxMarkers'
export { default as WithToken } from './WithToken'
export { default as ClickHandler } from './ClickHandler'
export { default as AuthForm } from './AuthForm'
export { default as BackHeader } from './BackHeader'
export { default as CategoryPreview } from './CategoryPreview'
export { default as StoriesPreview } from './StoriesPreview'
export { default as Points } from './Points'
export { default as SignOut } from './SignOut'
export { default as Poetry } from './Poetry'
export { default as SelectDisposalTrashbox } from './SelectDisposalTrashbox'
export { default as Rating } from './Rating'

View File

@ -1,6 +1,13 @@
export { default as useAnnouncements } from './useAnnouncements'
export { default as useBook } from './useBook'
export { default as useAuth } from './useAuth'
export { default as useTrashboxes } from './useTrashboxes'
export { default as useAddAnnouncement } from './useAddAnnouncement'
export { default as useOsmAddresses } from './useOsmAddress'
export { default as useUser } from './useUser'
export { default as useRemoveAnnouncement } from './useRemoveAnnouncement'
export { default as useSignIn } from './useSignIn'
export { default as useSignUp } from './useSignUp'
export { default as usePoetry } from './usePoetry'
export { default as useDispose } from './useDispose'
export { default as useSendRate } from './useSendRate'
export { default as useUserRating } from './useUserRating'

View File

@ -1,31 +1,26 @@
import { useCallback } from 'react'
import { useSend } from '..'
import { useSendWithButton } from '..'
import { composePutAnnouncementURL, processPutAnnouncement } from '../../api/putAnnouncement'
import { isPutAnnouncementResponse } from '../../api/putAnnouncement/types'
import useSendButtonCaption from '../useSendButtonCaption'
const useAddAnnouncement = () => {
const { doSend, loading, error } = useSend(
function useAddAnnouncement() {
const { doSend, button } = useSendWithButton(
'Опубликовать',
'Опубликовано',
true,
composePutAnnouncementURL(),
'PUT',
true,
isPutAnnouncementResponse,
processPutAnnouncement,
processPutAnnouncement
)
const { update, ...button } = useSendButtonCaption('Опубликовать', loading, error, 'Опубликовано')
const doSendWithButton = useCallback(async (formData: FormData) => {
const data = await doSend({}, {
body: formData
function handleAdd(formData: FormData) {
void doSend({}, {
body: formData,
})
update(data)
}
return data
}, [doSend, update])
return { doSend: doSendWithButton, button }
return { handleAdd, addButton: button }
}
export default useAddAnnouncement

View File

@ -1,7 +1,6 @@
import { useFetch } from '../'
import { FiltersType } from '../../utils/filters'
import { composeAnnouncementsURL, initialAnnouncements, processAnnouncements } from '../../api/announcements'
import { isAnnouncementsResponse } from '../../api/announcements/types'
const useAnnouncements = (filters: FiltersType) => (

View File

@ -1,117 +0,0 @@
import { useState } from 'react'
import { API_URL } from '../../config'
import { isConst, isObject } from '../../utils/types'
import { handleHTTPErrors } from '../../utils'
interface AuthData {
email: string,
password: string,
}
// interface LoginData extends AuthData { }
// interface SignUpData extends AuthData {
// name: string,
// surname: string
// }
type SignUpResponse = {
Success: true
} | {
Success: false,
Message: string
}
const isSignUpResponse = (obj: unknown): obj is SignUpResponse => (
isObject(obj, {
'Success': isConst(true)
}) ||
isObject(obj, {
'Success': isConst(false),
'Message': 'string'
})
)
interface LogInResponse {
access_token: string,
token_type: 'bearer'
}
const isLogInResponse = (obj: unknown): obj is LogInResponse => (
isObject(obj, {
'access_token': 'string',
'token_type': isConst('bearer')
})
)
function useAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function doAuth(data: AuthData, newAccount: boolean) {
setLoading(true)
if (newAccount) {
try {
const res = await fetch(API_URL + '/signup', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
})
handleHTTPErrors(res)
const signupData: unknown = await res.json()
if (!isSignUpResponse(signupData)) {
throw new Error('Malformed server response')
}
if (signupData.Success === false) {
throw new Error(signupData.Message)
}
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
try {
const res = await fetch(API_URL + '/auth/token' + new URLSearchParams({
username: data.email,
password: data.password
}).toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const logInData: unknown = await res.json()
if (!isLogInResponse(logInData)) {
throw new Error('Malformed server response')
}
const token = logInData.access_token
setError('')
setLoading(false)
return token
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
return { doAuth, loading, error }
}
export default useAuth

View File

@ -1,74 +1,32 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useCallback } from 'react'
import { getToken } from '../../utils/auth'
import { API_URL } from '../../config'
import { isObject } from '../../utils/types'
import { handleHTTPErrors } from '../../utils'
import { useSendWithButton } from '..'
import { composeBookURL, processBook } from '../../api/book'
import { isBookResponse } from '../../api/book/types'
type BookResponse = {
Success: boolean
}
function useBook() {
const { doSend, button } = useSendWithButton('Забронировать',
'Забронировано',
true,
composeBookURL(),
'POST',
true,
isBookResponse,
processBook,
)
const isBookResponse = (obj: unknown): obj is BookResponse => (
isObject(obj, {
'Success': 'boolean'
})
)
const handleBook = useCallback((id: number) => {
void doSend({}, {
body: JSON.stringify({
id,
}),
headers: {
'Content-Type': 'application/json',
},
})
}, [doSend])
type BookStatus = '' | 'Загрузка...' | 'Забронировано' | 'Ошибка бронирования'
function useBook(id: number) {
const navigate = useNavigate()
const [status, setStatus] = useState<BookStatus>('')
const handleBook = async () => {
const token = getToken()
if (token) {
setStatus('Загрузка...')
try {
const res = await fetch(API_URL + '/book', {
method: 'POST',
body: JSON.stringify({
id: id
}),
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
})
handleHTTPErrors(res)
const data: unknown = await res.json()
if (!isBookResponse(data)) {
throw new Error('Malformed server response')
}
if (data.Success === true) {
setStatus('Забронировано')
} else {
throw new Error('Server refused to book')
}
}
catch (err) {
setStatus('Ошибка бронирования')
if (import.meta.env.DEV) {
console.log(err)
}
}
} else {
return navigate('/login')
}
}
return { handleBook, status }
return { handleBook, bookButton: button }
}
export default useBook

View File

@ -0,0 +1,35 @@
import { useCallback } from 'react'
import { useSendWithButton } from '..'
import { composeDisposeBody, composeDisposeURL, processDispose } from '../../api/dispose'
import { DisposeParams, isDisposeResponse } from '../../api/dispose/types'
const useDispose = (resolve: () => void) => {
const { doSend, button } = useSendWithButton(
'Выбор сделан',
'Зачтено',
true,
composeDisposeURL(),
'POST',
true,
isDisposeResponse,
processDispose,
)
const doSendWithClose = useCallback(async (...args: DisposeParams) => {
const res = await doSend({}, {
body: composeDisposeBody(...args),
headers: {
'Content-Type': 'application/json',
},
})
if (res) {
resolve()
}
}, [doSend, resolve])
return { handleDispose: doSendWithClose, disposeButton: button }
}
export default useDispose

View File

@ -11,7 +11,7 @@ const useOsmAddresses = (addressPosition: LatLng) => (
false,
isOsmAddressResponse,
processOsmAddress,
''
'',
)
)

View File

@ -0,0 +1,122 @@
import { Poetry } from '../../api/poetry/types'
import { UseFetchReturn } from '../useFetch'
const testPoetry: Poetry = {
title: 'The Mouse\'s Tale',
text: `<div style="padding:0 60px"><div class="eleven" style="position:relative;left:-60px">"Fury said to</div>
<div class="ten" style="position:relative;left:-40px">a mouse, That</div>
<div class="ten" style="position:relative;left:0px">he met</div>
<div class="ten" style="position:relative;left:10px">in the</div>
<div class="ten" style="position:relative;left:20px">house,</div>
<div class="ten" style="position:relative;left:17px">'Let us</div>
<div class="ten" style="position:relative;left:5px">both go</div>
<div class="ten" style="position:relative;left:-7px">to law:</div>
<div class="ten" style="position:relative;left:-23px"><i>I</i> will</div>
<div class="ten" style="position:relative;left:-26px">prosecute</div>
<div class="nine" style="position:relative;left:-40px"><i>you.</i></div>
<div class="nine" style="position:relative;left:-30px">Come, I'll</div>
<div class="nine" style="position:relative;left:-20px">take no</div>
<div class="nine" style="position:relative;left:-7px">denial;</div>
<div class="nine" style="position:relative;left:19px">We must</div>
<div class="nine" style="position:relative;left:45px">have a</div>
<div class="nine" style="position:relative;left:67px">trial:</div>
<div class="nine" style="position:relative;left:80px">For</div>
<div class="eight" style="position:relative;left:70px">really</div>
<div class="eight" style="position:relative;left:57px">this</div>
<div class="eight" style="position:relative;left:75px">morning</div>
<div class="eight" style="position:relative;left:95px">I've</div>
<div class="eight" style="position:relative;left:77px">nothing</div>
<div class="eight" style="position:relative;left:57px">to do.'</div>
<div class="seven" style="position:relative;left:38px">Said the</div>
<div class="seven" style="position:relative;left:30px">mouse to</div>
<div class="seven" style="position:relative;left:18px">the cur,</div>
<div class="seven" style="position:relative;left:22px">'Such a</div>
<div class="seven" style="position:relative;left:37px">trial,</div>
<div class="seven" style="position:relative;left:27px">dear sir,</div>
<div class="seven" style="position:relative;left:9px">With no</div>
<div class="seven" style="position:relative;left:-8px">jury or</div>
<div class="seven" style="position:relative;left:-18px">judge,</div>
<div class="seven" style="position:relative;left:-6px">would be</div>
<div class="seven" style="position:relative;left:7px">wasting</div>
<div class="seven" style="position:relative;left:25px">our breath.'</div>
<div class="six" style="position:relative;left:30px">'I'll be</div>
<div class="six" style="position:relative;left:24px">judge,</div>
<div class="six" style="position:relative;left:15px">I'll be</div>
<div class="six" style="position:relative;left:2px">jury,'</div>
<div class="six" style="position:relative;left:-4px">Said</div>
<div class="six" style="position:relative;left:17px">cunning</div>
<div class="six" style="position:relative;left:29px">old Fury;</div>
<div class="six" style="position:relative;left:37px">'I'll try</div>
<div class="six" style="position:relative;left:51px">the whole</div>
<div class="six" style="position:relative;left:70px">cause,</div>
<div class="six" style="position:relative;left:65px">and</div>
<div class="six" style="position:relative;left:60px">condemn</div>
<div class="six" style="position:relative;left:60px">you</div>
<div class="six" style="position:relative;left:68px">to</div>
<div class="six" style="position:relative;left:82px">death.' "</div><style>.eleven {
font-size: 105%;
margin: 0px;
}
.ten {
font-size: 100%;
margin: 0px;
}
.nine {
font-size: 90%;
margin: 0px;
}
.eight {
font-size: 80%;
margin: 0px;
}
.seven {
font-size: 70%;
margin: 0px;
}
.six {
font-size: 60%;
margin: 0px;
}</style></div>`,
author: 'Lewis Carroll',
}
function usePoetry(): UseFetchReturn<Poetry> {
return (
// useFetch(
// composePoetryURL(),
// 'GET',
// false,
// isPoetryResponse,
// processPoetry,
// initialPoetry,
// )
{
data: testPoetry,
loading: false,
error: null,
refetch: () => { return },
}
// {
// data: undefined,
// loading: false,
// error: 'хе-хе',
// refetch: () => { return },
// }
// {
// data: undefined,
// loading: true,
// error: null,
// refetch: () => { return },
// }
)
}
export default usePoetry

View File

@ -0,0 +1,37 @@
import { useCallback } from 'react'
import { useSendWithButton } from '..'
import { composeRemoveAnnouncementURL, processRemoveAnnouncement } from '../../api/removeAnnouncement'
import { isRemoveAnnouncementResponse } from '../../api/removeAnnouncement/types'
const useRemoveAnnouncement = (resolve: () => void) => {
const { doSend, button } = useSendWithButton(
'Закрыть объявление',
'Закрыто',
true,
composeRemoveAnnouncementURL(),
'DELETE',
true,
isRemoveAnnouncementResponse,
processRemoveAnnouncement,
)
const doSendWithClose = useCallback(async (id: number) => {
const res = await doSend({}, {
body: JSON.stringify({
id,
}),
headers: {
'Content-Type': 'application/json',
},
})
if (res) {
resolve()
}
}, [doSend, resolve])
return { handleRemove: doSendWithClose, removeButton: button }
}
export default useRemoveAnnouncement

View File

@ -0,0 +1,33 @@
import { useSend } from '..'
import { composeSendRateURL, processSendRate } from '../../api/sendRate'
import { isSendRateResponse } from '../../api/sendRate/types'
function useSendRate() {
const { doSend, ...rest } = useSend(
composeSendRateURL(),
'POST',
true,
isSendRateResponse,
processSendRate,
)
const doSendRate = (rate: number) => (
doSend({}, {
body: JSON.stringify({
rate,
}),
headers: {
'Content-Type': 'application/json',
},
})
)
return {
doSendRate,
...rest,
}
}
export default useSendRate

View File

@ -0,0 +1,35 @@
import { useSendWithButton } from '..'
import { composeTokenURL, processToken } from '../../api/token'
import { isTokenResponse } from '../../api/token/types'
import { setToken } from '../../utils/auth'
function useSignIn() {
const { doSend, button } = useSendWithButton(
'Мы уже знакомы',
'Войдено',
false,
composeTokenURL(),
'POST',
false,
isTokenResponse,
processToken,
)
async function handleSignIn(formData: FormData) {
const token = await doSend({}, {
body: formData,
})
if (token !== undefined) {
setToken(token)
return true
}
return false
}
return { handleSignIn, signInButton: button }
}
export default useSignIn

View File

@ -0,0 +1,28 @@
import { useSendWithButton } from '..'
import { composeSignUpURL, processSignUp } from '../../api/signup'
import { isSignUpResponse } from '../../api/signup/types'
function useSignUp() {
const { doSend, button } = useSendWithButton(
'Я здесь впервые',
'Зарегистрирован',
false,
composeSignUpURL(),
'POST',
false,
isSignUpResponse,
processSignUp,
)
async function handleSignUp(formData: FormData) {
const res = await doSend({}, {
body: formData,
})
return res ?? false
}
return { handleSignUp, signUpButton: button }
}
export default useSignUp

View File

@ -1,18 +1,41 @@
import { LatLng } from 'leaflet'
import { sampleSize, random } from 'lodash'
import { useFetch } from '../'
import { composeTrashboxURL, processTrashbox } from '../../api/trashbox'
import { isTrashboxResponse } from '../../api/trashbox/types'
import { Trashbox } from '../../api/trashbox/types'
import { UseFetchReturn } from '../useFetch'
const useTrashboxes = (position: LatLng) => (
useFetch(
composeTrashboxURL(position),
'GET',
true,
isTrashboxResponse,
processTrashbox,
[]
)
import { faker } from '@faker-js/faker/locale/ru'
import { Category, categories } from '../../assets/category'
import { useCallback, useMemo } from 'react'
function genMockTrashbox(pos: LatLng): Trashbox {
const loc = faker.location.nearbyGPSCoordinate({ origin: [pos.lat, pos.lng], radius: 1 })
return {
Name: faker.company.name(),
Address: faker.location.streetAddress(),
Categories: faker.lorem.words({ max: 3, min: 1 }).split(' '),
Lat: loc[0],
Lng: loc[1],
}
}
const useTrashboxes = (position: LatLng, category: Category): UseFetchReturn<Trashbox[]> => (
// useFetch(
// composeTrashboxURL(position, category),
// 'GET',
// true,
// isTrashboxResponse,
// processTrashbox,
// [],
// )
{
data: useMemo(() => new Array(3).fill(3).map(() => genMockTrashbox(position)), [position]),
loading: false,
error: null,
refetch: () => { return },
}
)
export default useTrashboxes

View File

@ -0,0 +1,23 @@
import { initialUser } from '../../api/user'
import { User } from '../../api/user/types'
import { UseFetchReturn } from '../useFetch'
const useUser = (): UseFetchReturn<User> => (
// useFetch(
// composeUserURL(),
// 'GET',
// true,
// isUserResponse,
// processUser,
// initialUser
// )
{
data: initialUser,
loading: false,
error: null,
refetch: () => { return },
}
)
export default useUser

View File

@ -0,0 +1,30 @@
import { composeUserRatingURL, initialUserRating, processUserRating } from '../../api/userRating'
import { UserRating, isUserRatingResponse } from '../../api/userRating/types'
import useFetch, { UseFetchReturn } from '../useFetch'
const useUserRating = (userId: number): UseFetchReturn<UserRating> => (
// useFetch(
// composeUserRatingURL(userId),
// 'GET',
// false,
// isUserRatingResponse,
// processUserRating,
// initialUserRating
// )
{
data: 3,
loading: false,
error: null,
refetch: () => { return },
}
// {
// data: undefined,
// loading: true,
// error: null,
// refetch: () => { return },
// }
)
export default useUserRating

View File

@ -1,3 +1,8 @@
export { default as useStoryDimensions } from './useStoryDimensions'
export { default as useSend } from './useSend'
export { default as useFetch } from './useFetch'
export { default as useStoryIndex } from './useStoryIndex'
export { default as useFilters } from './useFilters'
export { default as useSendWithButton } from './useSendWithButton'
export { default as useSendButtonCaption } from './useSendButtonCaption'
export { default as useId } from './useId'

View File

@ -1,75 +1,107 @@
import { useEffect, useState } from 'react'
import { SetState } from '../utils/types'
import useSend from './useSend'
type UseFetchShared = {
loading: boolean,
abort?: () => void,
refetch: () => void,
}
type UseFetchSucced<T> = {
error: null,
data: T,
loading: false,
error: null,
} & UseFetchShared
type UseFetchLoading = {
data: undefined,
loading: true,
error: null,
} & UseFetchShared
type UseFetchErrored = {
data: undefined,
loading: false,
error: string,
data: undefined
} & UseFetchShared
const gotError = <T>(res: UseFetchErrored | UseFetchSucced<T>): res is UseFetchErrored => (
type UseFetchReturn<T> = UseFetchSucced<T> | UseFetchLoading | UseFetchErrored
const gotError = <T>(res: UseFetchReturn<T>): res is UseFetchErrored => (
typeof res.error === 'string'
)
const fallbackError = <T>(res: UseFetchSucced<T> | UseFetchErrored) => (
gotError(res) ? res.error : res.data
)
type UseFetchReturn<T> = ({
error: null,
data: T
} | {
error: string,
data: undefined
}) & {
loading: boolean,
setData: SetState<T | undefined>
abort?: (() => void)
function fallbackError<T>(res: UseFetchSucced<T> | UseFetchErrored): T | string
function fallbackError<T>(res: UseFetchReturn<T>): T | string | undefined
function fallbackError<T>(res: UseFetchReturn<T>): T | string | undefined {
return (
gotError(res) ? res.error : res.data
)
}
function useFetch<R, T>(
const gotResponse = <T>(res: UseFetchReturn<T>): res is UseFetchSucced<T> | UseFetchErrored => (
!res.loading
)
function useFetch<R, T extends NonNullable<unknown>>(
url: string,
method: RequestInit['method'],
needAuth: boolean,
guardResponse: (data: unknown) => data is R,
processResponse: (data: R) => T,
initialData?: T,
params?: Omit<RequestInit, 'method'>
params?: Omit<RequestInit, 'method'>,
): UseFetchReturn<T> {
const [data, setData] = useState(initialData)
const { doSend, loading, error } = useSend(url, method, needAuth, guardResponse, processResponse, params)
const { doSend, loading, error } = useSend(
url,
method,
needAuth,
guardResponse,
processResponse,
true,
params,
)
useEffect(() => {
function refetch() {
doSend().then(
data => { if (data !== undefined) setData(data) }
).catch( // must never occur
err => import.meta.env.DEV && console.error('Failed to do fetch request', err)
)
}, [doSend])
}
useEffect(refetch, [doSend])
if (loading === true) {
return {
data: undefined,
loading,
error: null,
refetch,
}
}
if (error !== null) {
return {
data: undefined,
loading,
error,
refetch,
}
}
return {
...(
error === null ? ({
data: data!, error: null
}) : ({ data: undefined, error })
),
data: data!,
loading,
setData
error,
refetch,
}
}
export type { UseFetchReturn }
export default useFetch
export { gotError, fallbackError }
export { gotError, gotResponse, fallbackError }

View File

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { FiltersType, URLDecoreFilters, URLEncodeFilters, defaultFilters, excludeFilters } from '../utils/filters'
import { SetState } from '../utils/types'
function useFilters(initialFilters: FiltersType = defaultFilters): [FiltersType, SetState<FiltersType>] {
const [searchParams, setSearchParams] = useSearchParams()
const [filters, setFilters] = useState(initialFilters)
const appendFiltersSearchParams = (filters: FiltersType, replace = false) => (
setSearchParams(params => ({
...excludeFilters(Object.fromEntries(params)),
...URLEncodeFilters(filters),
}), { replace })
)
useEffect(() => {
const urlFilters = URLDecoreFilters(searchParams)
setFilters(prev => ({
...prev,
...urlFilters,
}))
appendFiltersSearchParams({
...URLEncodeFilters(initialFilters),
...URLEncodeFilters(urlFilters),
}, true)
// searchParams have actual query string at first render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const withQuery = (f: SetState<FiltersType>) => (
(nextInit: (FiltersType | ((prev: FiltersType) => FiltersType))) => {
const newFilters = (typeof nextInit === 'function') ? nextInit(filters) : nextInit
appendFiltersSearchParams(newFilters)
f(nextInit)
}
)
return [filters, withQuery(setFilters)]
}
export default useFilters

17
front/src/hooks/useId.ts Normal file
View File

@ -0,0 +1,17 @@
import { useNavigate } from 'react-router-dom'
import { getId } from '../utils/auth'
function useId(require = false) {
const navigate = useNavigate()
const id = getId()
if (require && id < 0) {
navigate('/login')
}
return id
}
export default useId

View File

@ -4,15 +4,16 @@ import { useNavigate } from 'react-router-dom'
import { getToken } from '../utils/auth'
import { handleHTTPErrors, isAborted } from '../utils'
function useSend<R, T>(
function useSend<R, T extends NonNullable<unknown>>(
url: string,
method: RequestInit['method'],
needAuth: boolean,
guardResponse: (data: unknown) => data is R,
processResponse: (data: R) => T,
defaultParams?: Omit<RequestInit, 'method'>
startWithLoading = false,
defaultParams?: Omit<RequestInit, 'method'>,
) {
const [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(startWithLoading)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
@ -35,7 +36,7 @@ function useSend<R, T>(
const headers = new Headers({
...defaultParams?.headers,
...params?.headers
...params?.headers,
})
if (needAuth) {
@ -47,7 +48,7 @@ function useSend<R, T>(
return undefined
}
headers.append('Auth', `Bearer ${token}`)
headers.append('Authorization', `Bearer ${token}`)
}
try {
@ -73,10 +74,14 @@ function useSend<R, T>(
} catch (err) {
if (err instanceof Error && !isAborted(err)) {
setError('Ошибка сети')
if (err instanceof TypeError) {
setError('Ошибка сети')
} else {
setError(err.message)
}
if (import.meta.env.DEV) {
console.log(url, params, err)
console.error(url, params, err)
}
}
@ -88,7 +93,7 @@ function useSend<R, T>(
return {
doSend, loading, error,
abort: abortControllerRef.current?.abort.bind(abortControllerRef.current)
abort: abortControllerRef.current?.abort.bind(abortControllerRef.current),
}
}

View File

@ -5,7 +5,7 @@ function useSendButtonCaption(
loading: boolean,
error: string | null,
result = initial,
singular = true
singular = true,
) {
const [caption, setCaption] = useState(initial)
const [disabled, setDisabled] = useState(false)

View File

@ -0,0 +1,27 @@
import { useCallback } from 'react'
import { useSend } from '.'
import useSendButtonCaption from './useSendButtonCaption'
function useSendWithButton<R, T extends NonNullable<unknown>>(
initial: string,
result: string,
singular?: boolean,
...useSendArgs: Parameters<typeof useSend<R, T>>
) {
const { doSend, loading, error } = useSend(...useSendArgs)
const { update, ...button } = useSendButtonCaption(initial, loading, error, result, singular)
const doSendWithButton = useCallback(async (...args: Parameters<typeof doSend>) => {
const data = await doSend(...args)
update(data)
return data
}, [doSend, update])
return { doSend: doSendWithButton, button }
}
export default useSendWithButton

View File

@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
const getWindowDimensions = () => (
{
width: window.innerWidth,
height: window.innerHeight
height: window.innerHeight,
}
)
@ -12,20 +12,22 @@ function useStoryDimensions(maxRatio = 16 / 9) {
useEffect(() => {
function handleResize() {
setWindowDimensions(getWindowDimensions());
setWindowDimensions(getWindowDimensions())
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize)
}, []);
const height = windowDimensions.height - 56
const bottomBarHeight = 56
const height = windowDimensions.height - bottomBarHeight
const ratio = Math.max(maxRatio, height / windowDimensions.width)
return {
height: height,
width: Math.round(height / ratio)
width: Math.round(height / ratio),
}
}

View File

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { SetState } from '../utils/types'
function useStoryIndex(annLength: number | undefined) {
const [index, setIndex] = useState(0)
const [searchParams, setSearchParams] = useSearchParams()
const withReset = <T>(f: SetState<T>) => (...args: Parameters<SetState<T>>) => {
setIndex(0)
setSearchParams(prev => ({
...Object.fromEntries(prev),
storyIndex: '0',
}), { replace: true })
f(...args)
}
useEffect(() => {
setIndex(annLength ?
Number.parseInt(searchParams.get('storyIndex') || '0') :
0)
// searchParams have actual query string at first render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [annLength])
const increment = () => setIndex(prev => {
const newIndex = (prev + 1) % (annLength || 1)
setSearchParams(prev => ({
...Object.fromEntries(prev),
storyIndex: newIndex.toString(),
}), { replace: true })
return newIndex
})
const decrement = () => setIndex(prev => {
const newIndex = prev > 0 ? (prev - 1) : 0
setSearchParams(prev => ({
...Object.fromEntries(prev),
storyIndex: newIndex.toString(),
}), { replace: true })
return newIndex
})
return {
n: index,
withReset,
increment,
decrement,
}
}
export default useStoryIndex

View File

@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import 'bootstrap/dist/css/bootstrap.min.css'

View File

@ -1,22 +1,18 @@
import { CSSProperties, FormEventHandler, useState } from 'react'
import { Form, Button, Card } from 'react-bootstrap'
import { CSSProperties, FormEventHandler, useEffect, useState } from 'react'
import { Form, Button } from 'react-bootstrap'
import { MapContainer, TileLayer } from 'react-leaflet'
import { latLng } from 'leaflet'
import { useNavigate } from 'react-router-dom'
import { ClickHandler, LocationMarker, TrashboxMarkers } from '../components'
import { useAddAnnouncement, useTrashboxes } from '../hooks/api'
import { ClickHandler, LocationMarker } from '../components'
import { useAddAnnouncement } from '../hooks/api'
import { categories, categoryNames } from '../assets/category'
import { stations, lines, lineNames } from '../assets/metro'
import { fallbackError, gotError } from '../hooks/useFetch'
import { fallbackError, gotResponse } from '../hooks/useFetch'
import { useOsmAddresses } from '../hooks/api'
import CardLayout from '../components/CardLayout'
const styles = {
modal: {
height: 'calc(100vh - 3rem)',
} as CSSProperties,
body: {
overflowY: 'auto',
} as CSSProperties,
map: {
width: '100%',
height: 400,
@ -26,12 +22,11 @@ const styles = {
function AddPage() {
const [addressPosition, setAddressPosition] = useState(latLng(59.972, 30.3227))
const trashboxes = useTrashboxes(addressPosition)
const [selectedTrashbox, setSelectedTrashbox] = useState({ index: -1, category: '' })
const address = useOsmAddresses(addressPosition)
const { doSend, button } = useAddAnnouncement()
const { handleAdd, addButton } = useAddAnnouncement()
const navigate = useNavigate()
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault()
@ -44,142 +39,104 @@ function AddPage() {
formData.append('address', address.data || '') // if address.error
formData.set('bestBy', new Date((formData.get('bestBy') as number | null) || 0).getTime().toString())
void doSend(formData)
handleAdd(formData)
}
useEffect(() => {
if (addButton.children === 'Опубликовано') {
navigate('/')
}
}, [addButton.children, navigate])
return (
<Card className='m-4' style={styles.modal}>
<Card.Body style={styles.body} >
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3' controlId='name'>
<Form.Label>Заголовок объявления</Form.Label>
<Form.Control type='text' required name='name' />
</Form.Group>
<CardLayout text='Опубликовать объявление'>
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3' controlId='name'>
<Form.Label>Заголовок объявления</Form.Label>
<Form.Control type='text' required name='name' />
</Form.Group>
<Form.Group className='mb-3' controlId='category'>
<Form.Label>Категория</Form.Label>
<Form.Select required name='category'>
<option value='' hidden>
Выберите категорию
</option>
{categories.map(category =>
<option key={category} value={category}>{categoryNames[category]}</option>
)}
</Form.Select>
</Form.Group>
<Form.Group className='mb-3' controlId='bestBy'>
<Form.Label>Срок годности</Form.Label>
<Form.Control type='date' required name='bestBy' />
</Form.Group>
<Form.Group className='mb-3' controlId='address'>
<Form.Label>Адрес выдачи</Form.Label>
<div className='mb-3'>
<MapContainer
scrollWheelZoom={false}
style={styles.map}
center={addressPosition}
zoom={13}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<LocationMarker
address={fallbackError(address)}
position={addressPosition}
setPosition={setAddressPosition}
/>
<ClickHandler
setPosition={setAddressPosition}
/>
</MapContainer>
</div>
<p>Адрес: {fallbackError(address)}</p>
</Form.Group>
<Form.Group className='mb-3' controlId='description'>
<Form.Label>Описание</Form.Label>
<Form.Control as='textarea' name='description' rows={3} placeholder='Укажите свои контакты, а так же, когда вам будет удобно передать продукт' />
</Form.Group>
<Form.Group className='mb-3' controlId='src'>
<Form.Label>Иллюстрация (фото или видео)</Form.Label>
<Form.Control
type='file'
name='src'
accept='video/mp4,video/mkv, video/x-m4v,video/*, image/*'
capture='environment'
/>
</Form.Group>
<Form.Group className='mb-3' controlId='metro'>
<Form.Label>
Станция метро
</Form.Label>
<Form.Select name='metro'>
<option value=''>
Укажите ближайщую станцию метро
</option>
{lines.map(
line =>
<optgroup key={line} label={lineNames[line]}>
{Array.from(stations[line]).map(metro =>
<option key={metro} value={metro}>{metro}</option>
)}
</optgroup>
)}
</Form.Select>
</Form.Group>
<Form.Group className='mb-3' controlId='trashbox'>
<Form.Label>Пункт сбора мусора</Form.Label>
<div className='mb-3'>
{trashboxes.loading
? (
<div style={styles.map}>
<p>Загрузка...</p>
</div>
) : (
gotError(trashboxes) ? (
<p
style={styles.map}
className='text-danger'
>{trashboxes.error}</p>
) : (
<MapContainer
scrollWheelZoom={false}
style={styles.map}
center={addressPosition}
zoom={13}
className=''
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<TrashboxMarkers
trashboxes={trashboxes.data}
selectTrashbox={setSelectedTrashbox}
/>
</MapContainer>
)
)
}
</div>
{!gotError(trashboxes) && selectedTrashbox.index > -1 ? (
<p>Выбран пункт сбора мусора на {
trashboxes.data[selectedTrashbox.index].Address
} с категорией {selectedTrashbox.category}</p>
) : (
<p>Выберите пунк сбора мусора и категорию</p>
<Form.Group className='mb-3' controlId='category'>
<Form.Label>Категория</Form.Label>
<Form.Select required name='category'>
<option value='' hidden>
Выберите категорию
</option>
{categories.map(category =>
<option key={category} value={category}>{categoryNames[category]}</option>
)}
</Form.Group>
</Form.Select>
</Form.Group>
<Button variant='success' type='submit' {...button} />
</Form>
</Card.Body>
</Card>
<Form.Group className='mb-3' controlId='bestBy'>
<Form.Label>Срок годности</Form.Label>
<Form.Control type='date' required name='bestBy' />
</Form.Group>
<Form.Group className='mb-3' controlId='address'>
<Form.Label>Адрес выдачи</Form.Label>
<div className='mb-3'>
<MapContainer
scrollWheelZoom={false}
style={styles.map}
center={addressPosition}
zoom={13}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
{gotResponse(address) && <LocationMarker
address={fallbackError(address)}
position={addressPosition}
setPosition={setAddressPosition}
/>}
<ClickHandler
setPosition={setAddressPosition}
/>
</MapContainer>
</div>
<p>Адрес: {gotResponse(address) ? fallbackError(address) : 'Загрузка...'}</p>
</Form.Group>
<Form.Group className='mb-3' controlId='description'>
<Form.Label>Описание</Form.Label>
<Form.Control as='textarea' name='description' rows={3} placeholder='Укажите свои контакты, а так же, когда вам будет удобно передать продукт' />
</Form.Group>
<Form.Group className='mb-3' controlId='src'>
<Form.Label>Иллюстрация (фото или видео)</Form.Label>
<Form.Control
type='file'
name='src'
accept='video/mp4,video/mkv, video/x-m4v,video/*, image/*'
capture='environment'
/>
</Form.Group>
<Form.Group className='mb-3' controlId='metro'>
<Form.Label>
Станция метро
</Form.Label>
<Form.Select name='metro'>
<option value=''>
Укажите ближайщую станцию метро
</option>
{lines.map(
line =>
<optgroup key={line} label={lineNames[line]}>
{Array.from(stations[line]).map(metro =>
<option key={metro} value={metro}>{metro}</option>
)}
</optgroup>
)}
</Form.Select>
</Form.Group>
<Button variant='success' type='submit' {...addButton} />
</Form>
</CardLayout>
)
}

View File

@ -1,39 +1,44 @@
import { CSSProperties, useEffect, useState } from 'react'
import { CSSProperties, useEffect, useMemo, useState } from 'react'
import Stories from 'react-insta-stories'
import { Story } from 'react-insta-stories/dist/interfaces'
import { BottomNavBar, AnnouncementDetails, Filters } from '../components'
import { useStoryDimensions } from '../hooks'
import { useFilters, useStoryDimensions } from '../hooks'
import { useAnnouncements } from '../hooks/api'
import { defaultFilters } from '../utils/filters'
import { Announcement } from '../api/announcement/types'
import { categoryGraphics } from '../assets/category'
import { UseFetchReturn, gotError } from '../hooks/useFetch'
import { useStoryIndex } from '../hooks'
import puffSpinner from '../assets/puff.svg'
import { gotError } from '../hooks/useFetch'
function generateStories(announcements: Announcement[]): Story[] {
function generateStories(announcements: Announcement[], refresh: () => void): Story[] {
return announcements.map(announcement => {
return ({
id: announcement.id,
url: announcement.src || categoryGraphics[announcement.category],
type: announcement.src?.endsWith('mp4') ? 'video' : undefined,
seeMore: ({ close }: { close: () => void }) => <AnnouncementDetails close={close} announcement={announcement} />
seeMore: ({ close }: { close: () => void }) => (
<AnnouncementDetails close={close} refresh={refresh} announcement={announcement} />
),
})
})
}
function fallbackGenerateStories(announcementsFetch: ReturnType<typeof useAnnouncements>) {
if (announcementsFetch.loading)
function fallbackGenerateStories(announcements: UseFetchReturn<Announcement[]>) {
if (announcements.loading) {
return fallbackStory()
}
if (gotError(announcementsFetch))
return fallbackStory(announcementsFetch.error, true)
if (gotError(announcements)) {
return fallbackStory(announcements.error, true)
}
const stories = generateStories(announcementsFetch.data)
const stories = generateStories(announcements.data, announcements.refetch)
if (stories.length === 0)
if (stories.length === 0) {
return fallbackStory('Здесь пока пусто')
}
return stories
}
@ -41,7 +46,9 @@ function fallbackGenerateStories(announcementsFetch: ReturnType<typeof useAnnoun
const fallbackStory = (text = '', isError = false): Story[] => [{
content: ({ action }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { action('pause') }, [action])
useEffect(() => {
action('pause')
}, [action])
return (
<div style={styles.center} className={isError ? 'text-danger' : ''}>
@ -58,7 +65,7 @@ const styles = {
backgroundColor: 'rgb(17, 17, 17)',
} as CSSProperties,
center: {
margin: 'auto'
margin: 'auto',
} as CSSProperties,
}
@ -66,16 +73,23 @@ function HomePage() {
const { height, width } = useStoryDimensions(16 / 9)
const [filterShown, setFilterShown] = useState(false)
const [filter, setFilter] = useState(defaultFilters)
const announcementsFetch = useAnnouncements(filter)
const [filter, setFilter] = useFilters()
const stories = fallbackGenerateStories(announcementsFetch)
const announcements = useAnnouncements(filter)
const stories = useMemo(() => fallbackGenerateStories(announcements), [announcements])
const index = useStoryIndex(announcements.data?.length)
return (<>
<Filters filter={filter} setFilter={setFilter} filterShown={filterShown} setFilterShown={setFilterShown} />
<Filters filter={filter} setFilter={index.withReset(setFilter)} filterShown={filterShown} setFilterShown={setFilterShown} />
<div style={styles.container}>
<Stories
currentIndex={index.n}
onStoryEnd={index.increment}
onNext={index.increment}
onPrevious={index.decrement}
stories={stories}
defaultInterval={11000}
height={height}

View File

@ -1,50 +1,19 @@
import { FormEventHandler } from 'react'
import { Card, Tabs, Tab } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/api';
import { setToken } from '../utils/auth';
import { AuthForm } from '../components';
import { AuthForm } from '../components'
import CardLayout from '../components/CardLayout'
function LoginPage() {
const navigate = useNavigate()
const { doAuth, loading, error } = useAuth()
const handleAuth = (newAccount: boolean): FormEventHandler<HTMLFormElement> => async (event) => {
event.preventDefault();
event.stopPropagation();
const formData = new FormData(event.currentTarget)
const data = {
email: formData.get('email') as string,
name: newAccount ? formData.get('name') as string : undefined,
surname: newAccount ? formData.get('surname') as string : undefined,
password: formData.get('password') as string
}
const token = import.meta.env.PROD ? await doAuth(data, newAccount) : 'a'
if (token) {
setToken(token)
navigate(-1 - Number(import.meta.env.DEV))
}
function goBack() {
navigate(-1 - Number(import.meta.env.DEV))
}
return (
<Card className='m-4'>
<Card.Body>
<Tabs defaultActiveKey='register' fill justify className='mb-3'>
<Tab eventKey='register' title='Регистрация'>
<AuthForm handleAuth={handleAuth(true)} register={true} loading={loading} error={error} />
</Tab>
<Tab eventKey='login' title='Вход'>
<AuthForm handleAuth={handleAuth(false)} register={false} loading={loading} error={error} />
</Tab>
</Tabs>
</Card.Body>
</Card>
<CardLayout text='Представьтесь'>
<AuthForm goBack={goBack} />
</CardLayout>
)
}

View File

@ -1,9 +1,44 @@
import { Link } from 'react-router-dom'
import { Container } from 'react-bootstrap'
import { useUser } from '../hooks/api'
import { userCategories } from '../assets/userCategories'
import { BackHeader, CategoryPreview, Poetry, Points, SignOut } from '../components'
import { gotError, gotResponse } from '../hooks/useFetch'
function UserPage() {
/* TODO */
const user = useUser()
return <h1>For Yet Go <Link to='/'>Home</Link></h1>
return (
<Container style={{ maxWidth: 'calc(100vh*9/16)' }}>
<BackHeader text={
gotResponse(user) ? (
gotError(user) ? (
user.error
) : (
`${user.data.name}, с нами с ${new Date(user.data.regDate).toLocaleDateString('ru')}`
)
) : (
'Загрузка...'
)
}>
<SignOut />
</BackHeader>
<Points points={
gotResponse(user) ? (
gotError(user) ? -1 : user.data?.points
) : (
'Загрузка...'
)
} />
{userCategories.map(cat => (
<CategoryPreview key={cat} category={cat} />
))}
<Poetry />
</Container>
)
}
export default UserPage

View File

@ -0,0 +1,19 @@
.star {
-webkit-text-stroke: 2px var(--bs-body-color);
font-size: 1.5rem;
color: var(--bs-modal-bg);
background: none;
border: none;
padding: 0 3px;
transition: 0.15s ease-in-out;
}
.starFilled {
color: var(--bs-body-color);
}
.starSelected {
color: var(--bs-success);
-webkit-text-stroke: 2px var(--bs-success);
transform: scale(1.3);
}

View File

@ -1,13 +1,76 @@
const getToken = () => {
const token = localStorage.getItem('Token')
import jwt_decode, { JwtPayload } from 'jwt-decode'
/* check expirity */
import { isInt, isObject } from './types'
return token
const TOKEN_KEY = 'Token'
function getToken() {
const token = localStorage.getItem(TOKEN_KEY)
if (token === null) {
return null
}
const payload = jwt_decode<JwtPayload>(token)
// Checks only expiration, not validity
if (
payload.exp &&
(Date.now() >= payload.exp * 1000)
) {
return null
}
return token
}
function setToken(token: string) {
localStorage.setItem('Token', token)
localStorage.setItem(TOKEN_KEY, token)
}
export { getToken, setToken }
function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
type TokenPayload = {
user_id: number,
}
const isTokenPayload = (data: unknown): data is TokenPayload => isObject(data, {
'user_id': 'number',
})
function getId() {
try {
const token = getToken()
if (token === null) {
return -1
}
const payload = jwt_decode(token)
if (!isTokenPayload(payload)) {
throw new Error('Malformed token payload')
}
const id = payload.user_id
if (!isInt(id) || id < 0) {
throw new Error(`Not valid id: ${id}`)
}
return id
}
catch (err) {
if (import.meta.env.DEV) {
console.error(err)
}
clearToken()
return -1
}
}
export { getToken, setToken, clearToken, getId }

View File

@ -0,0 +1,9 @@
import { Announcement } from '../api/announcement/types'
const DAY_MS = 24 * 60 * 60 * 1000
const isAnnExpired = (ann: Announcement) => (
(ann.bestBy - Date.now()) < DAY_MS
)
export { isAnnExpired }

View File

@ -1,19 +1,59 @@
import { Announcement } from '../api/announcement/types'
import { isCategory } from '../assets/category'
import { fallbackToUndefined, isBoolean, isInt } from './types'
const filterNames = ['userId', 'category', 'metro', 'bookedBy'] as const
const filterNames = ['userId', 'category', 'metro', 'expired'] as const
type FilterNames = typeof filterNames[number]
type FiltersType = Partial<Pick<Announcement, FilterNames>>
type FiltersType = Partial<
Pick<Announcement, FilterNames & keyof Announcement> &
{
expired: boolean,
}
>
const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, bookedBy: undefined }
const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, expired: false }
const URLEncodeFilters = (filters: FiltersType) => (
Object.fromEntries(
filterNames.map(
fName => [fName, filters[fName]?.toString()]
fName => {
const v = filters[fName]
if (v || v === false) {
return [fName, v.toString()]
}
return [fName, undefined]
}
).filter((p): p is [string, string] => typeof p[1] !== 'undefined')
)
)
const URLDecoreFilters = (params: URLSearchParams): FiltersType => {
const strFilters = Object.fromEntries(
filterNames.map(
fName => [fName, params.get(fName)]
).filter((p): p is [FilterNames, string] => p[1] !== null)
) as Record<FilterNames, string>
return {
userId: fallbackToUndefined(Number.parseInt(strFilters['userId']), isInt),
category: fallbackToUndefined(strFilters['category'], isCategory),
metro: strFilters['metro'],
expired: fallbackToUndefined(strFilters['expired'] === 'true', isBoolean),
}
}
const excludeFilters = <T extends FiltersType>(obj: T) => (
filterNames.reduce((cObj, fName) => {
delete cObj[fName]
return cObj
}, obj)
)
const convertFilterNames = (input: Record<string, string>) => ({
...input,
...(input['userId'] === undefined ? {} : { 'user_id': input['userId'] }),
})
export type { FilterNames, FiltersType }
export { defaultFilters, filterNames, URLEncodeFilters }
export { defaultFilters, filterNames, URLEncodeFilters, URLDecoreFilters, excludeFilters, convertFilterNames }

View File

@ -60,8 +60,28 @@ const isString = (obj: unknown): obj is string => (
typeof obj === 'string'
)
const isInt = (obj: unknown): obj is number => (
Number.isSafeInteger(obj)
)
const isBoolean = (obj: unknown): obj is boolean => (
typeof obj === 'boolean'
)
function fallbackToUndefined<T>(obj: unknown, isT: ((obj: unknown) => obj is T)) {
if (!isT(obj)) return undefined
return obj
}
function fallbackTo<T>(obj: unknown, isT: ((obj: unknown) => obj is T), to: T) {
if (!isT(obj)) return to
return obj
}
type SetState<T> = React.Dispatch<React.SetStateAction<T>>
export type { SetState }
export { isRecord, isObject, isConst, isLiteralUnion, isArrayOf, isString }
export { isRecord, isObject, isConst, isLiteralUnion, isArrayOf, isString, isInt, isBoolean, fallbackToUndefined, fallbackTo }