Repository: htmlacademy-react/2609653-six-cities-3
Pull request #11: Оптимизируй это (часть 1)
Author: login: volgavr
Created: 2025-05-20T14:04:54Z | Merged: 2025-05-23T07:01:58Z

- other; added marker highlighting
- fixed error toast dismissing

---
:mortar_board: [Оптимизируй это (часть 1)](https://up.htmlacademy.ru/react-individual/3/user/2609653/tasks/14)


Diff:
diff --git a/index.html b/index.html
index 3e079c4..98b4662 100644
--- a/index.html
+++ b/index.html
@@ -8,7 +8,6 @@
   <title>6 cities</title>
   <link rel="stylesheet" href="css/main.css">
 </head>
-
 <body>
   <div style="display: none">
     <svg xmlns="http://www.w3.org/2000/svg">
diff --git a/src/components/App.tsx b/src/components/App.tsx
index e99f304..daea6c2 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -9,12 +9,13 @@ import PrivateRoute from './Routes/PrivateRoute';
 import { AppRoute } from '../const';
 import { useAppDispatch, useAppSelector } from '../hooks';
 import { useEffect } from 'react';
-import { checkAuthAction } from '../store/api-actions';
+import { checkAuthAction } from '../store/thunks/user-actions';
 import HistoryRouter from './HistoryRouter';
 import browserHistory from '../browser-history';
+import { getAuthorizationStatus } from '../store/selectors';
 
 function App() {
-  const authStatus = useAppSelector((state) => state.authorizationStatus);
+  const authStatus = useAppSelector(getAuthorizationStatus);
   const dispatch = useAppDispatch();
 
   useEffect(
diff --git a/src/components/CityList/CityList.tsx b/src/components/CityList/CityList.tsx
index 74418b6..e9209ab 100644
--- a/src/components/CityList/CityList.tsx
+++ b/src/components/CityList/CityList.tsx
@@ -1,13 +1,14 @@
+import React from 'react';
 import { useAppDispatch } from '../../hooks';
-import { selectCity } from '../../store/action';
 import { City } from '../../types/city';
+import { selectCity } from '../../store/mainScreenSlice';
 
 type CityListProps = {
     items: City[];
     cityName?: string;
 };
 
-export default function CityList({ items, cityName }: CityListProps) {
+function CityList({ items, cityName }: CityListProps) {
   const dispatch = useAppDispatch();
 
   if (items.length === 0) {
@@ -28,3 +29,5 @@ export default function CityList({ items, cityName }: CityListProps) {
       }
     </ul>);
 }
+
+export default React.memo(CityList);
diff --git a/src/components/Layout/Error/ErrorMessage.tsx b/src/components/Layout/Error/ErrorMessage.tsx
index 8501e2c..1a4181c 100644
--- a/src/components/Layout/Error/ErrorMessage.tsx
+++ b/src/components/Layout/Error/ErrorMessage.tsx
@@ -1,8 +1,9 @@
 import { useAppSelector } from '../../../hooks';
+import { getErrorText } from '../../../store/selectors';
 import './ErrorMessage.css';
 
 function ErrorMessage(): JSX.Element | null {
-  const error = useAppSelector((state) => state.error);
+  const error = useAppSelector(getErrorText);
 
   return (error)
     ? <div className='error-message'>{error}</div>
diff --git a/src/components/Layout/Header/Avatar.tsx b/src/components/Layout/Header/Avatar.tsx
new file mode 100644
index 0000000..51de674
--- /dev/null
+++ b/src/components/Layout/Header/Avatar.tsx
@@ -0,0 +1,7 @@
+type HeaderAvatarProps = {
+  src?: string;
+}
+
+export default function Avatar({ src }: HeaderAvatarProps) {
+  return (<div className="header__avatar-wrapper user__avatar-wrapper" style={{backgroundImage: src && `url(${src})` }}></div>);
+}
diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx
index 81eb96b..e73c3e7 100644
--- a/src/components/Layout/Header/Header.tsx
+++ b/src/components/Layout/Header/Header.tsx
@@ -1,11 +1,13 @@
 import { Link, useLocation } from 'react-router-dom';
 import { useAppDispatch, useAppSelector } from '../../../hooks';
 import { AppRoute, AuthorizationStatus } from '../../../const';
-import { logoutAction } from '../../../store/api-actions';
-import { getUserEmail } from '../../../services/userData';
+import { logoutAction } from '../../../store/thunks/user-actions';
+import { getUserEmail, getUserImageUrl } from '../../../services/userData';
+import { getAuthorizationStatus } from '../../../store/selectors';
+import Avatar from './Avatar';
 
-export default function Header() {
-  const authStatus = useAppSelector((state) => state.authorizationStatus);
+function Header() {
+  const authStatus = useAppSelector(getAuthorizationStatus);
   const location = useLocation();
   const dispatch = useAppDispatch();
 
@@ -18,21 +20,21 @@ export default function Header() {
               <img className="header__logo" src="img/logo.svg" alt="6 cities logo" width="81" height="41" />
             </Link>
           </div>
-          {location.pathname !== AppRoute.Login.toString() &&
+          {location.pathname !== (AppRoute.Login as string) &&
           <nav className="header__nav">
             {authStatus === AuthorizationStatus.Auth ?
               <ul className="header__nav-list">
                 <li className="header__nav-item user">
                   <a className="header__nav-link header__nav-link--profile" href="#">
-                    <div className="header__avatar-wrapper user__avatar-wrapper">
-                    </div>
+                    <Avatar src={getUserImageUrl()} />
                     <span className="header__user-name user__name">{getUserEmail()}</span>
                     <span className="header__favorite-count">0</span>
                   </a>
                 </li>
                 <li className="header__nav-item">
                   <a className="header__nav-link" href="#" onClick={(evt) => {
-                    evt.preventDefault(); dispatch(logoutAction());
+                    evt.preventDefault();
+                    dispatch(logoutAction());
                   }}
                   >
                     <span className="header__signout">Sign out</span>
@@ -54,3 +56,5 @@ export default function Header() {
     </header>
   );
 }
+
+export default Header;
diff --git a/src/components/Layout/PageLayout.tsx b/src/components/Layout/PageLayout.tsx
index 2855131..2845685 100644
--- a/src/components/Layout/PageLayout.tsx
+++ b/src/components/Layout/PageLayout.tsx
@@ -1,9 +1,11 @@
-import { Outlet } from 'react-router-dom';
+import { Outlet, useLocation } from 'react-router-dom';
 import Header from './Header/Header';
+import { AppRoute } from '../../const';
 
 function PageLayout() {
+  const location = useLocation();
   return(
-    <div className="page page--gray page--main">
+    <div className={`page page--gray ${location.pathname === AppRoute.Login as string ? 'page--login' : 'page--main'}`}>
       <Header />
       <Outlet />
     </div>
diff --git a/src/components/LoadingFallback.tsx b/src/components/LoadingFallback.tsx
new file mode 100644
index 0000000..cea0aa4
--- /dev/null
+++ b/src/components/LoadingFallback.tsx
@@ -0,0 +1,24 @@
+import { useAppDispatch } from '../hooks';
+import { fetchOffersAction } from '../store/thunks/offer-actions';
+
+
+function LoadingFallback(): JSX.Element {
+  const dispatch = useAppDispatch();
+
+  return (
+    <>
+      <p className="error__text">Не удалось загрузить вопросы</p>
+      <button
+        onClick={() => {
+          dispatch(fetchOffersAction());
+        }}
+        className="replay replay--error"
+        type="button"
+      >
+                Попробовать ещё раз
+      </button>
+    </>
+  );
+}
+
+export default LoadingFallback;
diff --git a/src/components/OfferCard/OfferCard.tsx b/src/components/OfferCard/OfferCard.tsx
index 5c908f9..cae0a50 100644
--- a/src/components/OfferCard/OfferCard.tsx
+++ b/src/components/OfferCard/OfferCard.tsx
@@ -1,10 +1,11 @@
 import { Link } from 'react-router-dom';
 import { MAX_RATING_VALUE } from '../../const';
 import { Offer } from '../../types/offer';
+import React from 'react';
 
 export type OfferCardProps = Offer;
 
-export default function OfferCard(props: OfferCardProps) {
+function OfferCard(props: OfferCardProps) {
   return (
     <>
       {props.isPremium &&
@@ -43,3 +44,5 @@ export default function OfferCard(props: OfferCardProps) {
     </>
   );
 }
+
+export default React.memo(OfferCard);
diff --git a/src/components/OfferList/OfferList.tsx b/src/components/OfferList/OfferList.tsx
index 3be9114..29d3ae0 100644
--- a/src/components/OfferList/OfferList.tsx
+++ b/src/components/OfferList/OfferList.tsx
@@ -1,9 +1,8 @@
-import { useState } from 'react';
 import OfferCard from '../OfferCard/OfferCard';
 import { OfferList as OfferListProps } from '../../types/offer';
+import React from 'react';
 
-export default function OfferList({ offers }: OfferListProps) {
-  const [, setActiveId] = useState<string | null>(null);
+function OfferList({ offers, onCardHover }: OfferListProps) {
 
   if (offers.length === 0) {
     return (<p>Список пуст</p>);
@@ -14,9 +13,9 @@ export default function OfferList({ offers }: OfferListProps) {
         offers.map((ofr) =>
           (
             <article key={ofr.id} className="cities__card place-card" onMouseOver={() => {
-              setActiveId(ofr.id);
+              onCardHover(ofr.id);
             }} onMouseOut={() => {
-              setActiveId(null);
+              onCardHover(null);
             }}
             >
               <OfferCard {...ofr} />
@@ -27,4 +26,4 @@ export default function OfferList({ offers }: OfferListProps) {
     </div>);
 }
 
-
+export default React.memo(OfferList);
diff --git a/src/components/OfferMap/OfferMap.tsx b/src/components/OfferMap/OfferMap.tsx
index 097d3e3..a597cbc 100644
--- a/src/components/OfferMap/OfferMap.tsx
+++ b/src/components/OfferMap/OfferMap.tsx
@@ -3,13 +3,14 @@ import 'leaflet/dist/leaflet.css';
 import useMap from '../../hooks/useMap';
 import { City } from '../../types/city';
 import { Offer } from '../../types/offer';
-import { URL_MARKER_DEFAULT } from '../../const';
-import { useEffect, useRef } from 'react';
-import { MapPoint } from '../../types/mapPoint';
+import { MAP_ZOOM_DEFAULT, URL_MARKER_DEFAULT, URL_MARKER_CURRENT } from '../../const';
+import { useCallback, useEffect, useRef } from 'react';
+import React from 'react';
 
 type OfferMapProps = {
   city: City;
   offers: Offer[];
+  activeOfferId?: string | null;
 }
 
 const defaultCustomIcon = leaflet.icon({
@@ -18,33 +19,56 @@ const defaultCustomIcon = leaflet.icon({
   iconAnchor: [20, 40],
 });
 
+const currentCustomIcon = leaflet.icon({
+  iconUrl: URL_MARKER_CURRENT,
+  iconSize: [40, 40],
+  iconAnchor: [20, 40],
+});
+
 const mapContainerStyle = { height: '100%', minHeight: '500px', margin: '0 auto' };
 
-function OfferMap({ city, offers }: OfferMapProps) {
+function OfferMap({ city, offers, activeOfferId }: OfferMapProps) {
   const mapRef = useRef(null);
   const map = useMap(mapRef, city);
+  const activeMarker = useRef<leaflet.Marker | null>(null);
 
-  useEffect(() => {
-    if (map && offers.length > 0) {
-      const createMarker = ({ latitude, longitude }: MapPoint) => {
-        leaflet.marker({ lat: latitude, lng: longitude }, { icon: defaultCustomIcon }).addTo(map);
-      };
+  const pickMarkers = useCallback((mapObj: leaflet.Map, center: leaflet.LatLngExpression, points: leaflet.LatLngExpression[], zoom: number) => {
+    mapObj.setView(center, zoom);
 
-      if (city.location) {
-        map.setView(new leaflet.LatLng(city.location.latitude, city.location.longitude), city.location?.zoom);
-      }
+    points.forEach((p) => {
+      leaflet.marker(p, { icon: defaultCustomIcon }).addTo(mapObj);
+    });
+  }, []);
 
-      offers.forEach((offer) => {
-        if (offer.location) {
-          createMarker(offer.location);
-        }
-      });
+  useEffect(() => {
+    if (map && city.location && offers.length) {
+      pickMarkers(map, { lat: city.location.latitude, lng: city.location.longitude },
+        offers.map((x) => new leaflet.LatLng(x.location.latitude, x.location.longitude)),
+        city.location?.zoom || MAP_ZOOM_DEFAULT);
     }
   }, [city.location, map, offers]);
 
+  useEffect(() => {
+    if(!map) {
+      return;
+    }
+    if(activeMarker.current !== null) {
+      activeMarker.current.removeFrom(map);
+      activeMarker.current = null;
+    }
+
+    if(activeOfferId) {
+      const activeOffer = offers.find((x) => x.id === activeOfferId);
+      if (activeOffer) {
+        activeMarker.current = leaflet.marker({lat: activeOffer.location.latitude, lng: activeOffer.location.longitude}, { icon: currentCustomIcon });
+        activeMarker.current.addTo(map);
+      }
+    }
+  }, [map, activeOfferId, offers]);
+
   return (
     <div style={mapContainerStyle} ref={mapRef}></div>
   );
 }
 
-export default OfferMap;
+export default React.memo(OfferMap);
diff --git a/src/const.ts b/src/const.ts
index 19da203..0a80479 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -14,7 +14,6 @@ export enum AuthorizationStatus {
     Unknown = 'UNKNOWN',
 }
 
-
 export enum ApiRoute {
     Offers = '/offers',
     Login = '/login',
@@ -28,3 +27,4 @@ export const OFFER_LIMIT_DEFFAULT = 6;
 export const AUTH_TOKEN_KEY_NAME = 'six-cities-token';
 export const USER_EMAIL_KEY_NAME = 'user-email';
 export const USER_IMAGE_KEY_NAME = 'user-image-url';
+export const ERROR_DISPLAY_TIME = 2000;
diff --git a/src/pages/login.tsx b/src/pages/login.tsx
index a1ee681..dcea2a1 100644
--- a/src/pages/login.tsx
+++ b/src/pages/login.tsx
@@ -1,7 +1,7 @@
 import { useRef, FormEvent } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppDispatch } from '../hooks';
-import { loginAction } from '../store/api-actions';
+import { loginAction } from '../store/thunks/user-actions';
 import { AppRoute } from '../const';
 
 function LoginPage() {
@@ -41,7 +41,7 @@ function LoginPage() {
         </section>
         <section className="locations locations--login locations--current">
           <div className="locations__item">
-            <a className="locations__item-link" href="#" onClick={() => navigate(AppRoute.Main)} >
+            <a className="locations__item-link" href="#amsterdam" onClick={() => navigate(AppRoute.Main)} >
               <span>Amsterdam</span>
             </a>
           </div>
diff --git a/src/pages/main.tsx b/src/pages/main.tsx
index 49bef73..c3a356e 100644
--- a/src/pages/main.tsx
+++ b/src/pages/main.tsx
@@ -1,17 +1,24 @@
-import { useEffect } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import CityList from '../components/CityList/CityList';
 import OfferList from '../components/OfferList/OfferList';
 import OfferMap from '../components/OfferMap/OfferMap';
 import { useAppDispatch, useAppSelector } from '../hooks';
 import { cities } from '../mocks/cities';
-import { fetchOffersAction } from '../store/api-actions';
+
 import Spinner from '../components/Layout/Spinner';
+import { getAuthCheckedStatus, getCurrentCity, getCurrentOffers, getDataLoading, getErrorStatus } from '../store/selectors';
+import LoadingFallback from '../components/LoadingFallback';
+import { fetchOffersAction } from '../store/thunks/offer-actions';
 
 export default function MainPage() {
-  const currentCity = useAppSelector((state) => state.city);
-  const currentOffers = useAppSelector((state) => state.offers);
-  const offersLoading = useAppSelector((state) => state.loading);
+  const isAuthChecked = useAppSelector(getAuthCheckedStatus);
+  const currentCity = useAppSelector(getCurrentCity);
+  const currentOffers = useAppSelector(getCurrentOffers);
+  const offersLoading = useAppSelector(getDataLoading);
+  const isLoadingError = useAppSelector(getErrorStatus);
+
   const dispatch = useAppDispatch();
+  const[activeOfferId, setActiveOfferId] = useState<string | null>(null);
 
   useEffect(
     () => {
@@ -19,10 +26,18 @@ export default function MainPage() {
     }, [currentCity, dispatch]
   );
 
-  if(offersLoading) {
+  const handleCardHover = useCallback((id: string | null) => {
+    setActiveOfferId(id);
+  }, []);
+
+  if (!isAuthChecked || offersLoading) {
     return (<Spinner />);
   }
 
+  if (isLoadingError) {
+    return (<LoadingFallback />);
+  }
+
   return (
     <main className="page__main page__main--index">
       <h1 className="visually-hidden">Cities</h1>
@@ -43,18 +58,18 @@ export default function MainPage() {
                   <use xlinkHref="#icon-arrow-select"></use>
                 </svg>
               </span>
-              <ul className="places__options places__options--custom places__options--opened">
+              <ul className="places__options places__options--custom">
                 <li className="places__option places__option--active" tabIndex={0}>Popular</li>
                 <li className="places__option" tabIndex={0}>Price: low to high</li>
                 <li className="places__option" tabIndex={0}>Price: high to low</li>
                 <li className="places__option" tabIndex={0}>Top rated first</li>
               </ul>
             </form>
-            <OfferList offers={currentOffers} />
+            <OfferList offers={currentOffers} onCardHover={handleCardHover} />
           </section>
           <div className="cities__right-section">
             <section className="cities__map map" style={{background: 'none'}}>
-              <OfferMap city={currentCity} offers={currentOffers} />
+              <OfferMap city={currentCity} offers={currentOffers} activeOfferId={activeOfferId} />
             </section>
           </div>
         </div>
diff --git a/src/services/process-error-handle.ts b/src/services/process-error-handle.ts
index 3fb098d..248484e 100644
--- a/src/services/process-error-handle.ts
+++ b/src/services/process-error-handle.ts
@@ -1,9 +1,9 @@
 
 import { store } from '../store';
-import { setError } from '../store/action';
-//import { clearErrorAction } from '../store/api-actions';
+import { clearErrorAction } from '../store/api-actions';
+import { setError } from '../store/mainScreenSlice';
 
 export const processErrorHandle = (message: string): void => {
   store.dispatch(setError(message));
-  //store.dispatch(clearErrorAction());
+  store.dispatch(clearErrorAction());
 };
diff --git a/src/store/action.ts b/src/store/action.ts
index ee000cb..161c899 100644
--- a/src/store/action.ts
+++ b/src/store/action.ts
@@ -1,11 +1,4 @@
 import { createAction } from '@reduxjs/toolkit';
-import { City } from '../types/city';
-import { Offer } from '../types/offer';
-import { AppRoute, AuthorizationStatus } from '../const';
+import { AppRoute } from '../const';
 
-export const selectCity = createAction<City>('mainScreen/changeCity');
-export const setLoading = createAction<boolean>('mainScreen/setLoading');
-export const loadOffers = createAction<Offer[]>('data/loadOffers');
-export const requireAuthorization = createAction<AuthorizationStatus>('user/requireAuthorization');
-export const setError = createAction<string | null>('app/setError');
 export const redirectToRoute = createAction<AppRoute>('app/redirectToRoute');
diff --git a/src/store/api-actions.ts b/src/store/api-actions.ts
index 4826668..6d5cfba 100644
--- a/src/store/api-actions.ts
+++ b/src/store/api-actions.ts
@@ -1,67 +1,14 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { AxiosInstance } from 'axios';
-import { ApiRoute, AppRoute, AuthorizationStatus } from '../const';
-import { AppDispatch, State } from '../types/state';
-import { Offer } from '../types/offer';
-import { loadOffers, redirectToRoute, requireAuthorization, setLoading } from './action';
-import { AuthData, UserData } from '../types/userData';
-import { dropUserData, saveUserData } from '../services/userData';
-
-export const fetchOffersAction = createAsyncThunk<void, undefined, {
-    dispatch: AppDispatch;
-    state: State;
-    extra: AxiosInstance;
-}>(
-  'data/loadOffers',
-  async (_arg, { dispatch, getState, extra: api }) => {
-    dispatch(setLoading(true));
-    const { data } = await api.get<Offer[]>(ApiRoute.Offers);
-    dispatch(setLoading(false)); //Запрос уже завершился, крутилку можно убрать
-
-    const city = getState().city;
-    dispatch(loadOffers(data.filter((ofr) => (ofr.city.name === city.name))));
-  },
-);
-
-export const checkAuthAction = createAsyncThunk<void, undefined, {
-  dispatch: AppDispatch;
-  state: State;
-  extra: AxiosInstance;
-}>(
-  'user/checkAuth',
-  async (_arg, { dispatch, extra: api }) => {
-    try {
-      await api.get(ApiRoute.Login);
-      dispatch(requireAuthorization(AuthorizationStatus.Auth));
-    } catch {
-      dispatch(requireAuthorization(AuthorizationStatus.NoAuth));
-    }
-  },
-);
-
-export const loginAction = createAsyncThunk<void, AuthData, {
-  dispatch: AppDispatch;
-  state: State;
-  extra: AxiosInstance;
-}>(
-  'user/login',
-  async ({ username: email, password }, { dispatch, extra: api }) => {
-    const { data: { token, avatarUrl } } = await api.post<UserData>(ApiRoute.Login, { email, password });
-    saveUserData({token, email, avatarUrl} as UserData); //saveToken(token);
-    dispatch(requireAuthorization(AuthorizationStatus.Auth));
-    dispatch(redirectToRoute(AppRoute.Main));
-  },
-);
-
-export const logoutAction = createAsyncThunk<void, undefined, {
-  dispatch: AppDispatch;
-  state: State;
-  extra: AxiosInstance;
-}>(
-  'user/logout',
-  async (_arg, { dispatch, extra: api }) => {
-    await api.delete(ApiRoute.Logout);
-    dropUserData();
-    dispatch(requireAuthorization(AuthorizationStatus.NoAuth));
+import { ERROR_DISPLAY_TIME } from '../const';
+import { setError } from './mainScreenSlice';
+import { store } from '.';
+
+export const clearErrorAction = createAsyncThunk(
+  'app/clearError',
+  () => {
+    setTimeout(
+      () => store.dispatch(setError(null)),
+      ERROR_DISPLAY_TIME,
+    );
   },
 );
diff --git a/src/store/dataSlice.ts b/src/store/dataSlice.ts
new file mode 100644
index 0000000..5a29530
--- /dev/null
+++ b/src/store/dataSlice.ts
@@ -0,0 +1,29 @@
+import { createSlice } from '@reduxjs/toolkit';
+import { DataState } from '../types/state';
+import { fetchOffersAction } from './thunks/offer-actions';
+
+const initialState: DataState = {
+  offers: [],
+  isLoading: false,
+  hasError: false
+};
+
+export const dataSlice = createSlice({
+  name: 'data',
+  initialState,
+  reducers: {},
+  extraReducers(builder) {
+    builder
+      .addCase(fetchOffersAction.pending, (state) => {
+        state.isLoading = true;
+      })
+      .addCase(fetchOffersAction.fulfilled, (state, action) => {
+        state.offers = action.payload;
+        state.isLoading = false;
+      })
+      .addCase(fetchOffersAction.rejected, (state) => {
+        state.isLoading = false;
+        state.hasError = true;
+      });
+  }
+});
diff --git a/src/store/index.ts b/src/store/index.ts
index 5544e1a..8b39c8f 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -1,12 +1,13 @@
 import { configureStore } from '@reduxjs/toolkit';
-import { reducer } from './reducer';
+import { rootReducer } from './root-reducer';
 import { createAPI } from '../services/api';
 import { redirect } from './middleware/redirect';
 
+
 export const api = createAPI();
 
 export const store = configureStore({
-  reducer,
+  reducer: rootReducer,
   middleware: (getDefaultMiddleware) =>
     getDefaultMiddleware({
       thunk: {
diff --git a/src/store/mainScreenSlice.ts b/src/store/mainScreenSlice.ts
new file mode 100644
index 0000000..28ad9e0
--- /dev/null
+++ b/src/store/mainScreenSlice.ts
@@ -0,0 +1,24 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { cities } from '../mocks/cities';
+import { MainScreenState } from '../types/state';
+import { City } from '../types/city';
+
+const initialState: MainScreenState = {
+  city: cities[0],
+  error: null
+};
+
+export const mainScreenSlice = createSlice({
+  name: 'mainScreen',
+  initialState,
+  reducers: {
+    selectCity: (state, action: PayloadAction<City>) => {
+      state.city = action.payload;
+    },
+    setError: (state, action: PayloadAction<string | null>) => {
+      state.error = action.payload;
+    },
+  },
+});
+
+export const { selectCity, setError } = mainScreenSlice.actions;
diff --git a/src/store/middleware/redirect.ts b/src/store/middleware/redirect.ts
index 0338a76..5892125 100644
--- a/src/store/middleware/redirect.ts
+++ b/src/store/middleware/redirect.ts
@@ -1,10 +1,10 @@
 
 import { PayloadAction } from '@reduxjs/toolkit';
 import { Middleware } from 'redux';
-import { reducer } from '../reducer';
+import { rootReducer } from '../root-reducer';
 import browserHistory from '../../browser-history';
 
-type Reducer = ReturnType<typeof reducer>;
+type Reducer = ReturnType<typeof rootReducer>;
 
 export const redirect: Middleware<unknown, Reducer> =
     () =>
diff --git a/src/store/reducer.ts b/src/store/reducer.ts
deleted file mode 100644
index 7af22da..0000000
--- a/src/store/reducer.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { AuthorizationStatus } from '../const';
-import { cities } from '../mocks/cities';
-import { OffersState } from '../types/offerState';
-import { loadOffers, requireAuthorization, selectCity, setError, setLoading } from './action';
-import { createReducer } from '@reduxjs/toolkit';
-
-const initialState: OffersState = { city: cities[0], offers: [], loading: false, authorizationStatus: AuthorizationStatus.Unknown, error: null };
-
-const reducer = createReducer(initialState, (builder) => {
-  builder
-    .addCase(selectCity, (state, action) => {
-      state.city = action.payload;
-    })
-    .addCase(setLoading, (state, action) => {
-      state.loading = action.payload;
-    })
-    .addCase(loadOffers, (state, action) => {
-      state.offers = action.payload;
-      state.loading = false;
-    })
-    .addCase(requireAuthorization, (state, action) => {
-      state.authorizationStatus = action.payload;
-    })
-    .addCase(setError, (state, action) => {
-      state.error = action.payload;
-    });
-});
-
-export { reducer };
diff --git a/src/store/root-reducer.ts b/src/store/root-reducer.ts
new file mode 100644
index 0000000..fd2ce6b
--- /dev/null
+++ b/src/store/root-reducer.ts
@@ -0,0 +1,10 @@
+import { combineReducers } from '@reduxjs/toolkit';
+import { userSlice } from './userSlice';
+import { dataSlice } from './dataSlice';
+import { mainScreenSlice } from './mainScreenSlice';
+
+export const rootReducer = combineReducers({
+  'data': dataSlice.reducer,
+  'mainScreen': mainScreenSlice.reducer,
+  'user': userSlice.reducer,
+});
diff --git a/src/store/selectors.ts b/src/store/selectors.ts
new file mode 100644
index 0000000..5c6aadb
--- /dev/null
+++ b/src/store/selectors.ts
@@ -0,0 +1,14 @@
+import { State } from '../types/state';
+import { AuthorizationStatus } from '../const';
+import { City } from '../types/city';
+import { Offer } from '../types/offer';
+import { UserData } from '../types/userData';
+
+export const getAuthorizationStatus = (state: State): AuthorizationStatus => state['user'].authorizationStatus;
+export const getAuthCheckedStatus = (state: State): boolean => state['user'].authorizationStatus !== AuthorizationStatus.Unknown;
+export const getCurrentCity = (state: State): City => state['mainScreen'].city;
+export const getCurrentOffers = (state: State): Offer[] => state['data'].offers;
+export const getDataLoading = (state: State): boolean => state['data'].isLoading;
+export const getUserData = (state: State): UserData | undefined => state['user'].userData;
+export const getErrorText = (state: State): string | null => state['mainScreen'].error;
+export const getErrorStatus = (state: State): boolean => state['data'].hasError;
diff --git a/src/store/thunks/offer-actions.ts b/src/store/thunks/offer-actions.ts
new file mode 100644
index 0000000..36cf6dd
--- /dev/null
+++ b/src/store/thunks/offer-actions.ts
@@ -0,0 +1,18 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { Offer } from '../../types/offer';
+import { AppDispatch, State } from '../../types/state';
+import { AxiosInstance } from 'axios';
+import { ApiRoute } from '../../const';
+
+export const fetchOffersAction = createAsyncThunk<Offer[], undefined, {
+    dispatch: AppDispatch;
+    state: State;
+    extra: AxiosInstance;
+}>(
+  'data/loadOffers',
+  async (_arg, { getState, extra: api }) => {
+    const { data } = await api.get<Offer[]>(ApiRoute.Offers);
+    const city = getState().mainScreen.city;
+    return data.filter((ofr) => (ofr.city.name === city.name));
+  },
+);
diff --git a/src/store/thunks/user-actions.ts b/src/store/thunks/user-actions.ts
new file mode 100644
index 0000000..254b8e6
--- /dev/null
+++ b/src/store/thunks/user-actions.ts
@@ -0,0 +1,44 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { AxiosInstance } from 'axios';
+import { State } from 'history';
+import { ApiRoute, AppRoute } from '../../const';
+import { AppDispatch } from '../../types/state';
+import { dropUserData, saveUserData } from '../../services/userData';
+import { redirectToRoute } from '../action';
+import { AuthData, UserData } from '../../types/userData';
+
+export const checkAuthAction = createAsyncThunk<void, undefined, {
+  dispatch: AppDispatch;
+  state: State;
+  extra: AxiosInstance;
+}>(
+  'user/checkAuth',
+  async (_arg, { extra: api }) => {
+    await api.get(ApiRoute.Login);
+  },
+);
+
+export const loginAction = createAsyncThunk<void, AuthData, {
+    dispatch: AppDispatch;
+    state: State;
+    extra: AxiosInstance;
+}>(
+  'user/login',
+  async ({ username: email, password }, { dispatch, extra: api }) => {
+    const { data: { token, avatarUrl } } = await api.post<UserData>(ApiRoute.Login, { email, password });
+    saveUserData({ token, email, avatarUrl } as UserData);
+    dispatch(redirectToRoute(AppRoute.Main));
+  },
+);
+
+export const logoutAction = createAsyncThunk<void, undefined, {
+    dispatch: AppDispatch;
+    state: State;
+    extra: AxiosInstance;
+}>(
+  'user/logout',
+  async (_arg, { extra: api }) => {
+    await api.delete(ApiRoute.Logout);
+    dropUserData();
+  },
+);
diff --git a/src/store/userSlice.ts b/src/store/userSlice.ts
new file mode 100644
index 0000000..870a8f5
--- /dev/null
+++ b/src/store/userSlice.ts
@@ -0,0 +1,32 @@
+import { createSlice } from '@reduxjs/toolkit';
+import { AuthorizationStatus } from '../const';
+import { checkAuthAction, loginAction, logoutAction } from './thunks/user-actions';
+import { UserState } from '../types/state';
+
+const initialState: UserState = {
+  authorizationStatus: AuthorizationStatus.Unknown,
+};
+
+export const userSlice = createSlice({
+  name: 'user',
+  initialState,
+  reducers: {},
+  extraReducers(builder) {
+    builder
+      .addCase(checkAuthAction.fulfilled, (state) => {
+        state.authorizationStatus = AuthorizationStatus.Auth;
+      })
+      .addCase(checkAuthAction.rejected, (state) => {
+        state.authorizationStatus = AuthorizationStatus.NoAuth;
+      })
+      .addCase(loginAction.fulfilled, (state) => {
+        state.authorizationStatus = AuthorizationStatus.Auth;
+      })
+      .addCase(loginAction.rejected, (state) => {
+        state.authorizationStatus = AuthorizationStatus.NoAuth;
+      })
+      .addCase(logoutAction.fulfilled, (state) => {
+        state.authorizationStatus = AuthorizationStatus.NoAuth;
+      });
+  }
+});
diff --git a/src/types/offer.ts b/src/types/offer.ts
index 592ad53..04eef17 100644
--- a/src/types/offer.ts
+++ b/src/types/offer.ts
@@ -30,4 +30,5 @@ export type OfferOld = {
 
 export type OfferList = {
     offers: Offer[];
+    onCardHover: (id: string | null) => void;
 };
diff --git a/src/types/offerState.ts b/src/types/offerState.ts
deleted file mode 100644
index fe8d3d5..0000000
--- a/src/types/offerState.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { AuthorizationStatus } from '../const';
-import { City } from './city';
-import { Offer } from './offer';
-
-export interface OffersState {
-    city: City;
-    offers: Offer[];
-    loading: boolean;
-    authorizationStatus: AuthorizationStatus;
-    error: string | null;
-}
diff --git a/src/types/state.ts b/src/types/state.ts
index bba9d82..adba415 100644
--- a/src/types/state.ts
+++ b/src/types/state.ts
@@ -1,4 +1,28 @@
+import { AuthorizationStatus } from '../const';
 import { store } from '../store';
+import { City } from './city';
+import { Offer } from './offer';
+import { UserData } from './userData';
+
+export type AppState = {
+    error: string | null;
+}
+
+export type MainScreenState = {
+    city: City;
+    error: string | null;
+};
+
+export type UserState = {
+    authorizationStatus: AuthorizationStatus;
+    userData?: UserData;
+};
+
+export type DataState = {
+    offers: Offer[];
+    isLoading: boolean;
+    hasError: boolean;
+};
 
 export type State = ReturnType<typeof store.getState>;
 export type AppDispatch = typeof store.dispatch;


Reviews:
user: login: PolinaShneider
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:41:37Z
user: login: PolinaShneider
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:45:45Z
user: login: PolinaShneider
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:50:50Z
user: login: PolinaShneider
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:51:26Z
user: login: PolinaShneider
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:52:35Z
user: login: PolinaShneider
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:53:06Z
user: login: PolinaShneider
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:53:19Z
user: login: PolinaShneider
body: Пару моментов нужно поправить. И желательно почистить закомментированный код
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:54:02Z
user: login: volgavr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:08:37Z

Review comments:
diff_hunk: @@ -54,3 +57,5 @@ export default function Header() {
     </header>
   );
 }
+
+export default React.memo(Header);
path: src/components/Layout/Header/Header.tsx
user: login: PolinaShneider
body: Можешь пояснить, зачем тебе тут React.memo?

По сути у тебя
- Нет props, по которым можно оптимизировать повторный рендер.
- Ререндер будет происходить при любом изменении состояния или маршрута.

created_at: 2025-05-21T18:41:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,28 +1,44 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
 import CityList from '../components/CityList/CityList';
 import OfferList from '../components/OfferList/OfferList';
 import OfferMap from '../components/OfferMap/OfferMap';
 import { useAppDispatch, useAppSelector } from '../hooks';
 import { cities } from '../mocks/cities';
-import { fetchOffersAction } from '../store/api-actions';
+
 import Spinner from '../components/Layout/Spinner';
+import { getAuthCheckedStatus, getCurrentCity, getCurrentOffers, getDataLoading, getErrorStatus } from '../store/selectors';
+import LoadingFallback from '../components/LoadingFallback';
+import { fetchOffersAction } from '../store/thunks/offer-actions';
 
 export default function MainPage() {
-  const currentCity = useAppSelector((state) => state.city);
-  const currentOffers = useAppSelector((state) => state.offers);
-  const offersLoading = useAppSelector((state) => state.loading);
+  const isAuthChecked = useAppSelector(getAuthCheckedStatus);
+  const currentCity = useAppSelector(getCurrentCity);
+  const currentOffers = useAppSelector(getCurrentOffers);
+  const offersLoading = useAppSelector(getDataLoading);
+  const isLoadingError = useAppSelector(getErrorStatus);
+
   const dispatch = useAppDispatch();
+  const[activeOfferId, setActiveOfferId] = useState<string | null>(null);
 
   useEffect(
     () => {
       dispatch(fetchOffersAction());
     }, [currentCity, dispatch]
   );
 
-  if(offersLoading) {
+  function handleCardHover(id: string | null) {
path: src/pages/main.tsx
user: login: PolinaShneider
body: У тебя эта функция передается пропсой в компонент, который ты оборачиваешь в React.memo

Чтобы не создавалась новая функция каждый раз (и пропсы не менялись, когда данные по сути те же), оберни ее

```
const handleCardHover = useCallback((id: string | null) => {
  setActiveOfferId(id);
}, []);
```
created_at: 2025-05-21T18:45:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,33 +19,62 @@ const defaultCustomIcon = leaflet.icon({
   iconAnchor: [20, 40],
 });
 
+const currentCustomIcon = leaflet.icon({
+  iconUrl: URL_MARKER_CURRENT,
+  iconSize: [40, 40],
+  iconAnchor: [20, 40],
+});
+
 const mapContainerStyle = { height: '100%', minHeight: '500px', margin: '0 auto' };
 
-function OfferMap({ city, offers }: OfferMapProps) {
+function OfferMap({ city, offers, activeOfferId }: OfferMapProps) {
   const mapRef = useRef(null);
   const map = useMap(mapRef, city);
+  const activeId = useRef<string | null>(activeOfferId || null);
+  const activeMarker = useRef<leaflet.Marker | null>(null);
path: src/components/OfferMap/OfferMap.tsx
user: login: PolinaShneider
body: Я бы лучше сразу импортировала типы из leaflet и обращалась бы просто Marker
created_at: 2025-05-21T18:50:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,33 +19,62 @@ const defaultCustomIcon = leaflet.icon({
   iconAnchor: [20, 40],
 });
 
+const currentCustomIcon = leaflet.icon({
+  iconUrl: URL_MARKER_CURRENT,
+  iconSize: [40, 40],
+  iconAnchor: [20, 40],
+});
+
 const mapContainerStyle = { height: '100%', minHeight: '500px', margin: '0 auto' };
 
-function OfferMap({ city, offers }: OfferMapProps) {
+function OfferMap({ city, offers, activeOfferId }: OfferMapProps) {
   const mapRef = useRef(null);
   const map = useMap(mapRef, city);
+  const activeId = useRef<string | null>(activeOfferId || null);
+  const activeMarker = useRef<leaflet.Marker | null>(null);
 
-  useEffect(() => {
-    if (map && offers.length > 0) {
-      const createMarker = ({ latitude, longitude }: MapPoint) => {
-        leaflet.marker({ lat: latitude, lng: longitude }, { icon: defaultCustomIcon }).addTo(map);
-      };
+  const pickMarkers = useCallback((mapObj: leaflet.Map, center: leaflet.LatLngExpression, points: leaflet.LatLngExpression[], zoom: number) => {
+    mapObj.setView(center, zoom);
 
-      if (city.location) {
-        map.setView(new leaflet.LatLng(city.location.latitude, city.location.longitude), city.location?.zoom);
-      }
+    points.forEach((p) => {
+      leaflet.marker(p, { icon: defaultCustomIcon }).addTo(mapObj);
+    });
+  }, []);
 
-      offers.forEach((offer) => {
-        if (offer.location) {
-          createMarker(offer.location);
-        }
-      });
+  useEffect(() => {
+    //console.log(`Active Offer: ${activeOfferId};`);
path: src/components/OfferMap/OfferMap.tsx
user: login: PolinaShneider
body: Не забывай чистить ненужные логи, чтобы потом не пришлось делать много работы перед защитой
created_at: 2025-05-21T18:51:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,11 +1,9 @@
 import { createAction } from '@reduxjs/toolkit';
-import { City } from '../types/city';
-import { Offer } from '../types/offer';
-import { AppRoute, AuthorizationStatus } from '../const';
+import { AppRoute } from '../const';
 
-export const selectCity = createAction<City>('mainScreen/changeCity');
-export const setLoading = createAction<boolean>('mainScreen/setLoading');
-export const loadOffers = createAction<Offer[]>('data/loadOffers');
-export const requireAuthorization = createAction<AuthorizationStatus>('user/requireAuthorization');
-export const setError = createAction<string | null>('app/setError');
+//export const selectCity = createAction<City>('mainScreen/changeCity');
path: src/store/action.ts
user: login: PolinaShneider
body: А это почему закомментировано? Нужно ли?
created_at: 2025-05-21T18:52:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,11 @@
+import { combineReducers } from '@reduxjs/toolkit';
+import { userSlice } from './userSlice';
+import { dataSlice } from './dataSlice';
+import { mainScreenSlice } from './mainScreenSlice';
+
+export const rootReducer = combineReducers({
+  //'app': appSlice.reducer,
path: src/store/root-reducer.ts
user: login: PolinaShneider
body: Это нужно?
created_at: 2025-05-21T18:53:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { Offer } from '../../types/offer';
+import { AppDispatch, State } from '../../types/state';
+import { AxiosInstance } from 'axios';
+import { ApiRoute } from '../../const';
+
+export const fetchOffersAction = createAsyncThunk<Offer[], undefined, {
+    dispatch: AppDispatch;
+    state: State;
+    extra: AxiosInstance;
+}>(
+  'data/loadOffers',
+  async (_arg, { getState, extra: api }) => {
+    //dispatch(setLoading(true));
+    const { data } = await api.get<Offer[]>(ApiRoute.Offers);
+    //dispatch(setLoading(false)); //Запрос уже завершился, крутилку можно убрать
path: src/store/thunks/offer-actions.ts
user: login: PolinaShneider
body: Аналогично здесь
created_at: 2025-05-21T18:53:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,28 +1,44 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
 import CityList from '../components/CityList/CityList';
 import OfferList from '../components/OfferList/OfferList';
 import OfferMap from '../components/OfferMap/OfferMap';
 import { useAppDispatch, useAppSelector } from '../hooks';
 import { cities } from '../mocks/cities';
-import { fetchOffersAction } from '../store/api-actions';
+
 import Spinner from '../components/Layout/Spinner';
+import { getAuthCheckedStatus, getCurrentCity, getCurrentOffers, getDataLoading, getErrorStatus } from '../store/selectors';
+import LoadingFallback from '../components/LoadingFallback';
+import { fetchOffersAction } from '../store/thunks/offer-actions';
 
 export default function MainPage() {
-  const currentCity = useAppSelector((state) => state.city);
-  const currentOffers = useAppSelector((state) => state.offers);
-  const offersLoading = useAppSelector((state) => state.loading);
+  const isAuthChecked = useAppSelector(getAuthCheckedStatus);
+  const currentCity = useAppSelector(getCurrentCity);
+  const currentOffers = useAppSelector(getCurrentOffers);
+  const offersLoading = useAppSelector(getDataLoading);
+  const isLoadingError = useAppSelector(getErrorStatus);
+
   const dispatch = useAppDispatch();
+  const[activeOfferId, setActiveOfferId] = useState<string | null>(null);
 
   useEffect(
     () => {
       dispatch(fetchOffersAction());
     }, [currentCity, dispatch]
   );
 
-  if(offersLoading) {
+  function handleCardHover(id: string | null) {
path: src/pages/main.tsx
user: login: volgavr
body: о, точно)
created_at: 2025-05-22T16:08:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-20T14:12:08Z
body: Ваш пулреквест опубликован. Посмотреть можно [здесь](https://htmlacademy-react.github.io/2609653-six-cities-3/11/)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-22T18:15:03Z
body: Ваш пулреквест опубликован. Посмотреть можно [здесь](https://htmlacademy-react.github.io/2609653-six-cities-3/11/)
author_association: NONE