diff --git a/front/src/api/announcement/index.ts b/front/src/api/announcement/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/front/src/api/announcement/types.ts b/front/src/api/announcement/types.ts new file mode 100644 index 0000000..a750c37 --- /dev/null +++ b/front/src/api/announcement/types.ts @@ -0,0 +1,59 @@ +import { isObject } from '../../utils/types' +import { Category, isCategory } from '../../assets/category' + +type AnnouncementResponse = { + id: number, + user_id: number, + name: string, + category: Category, + best_by: number, + address: string, + longtitude: number, + latitude: number, + description: string, + src: string | null, + metro: string, + trashId: number | null, + booked_by: number +} + +const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => isObject(obj, { + 'id': 'number', + 'user_id': 'number', + 'name': 'string', + 'category': isCategory, + 'best_by': 'number', + 'address': 'string', + 'longtitude': 'number', + 'latitude': 'number', + 'description': 'string', + 'src': 'string?', + 'metro': 'string', + 'trashId': 'number?', + 'booked_by': 'number' +}) + +type Announcement = { + id: number, + userId: number, + name: string, + category: Category, + bestBy: number, + address: string, + lng: number, + lat: number, + description: string | null, + src: string | null, + metro: string, + trashId: number | null, + bookedBy: number +} + +export type { + Announcement, + AnnouncementResponse, +} + +export { + isAnnouncementResponse, +} diff --git a/front/src/api/announcements/index.ts b/front/src/api/announcements/index.ts new file mode 100644 index 0000000..2b8bfa5 --- /dev/null +++ b/front/src/api/announcements/index.ts @@ -0,0 +1,24 @@ +import { API_URL } from '../../config' +import { FiltersType, URLEncodeFilters } from '../../utils/filters' +import { Announcement } from '../announcement/types' +import { AnnouncementsResponse } from './types' + +const initialAnnouncements: Announcement[] = [] + +const composeAnnouncementsURL = (filters: FiltersType) => + API_URL + '/announcements?' + new URLSearchParams(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 + })) +} + +export { initialAnnouncements, composeAnnouncementsURL, processAnnouncements } diff --git a/front/src/api/announcements/types.ts b/front/src/api/announcements/types.ts new file mode 100644 index 0000000..34119e0 --- /dev/null +++ b/front/src/api/announcements/types.ts @@ -0,0 +1,20 @@ +import { isArrayOf, isObject } from '../../utils/types' +import { AnnouncementResponse, isAnnouncementResponse } from '../announcement/types' + +type AnnouncementsResponse = { + list_of_announcements: AnnouncementResponse[], + Success: boolean +} + +const isAnnouncementsResponse = (obj: unknown): obj is AnnouncementsResponse => isObject(obj, { + 'list_of_announcements': obj => isArrayOf(obj, isAnnouncementResponse), + 'Success': 'boolean' +}) + +export type { + AnnouncementsResponse, +} + +export { + isAnnouncementsResponse, +} diff --git a/front/src/api/trashbox/index.ts b/front/src/api/trashbox/index.ts new file mode 100644 index 0000000..20244c1 --- /dev/null +++ b/front/src/api/trashbox/index.ts @@ -0,0 +1,11 @@ +import { LatLng } from 'leaflet' + +import { API_URL } from '../../config' + +const composeTrashboxURL = (position: LatLng) => + API_URL + '/trashbox?' + new URLSearchParams({ + lat: position.lat.toString(), + lng: position.lng.toString() + }).toString() + +export { composeTrashboxURL } diff --git a/front/src/api/trashbox/types.ts b/front/src/api/trashbox/types.ts new file mode 100644 index 0000000..1f60bdb --- /dev/null +++ b/front/src/api/trashbox/types.ts @@ -0,0 +1,22 @@ +import { isArrayOf, isObject, isString } from '../../utils/types' + +type Trashbox = { + Lat: number, + Lng: number, + Address: string, + Categories: string[] +} + +const isTrashbox = (obj: unknown): obj is Trashbox => isObject(obj, { + 'Lat': 'number', + 'Lng': 'number', + 'Address': 'string', + 'Categories': obj => isArrayOf(obj, isString) +}) + +type TrashboxResponse = Trashbox[] + +const isTrashboxResponse = (obj: unknown): obj is Trashbox[] => isArrayOf(obj, isTrashbox) + +export type { Trashbox, TrashboxResponse } +export { isTrashbox, isTrashboxResponse } diff --git a/front/src/components/AnnouncementDetails.tsx b/front/src/components/AnnouncementDetails.tsx index 3fcaf06..eef9bba 100644 --- a/front/src/components/AnnouncementDetails.tsx +++ b/front/src/components/AnnouncementDetails.tsx @@ -4,7 +4,7 @@ import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet' import LineDot from './LineDot' import { categoryNames } from '../assets/category' import { useBook } from '../hooks/api' -import { Announcement } from '../hooks/api/useHomeAnnouncementList' +import { Announcement } from '../hooks/api/useAnnouncements' import { iconItem } from '../utils/markerIcons' type AnnouncementDetailsProps = { diff --git a/front/src/hooks/api/index.ts b/front/src/hooks/api/index.ts index 69169bc..277a716 100644 --- a/front/src/hooks/api/index.ts +++ b/front/src/hooks/api/index.ts @@ -1,4 +1,4 @@ -export { default as useHomeAnnouncementList } from './useHomeAnnouncementList' +export { default as useAnnouncements } from './useAnnouncements' export { default as useBook } from './useBook' export { default as useAuth } from './useAuth' export { default as useTrashboxes } from './useTrashboxes' diff --git a/front/src/hooks/api/useAnnouncements.ts b/front/src/hooks/api/useAnnouncements.ts new file mode 100644 index 0000000..beed64e --- /dev/null +++ b/front/src/hooks/api/useAnnouncements.ts @@ -0,0 +1,18 @@ +import useFetch from './useFetch' +import { FiltersType } from '../../utils/filters' +import { composeAnnouncementsURL, initialAnnouncements, processAnnouncements } from '../../api/announcements' + +import { isAnnouncementsResponse } from '../../api/announcements/types' + +const useAnnouncements = (filters: FiltersType) => { + return useFetch( + composeAnnouncementsURL(filters), + 'GET', + false, + processAnnouncements, + isAnnouncementsResponse, + initialAnnouncements + ) +} + +export default useAnnouncements diff --git a/front/src/hooks/api/useFetch.ts b/front/src/hooks/api/useFetch.ts index 0568dc8..4759a93 100644 --- a/front/src/hooks/api/useFetch.ts +++ b/front/src/hooks/api/useFetch.ts @@ -1,11 +1,53 @@ import { useEffect, useRef, useState } from 'react' import { handleHTTPErrors, isAborted } from '../../utils' +import { getToken } from '../../utils/auth' +import { useNavigate } from 'react-router-dom' -const useFetch = (url: string, params: RequestInit | undefined, initialData: T, dataGuard: (obj: unknown) => obj is T) => { +type UseFetchShared = { + loading: boolean, + abort?: () => void, +} + +type UseFetchSucced = { + error: null, + data: T, +} & UseFetchShared + +type UseFetchErrored = { + error: string, + data: undefined +} & UseFetchShared + +const gotError = (res: UseFetchErrored | UseFetchSucced): res is UseFetchErrored => { + return typeof res.error === 'string' +} + +type UseFetchReturn = ({ + error: null, + data: T +} | { + error: string, + data: undefined +}) & { + loading: boolean, + abort?: (() => void) +} + +const useFetch = ( + url: string, + method: RequestInit['method'], + needAuth: boolean, + processData: (data: R) => T, + guardResponse: (data: unknown) => data is R, + initialData?: T, + params?: RequestInit +): UseFetchReturn => { const [data, setData] = useState(initialData) const [loading, setLoading] = useState(true) - const [error, setError] = useState('') + const [error, setError] = useState(null) + + const navigate = useNavigate() const abortControllerRef = useRef() @@ -17,39 +59,65 @@ const useFetch = (url: string, params: RequestInit | undefined, initialData: const abortController = new AbortController() abortControllerRef.current = abortController - fetch(url, { ...params, signal: abortControllerRef.current.signal }) + const headers = new Headers({ + ...params?.headers + }) + + if (needAuth) { + const token = getToken() + + if (token === null) { + return navigate('/login') + } + + headers.append('Auth', `Bearer ${token}`) + } + + fetch(url, { + method, + ...params, + headers, + signal: abortControllerRef.current.signal, + }) .then(res => { handleHTTPErrors(res) return res.json() }) .then(data => { - if (!dataGuard(data)) { - throw new Error('Неверный ответ от сервера') + if (!guardResponse(data)) { + throw new Error('Malformed server response') } - setData(data) + setData(processData(data)) setLoading(false) }) .catch(err => { if (err instanceof Error && !isAborted(err)) { setError('Ошибка сети') + + if (import.meta.env.DEV) { + console.log(url, params, err) + } } setLoading(false) - - if (import.meta.env.DEV) { - console.log(url, params, err) - } }) return () => abortControllerRef.current?.abort() - }, [url, params, dataGuard]) + }, [url, method, needAuth, params, guardResponse, processData, navigate]) return { - data, loading, error, + ...( + error === null ? ({ + data: data!, error: null + }) : ({ data: undefined, error }) + ), + loading, abort: abortControllerRef.current?.abort.bind(abortControllerRef.current) } } export default useFetch + +export { gotError } diff --git a/front/src/hooks/api/useHomeAnnouncementList.ts b/front/src/hooks/api/useHomeAnnouncementList.ts deleted file mode 100644 index ca1aa26..0000000 --- a/front/src/hooks/api/useHomeAnnouncementList.ts +++ /dev/null @@ -1,96 +0,0 @@ -import useFetch from './useFetch' -import { FiltersType, filterNames } from '../../utils/filters' -import { isArrayOf, isObject } from '../../utils/types' -import { API_URL } from '../../config' -import { Category, isCategory } from '../../assets/category' - -const initialAnnouncements = { list_of_announcements: [], Success: true } - -type AnnouncementsListResponse = { - list_of_announcements: AnnouncementResponse[], - Success: boolean -} - -const isAnnouncementsListResponse = (obj: unknown): obj is AnnouncementsListResponse => isObject(obj, { - 'list_of_announcements': obj => isArrayOf(obj, isAnnouncementResponse), - 'Success': 'boolean' -}) - -type AnnouncementResponse = { - id: number, - user_id: number, - name: string, - category: Category, - best_by: number, - address: string, - longtitude: number, - latitude: number, - description: string, - src: string | null, - metro: string, - trashId: number | null, - booked_by: number -} - -const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => isObject(obj, { - 'id': 'number', - 'user_id': 'number', - 'name': 'string', - 'category': isCategory, - 'best_by': 'number', - 'address': 'string', - 'longtitude': 'number', - 'latitude': 'number', - 'description': 'string', - 'src': 'string?', - 'metro': 'string', - 'trashId': 'number?', - 'booked_by': 'number' -}) - -type Announcement = { - id: number, - userId: number, - name: string, - category: Category, - bestBy: number, - address: string, - lng: number, - lat: number, - description: string | null, - src: string | null, - metro: string, - trashId: number | null, - bookedBy: number -} - -const composeFilters = (filters: FiltersType) => Object.fromEntries( - filterNames.map( - fName => [fName, filters[fName]?.toString()] - ).filter((p): p is [string, string] => typeof p[1] !== 'undefined') -) - -const useHomeAnnouncementList = (filters: FiltersType) => { - const { data, loading, error } = useFetch( - API_URL + '/announcements?' + new URLSearchParams(composeFilters(filters)).toString(), - undefined, - initialAnnouncements, - isAnnouncementsListResponse - ) - - const annList = data.list_of_announcements - - const res: Announcement[] = annList.map(ann => ({ - ...ann, - lat: ann.latitude, - lng: ann.longtitude, - bestBy: ann.best_by, - bookedBy: ann.booked_by, - userId: ann.user_id - })) - - return { data: error ? [] : res, loading, error } -} - -export type { Announcement, AnnouncementsListResponse } -export default useHomeAnnouncementList diff --git a/front/src/hooks/api/useTrashboxes.ts b/front/src/hooks/api/useTrashboxes.ts index 6c87488..f9a02b6 100644 --- a/front/src/hooks/api/useTrashboxes.ts +++ b/front/src/hooks/api/useTrashboxes.ts @@ -1,35 +1,18 @@ import { LatLng } from 'leaflet' -import { API_URL } from '../../config' -import { isArrayOf, isObject } from '../../utils/types' import useFetch from './useFetch' -import { isString } from '../../utils/types' - -type Trashbox = { - Lat: number, - Lng: number, - Address: string, - Categories: string[] -} - -const isTrashbox = (obj: unknown): obj is Trashbox => isObject(obj, { - 'Lat': 'number', - 'Lng': 'number', - 'Address': 'string', - 'Categories': obj => isArrayOf(obj, isString) -}) +import { composeTrashboxURL } from '../../api/trashbox' +import { isTrashboxResponse } from '../../api/trashbox/types' const useTrashboxes = (position: LatLng) => { return useFetch( - API_URL + '/trashbox?' + new URLSearchParams({ - lat: position.lat.toString(), - lng: position.lng.toString() - }).toString(), - undefined, - [], - (obj): obj is Trashbox[] => isArrayOf(obj, isTrashbox) + composeTrashboxURL(position), + 'GET', + true, + (data) => data, + isTrashboxResponse, + [] ) } -export type { Trashbox } export default useTrashboxes diff --git a/front/src/pages/AddPage.tsx b/front/src/pages/AddPage.tsx index 5ec6602..b9f9f70 100644 --- a/front/src/pages/AddPage.tsx +++ b/front/src/pages/AddPage.tsx @@ -9,6 +9,7 @@ import { isObject } from '../utils/types' import { handleHTTPErrors } from '../utils' import { categories, categoryNames } from '../assets/category' import { stations, lines, lineNames } from '../assets/metro' +import { gotError } from '../hooks/api/useFetch' function AddPage() { const [addressPosition, setAddressPosition] = useState(latLng(59.972, 30.3227)) @@ -165,7 +166,7 @@ function AddPage() {

