diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 096662d..affd48c 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -6,10 +6,14 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', - "htmlacademy/react-typescript", + 'htmlacademy/react-typescript', ], parser: '@typescript-eslint/parser', - parserOptions: { ecmaVersion: 'latest', sourceType: 'module', project: 'tsconfig.json' }, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: 'tsconfig.json', + }, settings: { react: { version: 'detect' } }, plugins: ['react-refresh'], rules: { @@ -17,8 +21,8 @@ module.exports = { }, overrides: [ { - files: [ '*test*' ], - rules: { '@typescript-eslint/unbound-method': 'off' } + files: ['*test*'], + rules: { '@typescript-eslint/unbound-method': 'off' }, }, ], -} +}; diff --git a/package.json b/package.json index d1d0ee6..e6a469b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,11 @@ "vite": "4.4.11", "vitest": "0.34.6" }, + "eslintConfig": { + "rules": { + "no-console": "off" + } + }, "browserslist": { "production": [ ">0.2%", diff --git a/src/App.tsx b/src/App.tsx index 38ebfbe..f50619d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,19 +5,19 @@ import LoginPage from './components/Login/LoginPage'; import Offer from './components/Offer/Offer'; import { useAppSelector } from './hooks'; import { REVIEWERS } from './mock/reviewers'; - +//import LoadingScreen from './components/loading-screen/loading-screen'; export const App: React.FC = () => { const currentCity = useAppSelector((state) => state.currentCity); - const offers = useAppSelector((state) => state.offers); - const cities = useAppSelector((state) => state.cities); + const offers = useAppSelector((state) => state.offerPage); + const cities = useAppSelector((state) => state.Cities); return ( - } /> + } /> } /> - } /> - } /> + } /> + } /> ); diff --git a/src/action.ts b/src/action.ts index a3db947..714ffb3 100644 --- a/src/action.ts +++ b/src/action.ts @@ -3,3 +3,5 @@ import { OfferObject } from './types/types'; export const changeCity = createAction('ChangeCity'); export const AddOffer = createAction('AddOffer'); + +export const loadOffers = createAction('data/fetchOffers'); diff --git a/src/api-actions.ts b/src/api-actions.ts new file mode 100644 index 0000000..ecc918f --- /dev/null +++ b/src/api-actions.ts @@ -0,0 +1,60 @@ +import { AxiosInstance } from 'axios'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { AppDispatch, State } from './types/types'; +import { OfferObject } from './types/types'; +//import {redirectToRoute} from './action'; +//import { saveToken, dropToken } from './token'; +import { APIRoute } from './const'; +//import {AuthData} from '../types/auth-data'; +//import {UserData} from '../types/user-data'; +import { createAPI } from './api'; + +export const api = createAPI(); +export const fetchOfferObjectAction = createAsyncThunk< + OfferObject[], + undefined, + { + dispatch: AppDispatch; + state: State; + extra: AxiosInstance; + } +>('data/fetchOffers', async () => { + const { data } = await api.get(APIRoute.Offers); + return data; +}); +/* +export const checkAuthAction = createAsyncThunk( + 'user/checkAuth', + async (_arg, {extra: api}) => { + await api.get(APIRoute.Login); + }, +); + +export const loginAction = createAsyncThunk( + 'user/login', + async ({login: email, password}, {dispatch, extra: api}) => { + const {data: {token}} = await api.post(APIRoute.Login, {email, password}); + saveToken(token); + dispatch(redirectToRoute(AppRoute.Result)); + }, +); + +export const logoutAction = createAsyncThunk( + 'user/logout', + async (_arg, {extra: api}) => { + await api.delete(APIRoute.Logout); + dropToken(); + }, +);*/ diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..a5bb389 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,52 @@ +import axios, { + AxiosInstance, + //AxiosRequestConfig, + InternalAxiosRequestConfig, + AxiosResponse, + AxiosError, +} from 'axios'; +//import AxiosRequestConfig from 'axios'; +import { StatusCodes } from 'http-status-codes'; +import { getToken } from './token'; + +const BACKEND_URL = 'https://14.design.htmlacademy.pro/six-cities'; +const REQUEST_TIMEOUT = 5000; +type DetailMessageType = { + type: string; + message: string; +}; + +const StatusCodeMapping: Record = { + [StatusCodes.BAD_REQUEST]: true, + [StatusCodes.UNAUTHORIZED]: true, + [StatusCodes.NOT_FOUND]: true, +}; + +const shouldDisplayError = (response: AxiosResponse) => + !!StatusCodeMapping[response.status]; +export const createAPI = (): AxiosInstance => { + const api = axios.create({ + baseURL: BACKEND_URL, + timeout: REQUEST_TIMEOUT, + }); + api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = getToken(); + if (token && config.headers) { + config.headers['x-token'] = token; + } + return config; + }); + + api.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response && shouldDisplayError(error.response)) { + //const detailMessage = (error.response.data); + //toast.warn(detailMessage.message); + } + + throw error; + } + ); + return api; +}; diff --git a/src/components/Favorites/Favorite.tsx b/src/components/Favorites/Favorite.tsx index 365752a..fd726de 100644 --- a/src/components/Favorites/Favorite.tsx +++ b/src/components/Favorites/Favorite.tsx @@ -3,7 +3,7 @@ import OfferCard from '../Offer/OfferCard'; import { OfferObject, CardCssNameList } from '../../types/types'; type FavoriteProps = { - offers: OfferObject[]; + offers: OfferObject[] | null; }; const Favorite = ({ offers }: FavoriteProps) => (
@@ -12,7 +12,7 @@ const Favorite = ({ offers }: FavoriteProps) => (

Saved listings

- {offers.map((offer) => ( + {offers?.map((offer) => ( ))}
diff --git a/src/components/MainPage/MainPage.tsx b/src/components/MainPage/MainPage.tsx index 772925e..4f2eab6 100644 --- a/src/components/MainPage/MainPage.tsx +++ b/src/components/MainPage/MainPage.tsx @@ -1,26 +1,31 @@ import {FC} from 'react'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import Spinner from '../spinner/spinner.tsx'; import OfferList from '../Offer/OfferList'; -import { useAppDispatch } from '../../hooks'; -import { OfferObject,AppRoute, City, CardCssNameList, SortName} from '../../types/types'; +import { useAppDispatch,useAppSelector } from '../../hooks'; +import { AppRoute, City, CardCssNameList, SortName} from '../../types/types'; import { changeCity } from '../../action'; import { ListCities } from '../../components/CityList/CityList'; import { FilterOffer } from '../FilterOffers/FilterOffer'; +import { getLoadingOfferPage,getOffer } from '../../store/selector'; +import { fetchOfferObjectAction } from '../../api-actions.ts'; import Map from '../Map/Map'; type MainPageProps = { - offers: OfferObject[]; currentCity: City; cities: City[]; }; export const MainPage : FC = ({ - offers, currentCity, cities, }:MainPageProps) => { const navigate = useNavigate(); const dispatch = useAppDispatch(); - + const isLoading = useAppSelector(getLoadingOfferPage); + const offers = useAppSelector(getOffer); + useEffect(() => { + dispatch(fetchOfferObjectAction()); + }, [dispatch]); const handleUserSelectCity = (cityName: string) => { dispatch(changeCity(cityName)); // dispatch(fillOffers()); @@ -28,7 +33,7 @@ export const MainPage : FC = ({ const [activeOffer, setActiveOffer] = useState(null); const [sortType, setSortType] = useState(SortName.popular); - const sortedOffers = offers.filter((a) =>a.city.name === currentCity.title).slice().sort((a, b) => { + const sortedOffers = offers?.filter((a) =>a.city.name === currentCity.title).slice().sort((a, b) => { switch (sortType) { case SortName.lowToHigh: return a.price - b.price; @@ -85,18 +90,19 @@ export const MainPage : FC = ({

Places

- {offers.filter((a) =>a.city.name === currentCity.title).length} places to stay in {currentCity.title} + {offers?.filter((a) =>a.city.name === currentCity.title).length} places to stay in {currentCity.title} - {sortedOffers.length} places to stay in {currentCity.title} + {sortedOffers?.length} places to stay in {currentCity.title} - -
- a.city.name === currentCity.title)} cardcssname={CardCssNameList.citiesList} setActiveOffer={setActiveOffer}/> -
+ { isLoading ? + + : + <>
a.city.name === currentCity.title)} cardcssname={CardCssNameList.citiesList} setActiveOffer={setActiveOffer} />
+ }
- a.city.name === currentCity.title)} selectedPoint={sortedOffers[0]} activeOffer={activeOffer} currentCity={currentCity} /> + a.city.name === currentCity.title)} selectedPoint={sortedOffers?.[0]} activeOffer={activeOffer} currentCity={currentCity} />
diff --git a/src/components/Map/Map.tsx b/src/components/Map/Map.tsx index 5c64a51..ba08b28 100644 --- a/src/components/Map/Map.tsx +++ b/src/components/Map/Map.tsx @@ -21,9 +21,9 @@ const activeCustomIcon = new Icon({ iconAnchor: [20, 40] }); type MainPageProps = { - offers: OfferObject[]; + offers: OfferObject[] | undefined; currentCity: City; - selectedPoint: OfferObject; + selectedPoint: OfferObject | undefined; activeOffer: number | null; }; @@ -41,7 +41,7 @@ function Map(props: MainPageProps): JSX.Element { } }); - offers.forEach((offer) => { + offers?.forEach((offer) => { leaflet .marker({ lat: offer.location.latitude, diff --git a/src/components/Offer/Offer.tsx b/src/components/Offer/Offer.tsx index efc208a..cff7642 100644 --- a/src/components/Offer/Offer.tsx +++ b/src/components/Offer/Offer.tsx @@ -8,7 +8,7 @@ import Map from '../Map/Map'; import { AppRoute, UserReview, OfferObject, City } from '../../types/types'; type OfferProps = { reviews: UserReview[]; - offers: OfferObject[]; + offers: OfferObject[] | null; currentCity: City; }; @@ -192,10 +192,10 @@ export const Offer: React.FC = ({
diff --git a/src/components/Offer/OfferList.tsx b/src/components/Offer/OfferList.tsx index e328d81..e9c847b 100644 --- a/src/components/Offer/OfferList.tsx +++ b/src/components/Offer/OfferList.tsx @@ -4,7 +4,7 @@ import { OfferObject } from '../../types/types'; type OfferListProps = { - offers: OfferObject[]; + offers: OfferObject[] | undefined; cardcssname: string; setActiveOffer?: (id: number | null) => void; }; @@ -14,7 +14,7 @@ const OfferList = ({ offers, cardcssname,setActiveOffer}: OfferListProps) => { return (
- {offers.map((offer) => ( + {offers?.map((offer) => (
setActiveOfferId(offer.id)} diff --git a/src/components/loading-screen/loading-screen.tsx b/src/components/loading-screen/loading-screen.tsx new file mode 100644 index 0000000..8c16a23 --- /dev/null +++ b/src/components/loading-screen/loading-screen.tsx @@ -0,0 +1,8 @@ + +function LoadingScreen(): JSX.Element { + return ( +

Loading ...

+ ); +} + +export default LoadingScreen; diff --git a/src/components/spinner/spinner.modules.css b/src/components/spinner/spinner.modules.css new file mode 100644 index 0000000..dbdb43b --- /dev/null +++ b/src/components/spinner/spinner.modules.css @@ -0,0 +1,35 @@ +.spinner-container { + display: flex; +} +.loader { + width: 48px; + height: 48px; + margin-left: auto; + margin-right: auto; + border-radius: 50%; + display: inline-block; + border-top: 4px solid #000; + border-right: 4px solid transparent; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} +.loader::after { + content: ''; + box-sizing: border-box; + position: absolute; + left: 0; + top: 0; + width: 48px; + height: 48px; + border-radius: 50%; + border-bottom: 4px solid #4481c3; + border-left: 4px solid transparent; +} +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/spinner/spinner.tsx b/src/components/spinner/spinner.tsx new file mode 100644 index 0000000..831038b --- /dev/null +++ b/src/components/spinner/spinner.tsx @@ -0,0 +1,5 @@ +import './spinner.modules.css'; + +export default function Spinner(): JSX.Element { + return
; +} diff --git a/src/const.ts b/src/const.ts index 1b7e76b..485f151 100644 --- a/src/const.ts +++ b/src/const.ts @@ -3,3 +3,7 @@ export const URL_MARKER_DEFAULT = export const URL_MARKER_CURRENT = 'https://assets.htmlacademy.ru/content/intensive/javascript-1/demo/interactive-map/main-pin.svg'; + +export enum APIRoute { + Offers = '/offers', +} diff --git a/src/index.tsx b/src/index.tsx index a850ac6..15f3da6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,10 @@ import {App} from './App'; import { Provider } from 'react-redux'; import { store } from './store'; +import {fetchOfferObjectAction} from './api-actions'; + +store.dispatch(fetchOfferObjectAction); + const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); diff --git a/src/reducer.ts b/src/reducer.ts index 71c99c5..f11e7bd 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -1,8 +1,10 @@ -import { createReducer } from '@reduxjs/toolkit'; +import { combineReducers, createReducer } from '@reduxjs/toolkit'; import { City, OfferObject } from './types/types'; -import { changeCity, AddOffer } from './action'; +import { changeCity, AddOffer, loadOffers } from './action'; +import { offerPage } from './store/offer-data'; import { CITYLIST } from './mock/cities'; import { offers } from './mock/offers'; + type InitialState = { currentCity: City; cities: City[]; @@ -24,5 +26,14 @@ export const reducer = createReducer(initialState, (builder) => { }) .addCase(AddOffer, (state, action) => { state.offers = action.payload; + }) + .addCase(loadOffers, (state, action) => { + state.offers = action.payload; }); }); + +export const rootReducer = combineReducers({ + Cities: reducer, + currentCity: reducer, + offerPage: offerPage.reducer, +}); diff --git a/src/store/index.ts b/src/store/index.ts index 320da04..0532eda 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,15 @@ import { configureStore } from '@reduxjs/toolkit'; -import { reducer } from '../reducer'; +import { rootReducer } from '../reducer'; +import { createAPI } from '../api'; -export const store = configureStore({ reducer }); +export const api = createAPI(); + +export const store = configureStore({ + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + thunk: { + extraArgument: api, + }, + }), +}); diff --git a/src/store/offer-data.ts b/src/store/offer-data.ts new file mode 100644 index 0000000..841ade1 --- /dev/null +++ b/src/store/offer-data.ts @@ -0,0 +1,32 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { OfferData } from '../types/types'; +import { fetchOfferObjectAction } from '../api-actions'; + +const initialState: OfferData = { + offer: null, + offerPageStatus: false, +}; + +export const offerPage = createSlice({ + name: 'offerPage', + initialState, + reducers: { + unmountOffer: (state) => { + state.offer = null; + }, + }, + extraReducers(builder) { + builder + .addCase(fetchOfferObjectAction.pending, (state) => { + state.offerPageStatus = true; + }) + .addCase(fetchOfferObjectAction.fulfilled, (state, action) => { + state.offer = action.payload; + state.offerPageStatus = false; + }) + .addCase(fetchOfferObjectAction.rejected, (state) => { + state.offerPageStatus = false; + }); + }, +}); +export const { unmountOffer } = offerPage.actions; diff --git a/src/store/selector.ts b/src/store/selector.ts new file mode 100644 index 0000000..fd00cb0 --- /dev/null +++ b/src/store/selector.ts @@ -0,0 +1,12 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { OfferData, State } from '../types/types'; + +export const getOffer = createSelector( + (state: State) => state['offerPage'], + (state: OfferData) => state.offer +); + +export const getLoadingOfferPage = createSelector( + (state: State) => state['offerPage'], + (state: OfferData) => state.offerPageStatus +); diff --git a/src/token.ts b/src/token.ts new file mode 100644 index 0000000..ee97828 --- /dev/null +++ b/src/token.ts @@ -0,0 +1,16 @@ +const AUTH_TOKEN_KEY_NAME = 'guess-melody-token'; + +export type Token = string; + +export const getToken = (): Token => { + const token = localStorage.getItem(AUTH_TOKEN_KEY_NAME); + return token ?? ''; +}; + +export const saveToken = (token: Token): void => { + localStorage.setItem(AUTH_TOKEN_KEY_NAME, token); +}; + +export const dropToken = (): void => { + localStorage.removeItem(AUTH_TOKEN_KEY_NAME); +}; diff --git a/src/types/types.ts b/src/types/types.ts index d15929e..2b03a01 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,7 +4,10 @@ export type City = { lat: number; lng: number; }; - +export type OfferData = { + offer: OfferObject[] | null; + offerPageStatus: boolean; +}; export type Point = { title: string; lat: number;