diff --git a/src/browser-history.ts b/src/browser-history.ts new file mode 100644 index 0000000..9ee0dfe --- /dev/null +++ b/src/browser-history.ts @@ -0,0 +1,5 @@ +import {createBrowserHistory} from 'history'; + +const browserHistory = createBrowserHistory(); + +export default browserHistory; diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 87bd2cd..a430aaf 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -1,5 +1,5 @@ import MainScreen from '../../pages/main-screen/main-screen'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router-dom'; import LoginScreen from '../../pages/login-screen/login-screen.tsx'; import FavoritesScreen from '../../pages/favorites-screen/favorites-screen.tsx'; import NotFoundScreen from '../../pages/not-found-screen/not-found-screen.tsx'; @@ -8,35 +8,37 @@ import PrivateRoute from '../private-route/private-route.tsx'; import { AppRoute, AuthorizationStatus } from '../../const.ts'; import { useAppSelector } from '../../hooks/index.ts'; import LoadingScreen from '../../pages/loading-screen/loading-screen.tsx'; +import HistoryRouter from '../history-route/history-route.tsx'; +import browserHistory from '../../browser-history.ts'; function App(): JSX.Element { - const isQuestionsDataLoading = useAppSelector((state) => state.isQuestionsDataLoading); + const isOffersDataLoading = useAppSelector((state) => state.isOffersDataLoading); + const authorizationStatus = useAppSelector((state) => state.authorizationStatus); - if (isQuestionsDataLoading) { + if (isOffersDataLoading || authorizationStatus === AuthorizationStatus.Unknown) { return ( ); } - return ( - + - } /> - } /> + } /> + } /> + } /> - } /> - } /> + } /> + } /> - + ); } export default App; diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx new file mode 100644 index 0000000..aae49be --- /dev/null +++ b/src/components/header/header.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router-dom'; +import LoginNavigation from '../login-navigation/login-navigation'; +import { AppRoute } from '../../const'; + +function Header(): JSX.Element { + return ( +
+
+
+
+ + 6 cities logo + +
+ +
+
+
+ ); +} + +export default Header; + diff --git a/src/components/history-route/history-route.tsx b/src/components/history-route/history-route.tsx new file mode 100644 index 0000000..509a2e2 --- /dev/null +++ b/src/components/history-route/history-route.tsx @@ -0,0 +1,35 @@ +import {useState, useLayoutEffect} from 'react'; +import {Router} from 'react-router-dom'; +import type {BrowserHistory} from 'history'; + +export interface HistoryRouterProps { + history: BrowserHistory; + basename?: string; + children?: React.ReactNode; +} + +function HistoryRouter({ + basename, + children, + history, +}: HistoryRouterProps) { + const [state, setState] = useState({ + action: history.action, + location: history.location, + }); + + useLayoutEffect(() => history.listen(setState), [history]); + + return ( + + {children} + + ); +} + +export default HistoryRouter; diff --git a/src/components/login-navigation/login-navigation.tsx b/src/components/login-navigation/login-navigation.tsx new file mode 100644 index 0000000..68593fe --- /dev/null +++ b/src/components/login-navigation/login-navigation.tsx @@ -0,0 +1,49 @@ +import { Link } from 'react-router-dom'; +import { logoutAction } from '../../store/api-actions'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import { AppRoute, AuthorizationStatus } from '../../const'; + +function HeaderNavigation(): JSX.Element { + const dispatch = useAppDispatch(); + const offers = useAppSelector((state) => state.offers); + const favoriteOffers = offers.filter((offer) => offer.isFavorite); + const userData = useAppSelector((state) => state.userData); + const authorizationStatus = useAppSelector((state) => state.authorizationStatus); + return ( + + ); +} + +export default HeaderNavigation; diff --git a/src/components/private-route/private-route.tsx b/src/components/private-route/private-route.tsx index 36c4c44..e2afd3c 100644 --- a/src/components/private-route/private-route.tsx +++ b/src/components/private-route/private-route.tsx @@ -1,13 +1,14 @@ import {Navigate} from 'react-router-dom'; import { AppRoute, AuthorizationStatus } from '../../const'; +import { useAppSelector } from '../../hooks'; type PrivateRouteProps = { - authorizationStatus: AuthorizationStatus; children: JSX.Element; } function PrivateRoute(props: PrivateRouteProps): JSX.Element { - const {authorizationStatus, children} = props; + const {children} = props; + const authorizationStatus = useAppSelector((state) => state.authorizationStatus); return ( authorizationStatus === AuthorizationStatus.Auth ? children : ); diff --git a/src/const.ts b/src/const.ts index 361c404..daaa5fb 100644 --- a/src/const.ts +++ b/src/const.ts @@ -21,6 +21,8 @@ export const URL_MARKER_STANDART = export enum APIRoute { Offers = '/offers', + Login = '/login', + Logout = '/logout' } export const CITIES: City[] = [ @@ -29,7 +31,7 @@ export const CITIES: City[] = [ location: { latitude: 48.864716, longitude: 2.349014, - zoom: 11, + zoom: 13, }, }, { @@ -37,7 +39,7 @@ export const CITIES: City[] = [ location: { latitude: 50.85034, longitude: 4.35171, - zoom: 11, + zoom: 13, }, }, { @@ -45,7 +47,7 @@ export const CITIES: City[] = [ location: { latitude: 50.935173, longitude: 6.953101, - zoom: 11, + zoom: 13, }, }, { @@ -53,7 +55,7 @@ export const CITIES: City[] = [ location: { latitude: 52.3740300, longitude: 4.8896900, - zoom: 11, + zoom: 13, }, }, { @@ -61,7 +63,7 @@ export const CITIES: City[] = [ location: { latitude: 53.551086, longitude: 9.993682, - zoom: 11, + zoom: 13, }, }, { @@ -69,7 +71,7 @@ export const CITIES: City[] = [ location: { latitude: 51.233334, longitude: 6.783333, - zoom: 11, + zoom: 13, }, }, ]; diff --git a/src/index.tsx b/src/index.tsx index 6a68b8c..249ff5c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,10 +3,11 @@ import ReactDOM from 'react-dom/client'; import App from './components/app/app'; import { Provider } from 'react-redux'; import { store } from './store'; -import { fetchOffersAction } from './store/api-actions'; +import { checkAuthAction, fetchOffersAction } from './store/api-actions'; import ErrorMessage from './components/error-message/error-message'; store.dispatch(fetchOffersAction()); +store.dispatch(checkAuthAction()); const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement diff --git a/src/pages/favorites-screen/favorites-screen.tsx b/src/pages/favorites-screen/favorites-screen.tsx index 4cb10d1..ef1aa4e 100644 --- a/src/pages/favorites-screen/favorites-screen.tsx +++ b/src/pages/favorites-screen/favorites-screen.tsx @@ -2,41 +2,15 @@ import { Link } from 'react-router-dom'; import { typeOfCardList } from '../../utils'; import OfferList from '../../components/offer-list/offer-list'; import { useAppSelector } from '../../hooks'; +import Header from '../../components/header/header'; +import { AppRoute } from '../../const'; function FavoritesScreen(): JSX.Element { const favoriteOffers = useAppSelector((state) => state.offers).filter((offer) => offer.isFavorite); return (
-
-
- -
-
- +
@@ -57,8 +31,8 @@ function FavoritesScreen(): JSX.Element {
- - 6 cities logo + + 6 cities logo
diff --git a/src/pages/login-screen/login-screen.tsx b/src/pages/login-screen/login-screen.tsx index 5494fd4..ceb6c21 100644 --- a/src/pages/login-screen/login-screen.tsx +++ b/src/pages/login-screen/login-screen.tsx @@ -1,4 +1,26 @@ +import { FormEvent, useRef } from 'react'; +import { AppRoute } from '../../const'; +import { loginAction } from '../../store/api-actions'; +import { useAppDispatch } from '../../hooks'; +import { Link } from 'react-router-dom'; + function LoginScreen(): JSX.Element { + const emailRef = useRef(null); + const passwordRef = useRef(null); + + const dispatch = useAppDispatch(); + + const handleSubmit = (evt: FormEvent) => { + evt.preventDefault(); + if (emailRef.current !== null && passwordRef.current !== null) { + dispatch( + loginAction({ + email: emailRef.current.value, + password: passwordRef.current.value + }) + ); + } + }; return (
@@ -6,7 +28,9 @@ function LoginScreen(): JSX.Element { @@ -17,14 +41,14 @@ function LoginScreen(): JSX.Element {

Sign in

-
+
- +
- +
diff --git a/src/pages/main-screen/main-screen.tsx b/src/pages/main-screen/main-screen.tsx index 5261bfe..373b1e9 100644 --- a/src/pages/main-screen/main-screen.tsx +++ b/src/pages/main-screen/main-screen.tsx @@ -1,50 +1,19 @@ import OfferList from '../../components/offer-list/offer-list'; -import { Link } from 'react-router-dom'; import Map from '../../components/map/map'; import { typeOfCardList } from '../../utils'; import { useAppSelector } from '../../hooks'; import CityList from '../../components/city-list/city-list'; import CardsSortingOptions from '../../components/cards-sorting-options/cards-sorting-options'; +import Header from '../../components/header/header'; function MainScreen(): JSX.Element { const city = useAppSelector((state) => state.city); const offers = useAppSelector((state) => state.offers); const chosenOffers = offers.filter((offer) => offer.city.name === city.name); const points = chosenOffers.map((offer) => offer.location); - const favoriteOffers = offers.filter((offer) => offer.isFavorite); return (
-
- -
- +

Cities

diff --git a/src/pages/offer-screen/offer-screen.tsx b/src/pages/offer-screen/offer-screen.tsx index 562c593..b9c3d74 100644 --- a/src/pages/offer-screen/offer-screen.tsx +++ b/src/pages/offer-screen/offer-screen.tsx @@ -1,68 +1,39 @@ -import { Link } from 'react-router-dom'; import ReviewsList from '../../components/reviews-list/reviews-list'; import Map from '../../components/map/map'; import OfferList from '../../components/offer-list/offer-list'; import { ratingPercentage, typeOfCardList } from '../../utils'; import { useAppSelector } from '../../hooks'; import { REVIEWS } from '../../mocks/reviews'; +import Header from '../../components/header/header'; function OfferScreen(): JSX.Element { - const [offer, offers] = useAppSelector((state) => [state.chosenOffer, state.offers]); + const offer = useAppSelector((state) => state.chosenOffer); + const offers = useAppSelector((state) => state.offers); return (
-
- -
- +
- Photo studio + Photo studio
- Photo studio + Photo studio
- Photo studio + Photo studio
- Photo studio + Photo studio
- Photo studio + Photo studio
- Photo studio + Photo studio
@@ -145,7 +116,7 @@ function OfferScreen(): JSX.Element {

Meet the host

- Host avatar + Host avatar
Angelina @@ -164,13 +135,13 @@ function OfferScreen(): JSX.Element {
- nearOffer.location)}/> + nearOffer.location)} />

Other places in the neighbourhood

- +
diff --git a/src/store/action.ts b/src/store/action.ts index 366ca6f..e49c3af 100644 --- a/src/store/action.ts +++ b/src/store/action.ts @@ -2,6 +2,8 @@ import { createAction } from '@reduxjs/toolkit'; import { City } from '../types/location'; import { Point } from '../types/location'; import { Offer } from '../types/offer'; +import { AppRoute, AuthorizationStatus } from '../const'; +import { UserData } from '../types/user-data'; export const changeCity = createAction('CITY_CHANGE', (value: City) => ({ payload: value @@ -23,10 +25,22 @@ export const changeChosenOffer = createAction('CHANGE_CHOSEN_OFFER', (value:Offe payload: value })); -export const setQuestionsDataLoadingStatus = createAction('SET_QUESTIONS_DATA_LOADING_STATUS', (value: boolean) => ({ +export const setOffersDataLoadingStatus = createAction('SET_OFFERS_DATA_LOADING_STATUS', (value: boolean) => ({ payload: value })); export const setError = createAction('SET_ERROR', (value: string | null) => ({ payload: value })); + +export const requireAuthorization = createAction('REQUIRE_AUTHORIZATION', (value: AuthorizationStatus) => ({ + payload: value +})); + +export const loadUserData = createAction('LOAD_USER_DATA', (value: UserData) => ({ + payload: value +})); + +export const redirectToRoute = createAction('REDIRECT_TO_ROUTE', (value: AppRoute) => ({ + payload: value +})); diff --git a/src/store/api-actions.ts b/src/store/api-actions.ts index 4cffb9f..c407edc 100644 --- a/src/store/api-actions.ts +++ b/src/store/api-actions.ts @@ -1,11 +1,13 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { AppDispatch, State } from '../types/state'; import { AxiosInstance } from 'axios'; -import { loadOffers, setError, setQuestionsDataLoadingStatus } from './action'; +import { loadOffers, loadUserData, redirectToRoute, requireAuthorization, setError, setOffersDataLoadingStatus } from './action'; import { Offer } from '../types/offer'; -import { APIRoute, TIMEOUT_SHOW_ERROR } from '../const'; - -import {store} from './'; +import { APIRoute, AppRoute, AuthorizationStatus, TIMEOUT_SHOW_ERROR } from '../const'; +import { store } from './'; +import { AuthData } from '../types/auth-data'; +import { UserData } from '../types/user-data'; +import { dropToken, saveToken } from '../services/token'; export const clearErrorAction = createAsyncThunk( 'CLEAR_ERROR_ACTION', @@ -18,15 +20,59 @@ export const clearErrorAction = createAsyncThunk( ); export const fetchOffersAction = createAsyncThunk( - 'FETCH_OFFERS_ACTION', - async (_arg, {dispatch, extra: api}) => { - dispatch(setQuestionsDataLoadingStatus(true)); - const {data} = await api.get(APIRoute.Offers); - dispatch(setQuestionsDataLoadingStatus(false)); - dispatch(loadOffers(data)); - }, - ); + dispatch: AppDispatch; + state: State; + extra: AxiosInstance; +}>( + 'FETCH_OFFERS_ACTION', + async (_arg, { dispatch, extra: api }) => { + dispatch(setOffersDataLoadingStatus(true)); + const { data } = await api.get(APIRoute.Offers); + dispatch(setOffersDataLoadingStatus(false)); + dispatch(loadOffers(data)); + }, +); + +export const checkAuthAction = createAsyncThunk( + 'CHECK_AUTH_ACTION', + async (_arg, { dispatch, extra: api }) => { + try { + await api.get(APIRoute.Login); + dispatch(requireAuthorization(AuthorizationStatus.Auth)); + } catch { + dispatch(requireAuthorization(AuthorizationStatus.NoAuth)); + } + }, +); + +export const loginAction = createAsyncThunk( + 'LOGIN_ACTION', + async ({ email, password }, { dispatch, extra: api }) => { + const { data } = await api.post(APIRoute.Login, { email, password }); + saveToken(data.token); + dispatch(requireAuthorization(AuthorizationStatus.Auth)); + dispatch(loadUserData(data)); + dispatch(redirectToRoute(AppRoute.Main)); + }, +); + +export const logoutAction = createAsyncThunk( + 'LOGOUT_ACTION', + async (_arg, { dispatch, extra: api }) => { + await api.delete(APIRoute.Logout); + dropToken(); + dispatch(requireAuthorization(AuthorizationStatus.NoAuth)); + }, +); diff --git a/src/store/index.ts b/src/store/index.ts index de49794..b6f6ecd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { reducer } from './reducer'; import { createAPI } from '../services/api'; +import { redirect } from './middlewares/redirect'; export const api = createAPI(); @@ -11,5 +12,5 @@ export const store = configureStore({ thunk: { extraArgument: api, }, - }) + }).concat(redirect), }); diff --git a/src/store/middlewares/redirect.ts b/src/store/middlewares/redirect.ts new file mode 100644 index 0000000..955a510 --- /dev/null +++ b/src/store/middlewares/redirect.ts @@ -0,0 +1,17 @@ +import {PayloadAction} from '@reduxjs/toolkit'; +import browserHistory from '../../browser-history'; +import {Middleware} from 'redux'; +import {reducer} from '../reducer'; + +type Reducer = ReturnType; + +export const redirect: Middleware = + () => + (next) => + (action: PayloadAction) => { + if (action.type === 'REDIRECT_TO_ROUTE') { + browserHistory.push(action.payload); + } + + return next(action); + }; diff --git a/src/store/reducer.ts b/src/store/reducer.ts index 70300df..9110982 100644 --- a/src/store/reducer.ts +++ b/src/store/reducer.ts @@ -1,10 +1,11 @@ import { createReducer } from '@reduxjs/toolkit'; import { City } from '../types/location'; import { Offer } from '../types/offer'; -import { changeChosenOffer, changeCity, changeHighlightedMarker, changeSortOptions, loadOffers, setError, setQuestionsDataLoadingStatus } from './action'; +import { changeChosenOffer, changeCity, changeHighlightedMarker, changeSortOptions, loadOffers, loadUserData, requireAuthorization, setError, setOffersDataLoadingStatus } from './action'; import { filters } from '../utils'; import { Point } from '../types/location'; -import { CITIES } from '../const'; +import { AuthorizationStatus, CITIES } from '../const'; +import { UserData } from '../types/user-data'; type StateType = { @@ -12,9 +13,11 @@ type StateType = { offers: Offer[]; sortType: string; highlightedMarker?: Point; - chosenOffer: Offer | undefined; - isQuestionsDataLoading: boolean; + chosenOffer?: Offer; + isOffersDataLoading: boolean; error: string | null; + authorizationStatus: AuthorizationStatus; + userData?: UserData; } const initialState: StateType = { @@ -23,8 +26,10 @@ const initialState: StateType = { sortType: filters.POPULAR, highlightedMarker: undefined, chosenOffer: undefined, - isQuestionsDataLoading: false, - error: null + isOffersDataLoading: false, + error: null, + authorizationStatus: AuthorizationStatus.Unknown, + userData: undefined }; const reducer = createReducer(initialState, (builder) => { @@ -44,11 +49,17 @@ const reducer = createReducer(initialState, (builder) => { .addCase(changeChosenOffer, (state, action) => { state.chosenOffer = action.payload; }) - .addCase(setQuestionsDataLoadingStatus, (state, action) => { - state.isQuestionsDataLoading = action.payload; + .addCase(setOffersDataLoadingStatus, (state, action) => { + state.isOffersDataLoading = action.payload; }) .addCase(setError, (state, action) => { state.error = action.payload; + }) + .addCase(requireAuthorization, (state, action) => { + state.authorizationStatus = action.payload; + }) + .addCase(loadUserData, (state, action) => { + state.userData = action.payload; }); }); diff --git a/src/types/auth-data.ts b/src/types/auth-data.ts new file mode 100644 index 0000000..545c1e1 --- /dev/null +++ b/src/types/auth-data.ts @@ -0,0 +1,4 @@ +export type AuthData = { + email: string; + password: string; + }; diff --git a/src/types/user-data.ts b/src/types/user-data.ts new file mode 100644 index 0000000..b9b39ed --- /dev/null +++ b/src/types/user-data.ts @@ -0,0 +1,7 @@ +export type UserData = { + name: string; + email: string; + token: string; + avatarUrl: string; + isPro: boolean; + };