Загрузка...

) : ( - trashboxes.error ? ( + gotError(trashboxes) ? (

- {selectedTrashbox.index > -1 ? ( + {!gotError(trashboxes) && selectedTrashbox.index > -1 ? (

Выбран пункт сбора мусора на { trashboxes.data[selectedTrashbox.index].Address } с категорией {selectedTrashbox.category}

diff --git a/front/src/pages/HomePage.tsx b/front/src/pages/HomePage.tsx index cae636c..ae926da 100644 --- a/front/src/pages/HomePage.tsx +++ b/front/src/pages/HomePage.tsx @@ -4,12 +4,14 @@ import { Story } from 'react-insta-stories/dist/interfaces' import { BottomNavBar, AnnouncementDetails, Filters } from '../components' import { useStoryDimensions } from '../hooks' -import { useHomeAnnouncementList } from '../hooks/api' +import { useAnnouncements } from '../hooks/api' import { defaultFilters } from '../utils/filters' -import { Announcement } from '../hooks/api/useHomeAnnouncementList' -import puffSpinner from '../assets/puff.svg' +import { Announcement } from '../api/announcement/types' import { categoryGraphics } from '../assets/category' +import puffSpinner from '../assets/puff.svg' +import { gotError } from '../hooks/api/useFetch' + function generateStories(announcements: Announcement[]): Story[] { return announcements.map(announcement => { return ({ @@ -21,15 +23,15 @@ function generateStories(announcements: Announcement[]): Story[] { }) } -function fallbackGenerateStories(announcementsFetch: ReturnType) { - const stories = generateStories(announcementsFetch.data) - +function fallbackGenerateStories(announcementsFetch: ReturnType) { if (announcementsFetch.loading) return fallbackStory() - if (announcementsFetch.error) + if (gotError(announcementsFetch)) return fallbackStory(announcementsFetch.error, true) + const stories = generateStories(announcementsFetch.data) + if (stories.length === 0) return fallbackStory('Здесь пока пусто') @@ -49,19 +51,25 @@ const fallbackStory = (text = '', isError = false): Story[] => [{ }, }] +const storiesContainerCSS = { + display: 'flex', + justifyContent: 'center', + backgroundColor: 'rgb(17, 17, 17)' +} + function HomePage() { const { height, width } = useStoryDimensions(16 / 10) const [filterShown, setFilterShown] = useState(false) const [filter, setFilter] = useState(defaultFilters) - const announcementsFetch = useHomeAnnouncementList(filter) + const announcementsFetch = useAnnouncements(filter) const stories = fallbackGenerateStories(announcementsFetch) return (<> -
+
> const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, bookedBy: undefined } +const URLEncodeFilters = (filters: FiltersType) => Object.fromEntries( + filterNames.map( + fName => [fName, filters[fName]?.toString()] + ).filter((p): p is [string, string] => typeof p[1] !== 'undefined') +) + export type { FilterNames, FiltersType } -export { defaultFilters, filterNames } +export { defaultFilters, filterNames, URLEncodeFilters }