From c42a67435d0fb00ba746b5691c7f07db351023cd Mon Sep 17 00:00:00 2001 From: Denis P <34074159+denispan@users.noreply.github.com> Date: Fri, 5 Apr 2024 13:23:31 +0200 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D1=80=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B6=D0=B0=D0=BB=D0=BE=D0=B2=D0=B0=D1=82=D1=8C,=20=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B5=D1=89=D1=91=D0=BD=20(=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=8C=C2=A01)=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * переменовывает переменные * добавляет авторизацию * фиксит ошибки --- package-lock.json | 64 +++++++++++----- package.json | 3 + src/app.tsx | 22 +++--- src/components/header/header.tsx | 67 +++++++++++++---- src/components/login-form/login-form.tsx | 59 +++++++++++++++ src/components/offer-card/offer-card.tsx | 8 +- .../offers-list-loader/offers-list-loader.tsx | 3 +- .../private-route/private-route.tsx | 12 +-- src/components/public-route/public-route.tsx | 18 +++-- src/const.ts | 22 ++++-- src/hooks/store.ts | 19 +++-- src/index.tsx | 7 ++ src/pages/login/login.tsx | 13 +--- src/pages/main/main.tsx | 6 +- src/pages/offer/offer.tsx | 31 ++++---- src/services/api.ts | 4 +- src/services/token.ts | 2 +- src/store/index.ts | 18 ++++- src/store/slices/comments.ts | 44 +++++++++++ src/store/slices/offer-full-info.ts | 44 +++++++++++ src/store/slices/offers-near.ts | 44 +++++++++++ src/store/slices/offers.ts | 38 +--------- src/store/slices/user.ts | 73 +++++++++++++++++++ src/store/thunks/offers.ts | 2 +- src/store/thunks/user.ts | 27 +++++++ src/types/user.ts | 15 ++++ 26 files changed, 523 insertions(+), 142 deletions(-) create mode 100644 src/components/login-form/login-form.tsx create mode 100644 src/store/slices/comments.ts create mode 100644 src/store/slices/offer-full-info.ts create mode 100644 src/store/slices/offers-near.ts create mode 100644 src/store/slices/user.ts create mode 100644 src/store/thunks/user.ts create mode 100644 src/types/user.ts diff --git a/package-lock.json b/package-lock.json index 3abe227..cfb2b28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,14 @@ "history": "5.3.0", "http-status-codes": "2.3.0", "leaflet": "1.7.1", + "nanoid": "5.0.3", "react": "18.2.0", "react-content-loader": "6.0.3", "react-dom": "18.2.0", "react-helmet-async": "1.3.0", + "react-redux": "8.1.3", "react-router-dom": "6.16.0", + "react-toastify": "10.0.5", "vite-plugin-rewrite-all": "1.0.2" }, "devDependencies": { @@ -1408,7 +1411,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "devOptional": true, "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -1510,14 +1512,12 @@ "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/react": { "version": "18.2.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.25.tgz", "integrity": "sha512-24xqse6+VByVLIr+xWaQ9muX1B4bXJKXBbjszbld/UEDslGLY53+ZucF44HCmLbMPejTzGG9XgR+3m2/Wqu1kw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -1548,8 +1548,7 @@ "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "devOptional": true + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, "node_modules/@types/semver": { "version": "7.5.3", @@ -1575,9 +1574,7 @@ "node_modules/@types/use-sync-external-store": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", - "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", - "optional": true, - "peer": true + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, "node_modules/@types/yargs": { "version": "17.0.28", @@ -2408,6 +2405,14 @@ "node": ">=0.8.0" } }, + "node_modules/clsx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2492,8 +2497,7 @@ "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "devOptional": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/data-urls": { "version": "4.0.0", @@ -3703,7 +3707,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "devOptional": true, "dependencies": { "react-is": "^16.7.0" } @@ -4701,6 +4704,23 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/nanoid": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.3.tgz", + "integrity": "sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5267,8 +5287,6 @@ "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", - "optional": true, - "peer": true, "dependencies": { "@babel/runtime": "^7.12.1", "@types/hoist-non-react-statics": "^3.3.1", @@ -5306,9 +5324,7 @@ "node_modules/react-redux/node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "optional": true, - "peer": true + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/react-refresh": { "version": "0.14.0", @@ -5349,6 +5365,18 @@ "react-dom": ">=16.8" } }, + "node_modules/react-toastify": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.5.tgz", + "integrity": "sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -6228,8 +6256,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "optional": true, - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } diff --git a/package.json b/package.json index 4e59f89..d196163 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,14 @@ "history": "5.3.0", "http-status-codes": "2.3.0", "leaflet": "1.7.1", + "nanoid": "5.0.3", "react": "18.2.0", "react-content-loader": "6.0.3", "react-dom": "18.2.0", "react-helmet-async": "1.3.0", + "react-redux": "8.1.3", "react-router-dom": "6.16.0", + "react-toastify": "10.0.5", "vite-plugin-rewrite-all": "1.0.2" }, "devDependencies": { diff --git a/src/app.tsx b/src/app.tsx index 45c270a..f2d1a12 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,14 +1,22 @@ import Main from './pages/main/main.tsx'; import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom'; import NotFound from './pages/not-found/not-found.tsx'; -import {AppRoute, AuthorizationStatus, CITIES, DEFAULT_CITY_SLUG} from './const.ts'; +import {AppRoute, AuthStatus, CITIES, DEFAULT_CITY_SLUG} from './const.ts'; import Favorites from './pages/favorites/favorites.tsx'; import Offer from './pages/offer/offer.tsx'; import PrivateRoute from './components/private-route/private-route.tsx'; import Login from './pages/login/login.tsx'; import PublicRoute from './components/public-route/public-route.tsx'; +import {useActionCreators, useAppSelector} from './hooks/store.ts'; +import {userActions, userSelectors} from './store/slices/user.ts'; +import {useEffect} from 'react'; function App() { + const {checkAuth} = useActionCreators(userActions); + const authStatus = useAppSelector(userSelectors.authStatus); + useEffect(() => { + checkAuth(); + }, [authStatus, checkAuth]); return ( @@ -20,7 +28,7 @@ function App() { {CITIES.map((city) => ( } /> ) @@ -28,9 +36,7 @@ function App() { + } @@ -38,16 +44,14 @@ function App() { + } /> } + element={} /> ) => { + e.preventDefault(); + await logout().unwrap().catch((error: Error) => { + toast.warning(error.message); + }); + }; + + const AuthUserComponent = ( + <> +
  • + +
    + {userInfo?.avatarUrl && avatar} +
    + {userInfo?.name && {userInfo.name}} + 3 + +
  • +
  • + { + logoutHandler(e); + }} + > + Sign out + +
  • + + ); + + const NotAuthUserComponent = ( +
  • + +
    +
    + Sign in + +
  • + ); + return (
    - +
    diff --git a/src/components/login-form/login-form.tsx b/src/components/login-form/login-form.tsx new file mode 100644 index 0000000..bfc3da5 --- /dev/null +++ b/src/components/login-form/login-form.tsx @@ -0,0 +1,59 @@ +import React, {useState} from 'react'; +import {useActionCreators} from '../../hooks/store.ts'; +import {userActions} from '../../store/slices/user.ts'; +import {toast} from 'react-toastify'; + +function LoginForm() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const {login} = useActionCreators(userActions); + + const onSubmitHandler = (e: React.FormEvent) => { + e.preventDefault(); + toast.promise(login({email, password}).unwrap(), { + pending: 'Loading', + success: 'Success', + error: 'Error', + }); + }; + + return ( +
    onSubmitHandler(e)} + className="login__form form" + action="#" + method="post" + > +
    + + setEmail(evt.target.value)} + className="login__input form__input" + type="email" + name="email" + placeholder="Email" + required + /> +
    +
    + + setPassword(evt.target.value)} + className="login__input form__input" + type="password" + name="password" + placeholder="Password" + required + /> +
    + +
    + ); +} + +export default LoginForm; diff --git a/src/components/offer-card/offer-card.tsx b/src/components/offer-card/offer-card.tsx index 52827fc..feed57b 100644 --- a/src/components/offer-card/offer-card.tsx +++ b/src/components/offer-card/offer-card.tsx @@ -37,14 +37,14 @@ function OfferCard ({offer, componentType, hoverHandler}: PlaceCardProps) { }, }; - const mouseOnHadnler = () => hoverHandler && hoverHandler(offer); - const mouseOfHadnler = () => hoverHandler && hoverHandler(null); + const mouseOnHandler = () => hoverHandler && hoverHandler(offer); + const mouseOfHandler = () => hoverHandler && hoverHandler(null); return (
    {isPremium && (
    diff --git a/src/components/offers-list-loader/offers-list-loader.tsx b/src/components/offers-list-loader/offers-list-loader.tsx index 6a1d3b7..8921a2c 100644 --- a/src/components/offers-list-loader/offers-list-loader.tsx +++ b/src/components/offers-list-loader/offers-list-loader.tsx @@ -1,10 +1,11 @@ import ContentLoader from 'react-content-loader'; import OfferLoader from '../offer-loader/offer-loader.tsx'; import {OFFERS_LOADER_COUNT} from '../../const.ts'; +import {nanoid} from 'nanoid'; function OffersListLoader () { - const offerLoaders = Array.from({length: OFFERS_LOADER_COUNT}, (i: number) => i++); + const offerLoaders = Array.from({length: OFFERS_LOADER_COUNT}, () => nanoid()); return (

    Places

    diff --git a/src/components/private-route/private-route.tsx b/src/components/private-route/private-route.tsx index 520d804..d61a158 100644 --- a/src/components/private-route/private-route.tsx +++ b/src/components/private-route/private-route.tsx @@ -1,16 +1,18 @@ -import {AppRoute, AuthorizationStatus} from '../../const.ts'; +import {AppRoute, AuthStatus} from '../../const.ts'; import {Navigate} from 'react-router-dom'; import React from 'react'; +import {useAppSelector} from '../../hooks/store.ts'; +import {userSelectors} from '../../store/slices/user.ts'; -export type PrivateRouteProps = { - authorizationStatus: AuthorizationStatus; +type PrivateRouteProps = { children: React.JSX.Element; } -function PrivateRoute({authorizationStatus, children}: PrivateRouteProps): React.JSX.Element { +function PrivateRoute({children}: PrivateRouteProps) { + const authStatus = useAppSelector(userSelectors.authStatus); return ( - authorizationStatus === AuthorizationStatus.Auth + authStatus === AuthStatus.Auth ? children : ); diff --git a/src/components/public-route/public-route.tsx b/src/components/public-route/public-route.tsx index 5faddcd..3aa9f0e 100644 --- a/src/components/public-route/public-route.tsx +++ b/src/components/public-route/public-route.tsx @@ -1,14 +1,20 @@ -import {AppRoute, AuthorizationStatus} from '../../const.ts'; +import {AppRoute, AuthStatus} from '../../const.ts'; import {Navigate} from 'react-router-dom'; import React from 'react'; -import {PrivateRouteProps} from '../private-route/private-route.tsx'; +import {useAppSelector} from '../../hooks/store.ts'; +import {userSelectors} from '../../store/slices/user.ts'; +type PublicRouteProps = { + children: React.JSX.Element; +} + +function PublicRoute({children}: PublicRouteProps): React.JSX.Element { + const authStatus = useAppSelector(userSelectors.authStatus); -function PublicRoute({authorizationStatus, children}: PrivateRouteProps): React.JSX.Element { return ( - authorizationStatus === AuthorizationStatus.NoAuth - ? children - : + authStatus === AuthStatus.Auth + ? + : children ); } diff --git a/src/const.ts b/src/const.ts index 5016b83..4193076 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,7 +1,12 @@ import {BaseIconOptions} from 'leaflet'; +const BACKEND_URL = 'https://15.design.htmlacademy.pro/six-cities'; +const REQUEST_TIMEOUT = 5000; + const OFFERS_LOADER_COUNT = 4; +const TOAST_AUTO_CLOSE_TIME = 2000; + const RATING_STARS = { 'perfect': 5, 'good': 4, @@ -22,7 +27,7 @@ const CITIES = [ {name: 'Dusseldorf', location: {latitude: 51.225402, longitude: 6.776314, zoom: 13}, slug: 'dusseldorf'}, ] as const; -const DEFAULT_CITY_SLUG = CITIES[0].name; +const DEFAULT_CITY_SLUG = CITIES[0].slug; const OFFER_TYPES = [ 'hotel', @@ -45,7 +50,7 @@ enum APIRoute { Logout = '/logout', } -const enum AuthorizationStatus { +const enum AuthStatus { Auth = 'AUTH', NoAuth = 'NO_AUTH', Unknown = 'UNKNOWN', @@ -67,19 +72,22 @@ const MARKER_ACTIVE_OPTIONS: BaseIconOptions = { }; const enum RequestStatus { - Idle = 'idle', - Loading = 'loading', - Succeed = 'succeed', - Failed = 'failed', + Idle, + Loading, + Succeed, + Failed, } export { + BACKEND_URL, + REQUEST_TIMEOUT, + TOAST_AUTO_CLOSE_TIME, OFFERS_LOADER_COUNT, RATING_STARS, OFFER_TYPES, AppRoute, APIRoute, - AuthorizationStatus, + AuthStatus, CITIES, DEFAULT_CITY_SLUG, DATE_FORMAT, diff --git a/src/hooks/store.ts b/src/hooks/store.ts index f905c20..bb98086 100644 --- a/src/hooks/store.ts +++ b/src/hooks/store.ts @@ -1,15 +1,24 @@ -import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {TypedUseSelectorHook, useDispatch, useSelector, useStore} from 'react-redux'; import {AppDispatch, RootState} from '../types/store.ts'; -import {ActionCreatorsMapObject, bindActionCreators} from '@reduxjs/toolkit'; +import {ActionCreatorsMapObject, AsyncThunk, bindActionCreators} from '@reduxjs/toolkit'; import {useMemo} from 'react'; +import {store} from '../store'; const useAppDispatch = useDispatch; const useAppSelector: TypedUseSelectorHook = useSelector; +const useAppStore: () => typeof store = useStore; -const useActionCreators = (actions: Actions) => { +const useActionCreators = (actions: Actions): BoundActions => { const dispatch = useAppDispatch(); - return useMemo(() => bindActionCreators(actions, dispatch), [actions, dispatch]); + return useMemo(() => bindActionCreators(actions, dispatch), []); }; -export {useAppDispatch, useAppSelector, useActionCreators}; +type BoundActions = { + [key in keyof Actions]: Actions[key] extends AsyncThunk ? BoundAsyncThunk : Actions[key]; +} + +type BoundAsyncThunk> = (...args: Parameters) => ReturnType>; + +export {useAppDispatch, useAppSelector, useAppStore, useActionCreators}; diff --git a/src/index.tsx b/src/index.tsx index 99bcb6f..a6daca5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'; import App from './app.tsx'; import {Provider} from 'react-redux'; import {store} from './store'; +import {ToastContainer} from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import {TOAST_AUTO_CLOSE_TIME} from './const.ts'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement @@ -11,6 +14,10 @@ const root = ReactDOM.createRoot( root.render( + diff --git a/src/pages/login/login.tsx b/src/pages/login/login.tsx index f7cf89a..54a6803 100644 --- a/src/pages/login/login.tsx +++ b/src/pages/login/login.tsx @@ -1,5 +1,6 @@ import Logo from '../../components/logo/logo.tsx'; import {useDocumentTitle} from '../../hooks/document-title.ts'; +import LoginForm from '../../components/login-form/login-form.tsx'; interface LoginProps { title?: string; @@ -24,17 +25,7 @@ function Login({title = 'Login'}: LoginProps) {

    Sign in

    -
    -
    - - -
    -
    - - -
    - -
    +
    diff --git a/src/pages/main/main.tsx b/src/pages/main/main.tsx index e8c340c..64ad5c0 100644 --- a/src/pages/main/main.tsx +++ b/src/pages/main/main.tsx @@ -8,6 +8,8 @@ import {useActionCreators, useAppSelector} from '../../hooks/store.ts'; import {offersActions, offersSelectors} from '../../store/slices/offers.ts'; import {useEffect} from 'react'; import OffersList from '../../components/offers-list/offers-list.tsx'; +import {toast} from 'react-toastify'; +import {AxiosError} from 'axios'; export type MainProps = { title?: string; @@ -22,7 +24,9 @@ function Main({title = 'Main', citySlug}: MainProps) { useEffect(() => { if (status === RequestStatus.Idle) { - fetchOffers(); + fetchOffers().unwrap().catch((err: AxiosError) => { + toast.warning(err.message); + }) ; } }, [status, fetchOffers]); diff --git a/src/pages/offer/offer.tsx b/src/pages/offer/offer.tsx index 333a305..939eb57 100644 --- a/src/pages/offer/offer.tsx +++ b/src/pages/offer/offer.tsx @@ -8,40 +8,39 @@ import {capitalizeFirstLetter} from '../../utils/common.ts'; import Host from '../../components/host/host.tsx'; import Review from '../../components/review/review.tsx'; import FormReview from '../../components/form-review/form-review.tsx'; -import {AuthorizationStatus, CITIES} from '../../const.ts'; +import {AuthStatus, CITIES} from '../../const.ts'; import Map from '../../components/map/map.tsx'; import OfferCard from '../../components/offer-card/offer-card.tsx'; import {useActionCreators, useAppSelector} from '../../hooks/store.ts'; -import {offersActions, offersSelectors} from '../../store/slices/offers.ts'; +import {offersActions} from '../../store/slices/offers.ts'; import {useEffect} from 'react'; +import {offerFullInfoActions, offerFullInfoSelectors} from '../../store/slices/offer-full-info.ts'; +import {offersNearActions, offersNearSelectors} from '../../store/slices/offers-near.ts'; +import {commentsActions, commentsSelectors} from '../../store/slices/comments.ts'; interface OfferProps { title?: string; - userAuth: AuthorizationStatus; + userAuth: AuthStatus; } function Offer({title = 'Offer', userAuth}: OfferProps) { useDocumentTitle(title); - const { - setActiveOffer, - fetchOfferFullInfo, - fetchOffersNear, - fetchComments, - } = useActionCreators(offersActions); + const {setActiveOffer} = useActionCreators(offersActions); + const {fetchOfferFullInfo} = useActionCreators(offerFullInfoActions); + const {fetchOffersNear} = useActionCreators(offersNearActions); + const {fetchComments} = useActionCreators(commentsActions); const {offerId} = useParams(); useEffect(() => { if (offerId) { - fetchOfferFullInfo(offerId); - fetchOffersNear(offerId); - fetchComments(offerId); + Promise.all([fetchOfferFullInfo(offerId), fetchOffersNear(offerId), fetchComments(offerId)]); } }, [fetchOfferFullInfo, fetchOffersNear, fetchComments, offerId]); - const offerFullInfo = useAppSelector(offersSelectors.offerFullInfo); - const offersNear = useAppSelector(offersSelectors.offersNear); - const comments = useAppSelector(offersSelectors.comments); + const offerFullInfo = useAppSelector(offerFullInfoSelectors.offerFullInfo); + const offersNear = useAppSelector(offersNearSelectors.offersNear); + const comments = useAppSelector(commentsSelectors.comments); if (!offerFullInfo) { return ; @@ -131,7 +130,7 @@ function Offer({title = 'Offer', userAuth}: OfferProps) { ) } - {userAuth === AuthorizationStatus.Auth && } + {userAuth === AuthStatus.Auth && }
    diff --git a/src/services/api.ts b/src/services/api.ts index ed05a4f..5fe8d5e 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,8 +1,6 @@ import axios, {AxiosInstance, InternalAxiosRequestConfig} from 'axios'; import {getToken} from './token.ts'; - -const BACKEND_URL = 'https://15.design.htmlacademy.pro/six-cities'; -const REQUEST_TIMEOUT = 5000; +import {BACKEND_URL, REQUEST_TIMEOUT} from '../const.ts'; export const createAPI = (): AxiosInstance => { const api = axios.create({ diff --git a/src/services/token.ts b/src/services/token.ts index 51eab7a..df439d4 100644 --- a/src/services/token.ts +++ b/src/services/token.ts @@ -1,4 +1,4 @@ -const AUTH_TOKEN_KEY_NAME = 'X-Token'; +const AUTH_TOKEN_KEY_NAME = 'six-cities-token'; export type Token = string; diff --git a/src/store/index.ts b/src/store/index.ts index 232c2ea..0b06186 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,13 +1,23 @@ -import {configureStore} from '@reduxjs/toolkit'; +import {combineReducers, configureStore} from '@reduxjs/toolkit'; import {offersSlice} from './slices/offers.ts'; import {createAPI} from '../services/api.ts'; +import {userSlice} from './slices/user.ts'; +import {offerFullInfoSlice} from './slices/offer-full-info.ts'; +import {commentsSlice} from './slices/comments.ts'; +import {offersNearSlice} from './slices/offers-near.ts'; export const api = createAPI(); +const reducer = combineReducers({ + [offersSlice.name]: offersSlice.reducer, + [offerFullInfoSlice.name]: offerFullInfoSlice.reducer, + [commentsSlice.name]: commentsSlice.reducer, + [offersNearSlice.name]: offersNearSlice.reducer, + [userSlice.name]: userSlice.reducer, +}); + export const store = configureStore({ - reducer: { - [offersSlice.name]: offersSlice.reducer - }, + reducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { diff --git a/src/store/slices/comments.ts b/src/store/slices/comments.ts new file mode 100644 index 0000000..e8a5bc4 --- /dev/null +++ b/src/store/slices/comments.ts @@ -0,0 +1,44 @@ +import {createSlice} from '@reduxjs/toolkit'; +import {RequestStatus} from '../../const.ts'; +import {fetchCommentsAction} from '../thunks/offers.ts'; +import {CommentInterface} from '../../types/comment.ts'; + +interface CommentsState { + comments: CommentInterface[]; + status: RequestStatus; +} + +const initialState: CommentsState = { + comments: [], + status: RequestStatus.Idle, +}; + +const commentsSlice = createSlice({ + name: 'comments', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchCommentsAction.pending, (state) => { + state.status = RequestStatus.Loading; + }); + builder.addCase(fetchCommentsAction.fulfilled, (state, action) => { + state.comments = action.payload; + state.status = RequestStatus.Succeed; + }); + builder.addCase(fetchCommentsAction.rejected, (state) => { + state.status = RequestStatus.Failed; + }); + }, + selectors: { + comments: (state) => state.comments, + status: (state) => state.status, + } +}); + +const commentsActions = { + ...commentsSlice.actions, + fetchComments: fetchCommentsAction, +}; +const commentsSelectors = commentsSlice.selectors; + +export { commentsSlice, commentsActions, commentsSelectors }; diff --git a/src/store/slices/offer-full-info.ts b/src/store/slices/offer-full-info.ts new file mode 100644 index 0000000..aacadcd --- /dev/null +++ b/src/store/slices/offer-full-info.ts @@ -0,0 +1,44 @@ +import {OfferFullInfo} from '../../types/offer.ts'; +import {createSlice} from '@reduxjs/toolkit'; +import {RequestStatus} from '../../const.ts'; +import {fetchOfferFullInfoAction} from '../thunks/offers.ts'; + +interface OfferFullInfoState { + offerFullInfo: OfferFullInfo | null; + status: RequestStatus; +} + +const initialState: OfferFullInfoState = { + offerFullInfo: null, + status: RequestStatus.Idle, +}; + +const offerFullInfoSlice = createSlice({ + name: 'offerFullInfo', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchOfferFullInfoAction.pending, (state) => { + state.status = RequestStatus.Loading; + }); + builder.addCase(fetchOfferFullInfoAction.fulfilled, (state, action) => { + state.offerFullInfo = action.payload; + state.status = RequestStatus.Succeed; + }); + builder.addCase(fetchOfferFullInfoAction.rejected, (state) => { + state.status = RequestStatus.Failed; + }); + }, + selectors: { + offerFullInfo: (state) => state.offerFullInfo, + status: (state) => state.status, + } +}); + +const offerFullInfoActions = { + ...offerFullInfoSlice.actions, + fetchOfferFullInfo: fetchOfferFullInfoAction, +}; +const offerFullInfoSelectors = offerFullInfoSlice.selectors; + +export { offerFullInfoSlice, offerFullInfoActions, offerFullInfoSelectors }; diff --git a/src/store/slices/offers-near.ts b/src/store/slices/offers-near.ts new file mode 100644 index 0000000..9ced920 --- /dev/null +++ b/src/store/slices/offers-near.ts @@ -0,0 +1,44 @@ +import {createSlice} from '@reduxjs/toolkit'; +import {RequestStatus} from '../../const.ts'; +import {fetchOffersNearAction} from '../thunks/offers.ts'; +import {OfferShortInfo} from '../../types/offer.ts'; + +interface OffersNearState { + offersNear: OfferShortInfo[]; + status: RequestStatus; +} + +const initialState: OffersNearState = { + offersNear: [], + status: RequestStatus.Idle, +}; + +const offersNearSlice = createSlice({ + name: 'offersNear', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchOffersNearAction.pending, (state) => { + state.status = RequestStatus.Loading; + }); + builder.addCase(fetchOffersNearAction.fulfilled, (state, action) => { + state.offersNear = action.payload; + state.status = RequestStatus.Succeed; + }); + builder.addCase(fetchOffersNearAction.rejected, (state) => { + state.status = RequestStatus.Failed; + }); + }, + selectors: { + offersNear: (state) => state.offersNear, + status: (state) => state.status, + } +}); + +const offersNearActions = { + ...offersNearSlice.actions, + fetchOffersNear: fetchOffersNearAction, +}; +const offersNearSelectors = offersNearSlice.selectors; + +export { offersNearSlice, offersNearActions, offersNearSelectors }; diff --git a/src/store/slices/offers.ts b/src/store/slices/offers.ts index 3a840fb..84bba16 100644 --- a/src/store/slices/offers.ts +++ b/src/store/slices/offers.ts @@ -1,41 +1,26 @@ -import {OfferFullInfo, OfferShortInfo} from '../../types/offer.ts'; +import {OfferShortInfo} from '../../types/offer.ts'; import {createSlice, PayloadAction} from '@reduxjs/toolkit'; -import {AuthorizationStatus, RequestStatus} from '../../const.ts'; -import {fetchCommentsAction, fetchOfferByIdAction, fetchOffersAction, fetchOffersNearAction} from '../thunks/offers.ts'; -import {CommentInterface} from '../../types/comment.ts'; +import {RequestStatus} from '../../const.ts'; +import {fetchOffersAction} from '../thunks/offers.ts'; interface OffersState { offers: OfferShortInfo[]; - offerFullInfo: OfferFullInfo | null; - offersNear: OfferShortInfo[]; offersFavorites: OfferShortInfo[]; activeOffer: OfferShortInfo | null; - authorizationStatus: AuthorizationStatus; status: RequestStatus; - comments: CommentInterface[]; } const initialState: OffersState = { offers: [], - offerFullInfo: null, - offersNear: [], offersFavorites: [], activeOffer: null, - authorizationStatus: AuthorizationStatus.Unknown, status: RequestStatus.Idle, - comments: [], }; const offersSlice = createSlice({ name: 'offers', initialState, reducers: { - requireAuthorization: (state, action: PayloadAction) => { - state.authorizationStatus = action.payload; - }, - setRequestStatus: (state, action: PayloadAction) => { - state.status = action.payload; - }, setActiveOffer: (state, action: PayloadAction) => { state.activeOffer = action.payload; }, @@ -51,32 +36,17 @@ const offersSlice = createSlice({ builder.addCase(fetchOffersAction.rejected, (state) => { state.status = RequestStatus.Failed; }); - builder.addCase(fetchOfferByIdAction.fulfilled, (state, action) => { - state.offerFullInfo = action.payload; - }); - builder.addCase(fetchOffersNearAction.fulfilled, (state, action) => { - state.offersNear = action.payload; - }); - builder.addCase(fetchCommentsAction.fulfilled, (state, action) => { - state.comments = action.payload; - }); }, selectors: { offers: (state) => state.offers, - offerFullInfo: (state) => state.offerFullInfo, - offersNear: (state) => state.offersNear, - status: (state) => state.status, activeOffer: (state) => state.activeOffer, - comments: (state) => state.comments, + status: (state) => state.status, } }); const offersActions = { ...offersSlice.actions, fetchOffers: fetchOffersAction, - fetchOfferFullInfo: fetchOfferByIdAction, - fetchOffersNear: fetchOffersNearAction, - fetchComments: fetchCommentsAction, }; const offersSelectors = offersSlice.selectors; diff --git a/src/store/slices/user.ts b/src/store/slices/user.ts new file mode 100644 index 0000000..0e3ad3a --- /dev/null +++ b/src/store/slices/user.ts @@ -0,0 +1,73 @@ +import {createSlice} from '@reduxjs/toolkit'; +import {AuthStatus, RequestStatus} from '../../const.ts'; +import {checkAuthAction, loginAction, logoutAction} from '../thunks/user.ts'; +import {UserAuthData, UserInfo} from '../../types/user.ts'; +import {dropToken, saveToken} from '../../services/token.ts'; + +interface UserState { + authStatus: AuthStatus; + requestStatus: RequestStatus; + userInfo: UserInfo | null; + userAuthData: UserAuthData | null; +} + +const initialState: UserState = { + authStatus: AuthStatus.Unknown, + requestStatus: RequestStatus.Idle, + userInfo: null, + userAuthData: null, +}; + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(checkAuthAction.fulfilled, (state, action) => { + state.userInfo = action.payload; + state.authStatus = AuthStatus.Auth; + state.requestStatus = RequestStatus.Succeed; + }); + builder.addCase(checkAuthAction.rejected, (state) => { + state.authStatus = AuthStatus.NoAuth; + state.requestStatus = RequestStatus.Failed; + }); + builder.addCase(loginAction.fulfilled, (state, action) => { + state.userInfo = action.payload; + saveToken(state.userInfo.token); + state.authStatus = AuthStatus.Auth; + state.requestStatus = RequestStatus.Succeed; + }); + builder.addCase(loginAction.rejected, (state) => { + state.authStatus = AuthStatus.NoAuth; + state.requestStatus = RequestStatus.Failed; + }); + builder.addCase(logoutAction.fulfilled, (state) => { + dropToken(); + state.userInfo = null; + state.authStatus = AuthStatus.NoAuth; + state.requestStatus = RequestStatus.Succeed; + }); + builder.addCase(logoutAction.rejected, (state) => { + state.userInfo = null; + state.authStatus = AuthStatus.NoAuth; + state.requestStatus = RequestStatus.Failed; + }); + }, + selectors: { + authStatus: (state) => state.authStatus, + requestStatus: (state) => state.requestStatus, + userInfo: (state) => state.userInfo, + userAuthData: (state) => state.userAuthData, + } +}); + +const userActions = { + ...userSlice.actions, + checkAuth: checkAuthAction, + login: loginAction, + logout: logoutAction, +}; +const userSelectors = userSlice.selectors; + +export { userSlice, userActions, userSelectors }; diff --git a/src/store/thunks/offers.ts b/src/store/thunks/offers.ts index 2a33842..4e2f468 100644 --- a/src/store/thunks/offers.ts +++ b/src/store/thunks/offers.ts @@ -12,7 +12,7 @@ export const fetchOffersAction = createAsyncThunk( +export const fetchOfferFullInfoAction = createAsyncThunk( 'data/fetchOfferFullInfo', async (offerId, {extra: api}) => { const {data} = await api.get(`${APIRoute.Offers}/${offerId}`); diff --git a/src/store/thunks/user.ts b/src/store/thunks/user.ts new file mode 100644 index 0000000..9bc4c73 --- /dev/null +++ b/src/store/thunks/user.ts @@ -0,0 +1,27 @@ +import {createAsyncThunk} from '@reduxjs/toolkit'; +import {ThunkApi} from '../../types/store.ts'; +import {APIRoute} from '../../const.ts'; +import {UserAuthData, UserInfo} from '../../types/user.ts'; + +export const checkAuthAction = createAsyncThunk( + 'user/checkAuth', + async (_arg, {extra: api}) => { + const {data} = await api.get(APIRoute.Login); + return data; + }, +); + +export const loginAction = createAsyncThunk( + 'user/login', + async (body, {extra: api}) => { + const {data} = await api.post(APIRoute.Login, body); + return data; + }, +); + +export const logoutAction = createAsyncThunk( + 'user/logout', + async (_arg, {extra: api}) => { + await api.delete(APIRoute.Logout); + }, +); diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..11e1670 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,15 @@ +type UserInfo = { + name: string; + avatarUrl: string; + isPro: boolean; + email: string; + token: string; +} + +type UserAuthData = { + email: string; + password: string; +} + + +export type {UserAuthData, UserInfo};