diff --git a/src/components/app.tsx b/src/components/app.tsx index 771b364..3ee6b5a 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -4,29 +4,24 @@ import FavoritePage from '../pages/favorite-page'; import LoginPage from '../pages/login-page'; import { Route, Routes } from 'react-router-dom'; import { AppRoute, authorizationStatus } from '../const'; -import NotFoundPage from '../pages/not-found-page'; +import NotFoundPage from '../pages/not-found-page/not-found-page'; import ProtectedRoute from './protected-route'; -import { Offer } from '../types/offer'; -type TAppProps = { - offers: Offer[]; -} - -export default function App({ offers }: TAppProps) { +export default function App() { return ( } + element={} /> } + element={} /> } + element={ } /> void; } -export default function Card({ offer, optionCard }: TCardProps) { +export default function Card({ offer, optionCard, handelPointCardMouseOver }: TCardProps) { const { width, height, classCard } = optionCard; + + const onPointCardMouseOver = () => { + handelPointCardMouseOver(offer); + }; + + const onPointCardMouseLeave = () => { + handelPointCardMouseOver(null); + }; + return ( -
+
{offer.isPremium &&
Premium @@ -22,27 +32,27 @@ export default function Card({ offer, optionCard }: TCardProps) {
- Place image + Place image
-
-
-
- €{offer.price} - / night +
+
+
+ €{offer.price} + / night
-
- -

- {offer.title} + +

+ {offer.title}

-

{offer.type}

+

{offer.type}

); diff --git a/src/components/list-cards.tsx b/src/components/list-cards.tsx index c700032..11bd59e 100644 --- a/src/components/list-cards.tsx +++ b/src/components/list-cards.tsx @@ -1,30 +1,23 @@ -import { MouseEvent, useState } from 'react'; -import { optionCard } from '../const'; +import { OptionCard } from '../const'; import { Offer } from '../types/offer'; import Card from './card'; +import { OfferPreviews } from '../types/offer-preview'; type ListOfferNearbyProps = { offers: Offer[]; - onListItemHover: (currentCard: Offer) => void; + onListItemHover: (currentCard: OfferPreviews | null) => void; extraClass: string; } export default function ListCards({ offers, onListItemHover, extraClass }: ListOfferNearbyProps) { - const [cardId, setCardId] = useState(''); - const onPointingCardMouseOver = ({ target }: MouseEvent) => { - setCardId(target.dataset.id); + const handelPointingCardMouseOver = (offer: OfferPreviews | null) => { + onListItemHover(offer); }; - const currentCard = offers.find((offer) => offer.id === cardId); - - if (currentCard !== undefined) { - onListItemHover(currentCard); - } - return ( -
- {offers.map((offer) => )} +
+ {offers.map((offer) => )}
); } diff --git a/src/components/list-location.tsx b/src/components/list-location.tsx new file mode 100644 index 0000000..6af6b3a --- /dev/null +++ b/src/components/list-location.tsx @@ -0,0 +1,16 @@ +import Location from './location'; +import { SyntheticEvent } from 'react'; + +type ListLocation = { + listLocations: string[]; + handleCurrentCityClick: (evt: SyntheticEvent) => void; + currentCity: string; +} + +export default function ListLocation({ listLocations, handleCurrentCityClick, currentCity }: ListLocation) { + return ( +
    + {listLocations.map((location) => )} +
+ ); +} diff --git a/src/components/location.tsx b/src/components/location.tsx new file mode 100644 index 0000000..fd5d6a9 --- /dev/null +++ b/src/components/location.tsx @@ -0,0 +1,18 @@ +import { SyntheticEvent } from 'react'; + +type LocationProps = { + city: string; + isActive: boolean; + handleCurrentCityClick: (evt: SyntheticEvent) => void; +} + +export default function Location({ city, isActive, handleCurrentCityClick }: LocationProps) { + return ( +
  • + + {city} + +
  • + ); +} + diff --git a/src/components/map.tsx b/src/components/map.tsx index be59b63..b111c7b 100644 --- a/src/components/map.tsx +++ b/src/components/map.tsx @@ -5,11 +5,13 @@ import { useEffect, useRef } from 'react'; import { URL_MARKER_DEFAULT, URL_MARKER_CURRENT } from '../const'; import { City } from '../types/city'; import useMap from '../hooks/useMap'; +import { OfferPreviews } from '../types/offer-preview'; +import { MapSize } from '../const'; type MapProps = { city: City; offers: Offer[]; - selectedOffer: Offer | undefined; + selectedOffer: OfferPreviews | null; }; const defaultCustomIcon = leaflet.icon({ @@ -32,24 +34,31 @@ export default function Map(props: MapProps): JSX.Element { useEffect(() => { if (map) { + const markerLayer = leaflet.layerGroup().addTo(map); offers.forEach((offer) => { leaflet.marker({ lat: offer.location.latitude, lng: offer.location.longitude }, { - icon: (selectedOffer !== undefined && offer.title === selectedOffer.title) + icon: (selectedOffer !== null && offer.title === selectedOffer.title) ? currentCustomIcon : defaultCustomIcon, }) - .addTo(map); + .addTo(markerLayer); }); + return () => { + map.removeLayer(markerLayer); + mapRef.current = null; + }; } - }, [map, offers, selectedOffer]); + + + }, [map, offers, selectedOffer, city]); return (
    diff --git a/src/components/places-option.tsx b/src/components/places-option.tsx new file mode 100644 index 0000000..4a28f99 --- /dev/null +++ b/src/components/places-option.tsx @@ -0,0 +1,17 @@ +import { SyntheticEvent } from 'react'; + +type PlacesOptionProps = { + place: string; + handelSortOfferClick: (sortType: string) => void; +} + +export default function PlacesOption({ place, handelSortOfferClick }: PlacesOptionProps) { + const onSortOfferClick = (evt: SyntheticEvent) => { + if (evt.currentTarget.textContent !== null) { + handelSortOfferClick(evt.currentTarget.textContent); + } + }; + return ( +
  • {place}
  • + ); +} diff --git a/src/components/places-options.tsx b/src/components/places-options.tsx new file mode 100644 index 0000000..6163c7e --- /dev/null +++ b/src/components/places-options.tsx @@ -0,0 +1,15 @@ +import PlacesOption from './places-option'; +import { placesOption } from '../const'; + +type PlacesOptionsProps = { + isOpen: boolean; + handelSortOfferClick: (sortType: string) => void; +} + +export default function PlacesOptions({ isOpen, handelSortOfferClick }: PlacesOptionsProps) { + return ( +
      + {placesOption.map((place) => )} +
    + ); +} diff --git a/src/const.ts b/src/const.ts index ec991e4..1776c12 100644 --- a/src/const.ts +++ b/src/const.ts @@ -10,7 +10,7 @@ const AuthorizationStatus = { NO_AUTH: false, }; -const optionCard = { +const OptionCard = { CITIES_CARD: { classCard: 'cities__card', width: '260', @@ -23,11 +23,19 @@ const optionCard = { } }; +const MapSize = { + WIDTH: '100%', + HEIGHT: '100%', +}; + + const OptionListCard = { FAVORITES_CARD: 'near-places__list places__list', CITIES_CARD: 'cities__places-list places__list tabs__content', }; +const placesOption = ['Popular', 'Price: low to high', 'Price: high to low', 'Top rated first',]; + const URL_MARKER_DEFAULT = '../markup/img/pin.svg'; const URL_MARKER_CURRENT = '../markup/img/pin-active.svg'; @@ -35,4 +43,4 @@ const URL_MARKER_CURRENT = '../markup/img/pin-active.svg'; const CountStar: number = 5; -export { CountStar, AppRoute, AuthorizationStatus as authorizationStatus, optionCard, URL_MARKER_DEFAULT, URL_MARKER_CURRENT, OptionListCard }; +export { CountStar, AppRoute, AuthorizationStatus as authorizationStatus, OptionCard, URL_MARKER_DEFAULT, URL_MARKER_CURRENT, OptionListCard, MapSize, placesOption }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..9cb3402 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,9 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { State, AppDispatch } from '../types/state'; + +const useAppDispatch = () => useDispatch(); + +const useAppSelector: TypedUseSelectorHook = useSelector; + +export { useAppDispatch, useAppSelector }; + diff --git a/src/hooks/useMap.tsx b/src/hooks/useMap.tsx index 686440a..0b4dc41 100644 --- a/src/hooks/useMap.tsx +++ b/src/hooks/useMap.tsx @@ -8,7 +8,6 @@ function useMap( ): Map | null { const [map, setMap] = useState(null); const isRenderedRef = useRef(false); - useEffect(() => { if (mapRef.current !== null && !isRenderedRef.current) { const instance = new Map(mapRef.current, { @@ -28,9 +27,9 @@ function useMap( ); instance.addLayer(layer); - setMap(instance); } + isRenderedRef.current = true; }, [mapRef, city]); diff --git a/src/index.tsx b/src/index.tsx index 9ba07ae..7d23b82 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,8 @@ import App from './components/app'; import { BrowserRouter } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import { offers } from './mocks/offers'; +import { Provider } from 'react-redux'; +import { store } from './store'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement @@ -11,10 +13,12 @@ const root = ReactDOM.createRoot( root.render( - - - - - + + + + + + + ); diff --git a/src/mocks/locations.ts b/src/mocks/locations.ts new file mode 100644 index 0000000..a6fd1f8 --- /dev/null +++ b/src/mocks/locations.ts @@ -0,0 +1 @@ +export const locations = ['Paris', 'Cologne', 'Brussels', 'Amsterdam', 'Hamburg', 'Dusseldorf',]; diff --git a/src/pages/favorite-page.tsx b/src/pages/favorite-page.tsx index a08f6d5..ebcf05c 100644 --- a/src/pages/favorite-page.tsx +++ b/src/pages/favorite-page.tsx @@ -2,16 +2,15 @@ import Container from '../components/container'; import Card from '../components/card'; import FavoriteItems from '../components/favorite-items'; import { Helmet } from 'react-helmet-async'; -import { Offer } from '../types/offer'; -import { optionCard } from '../const'; +import { OptionCard } from '../const'; import FavoritesEmpty from '../components/favorites-empty'; import { getFavoritesByLocation } from '../utils/utils'; +import { useAppSelector } from '../hooks'; -type TOfferPageProps = { - offers: Offer[]; -} -export default function FavoritePage({ offers }: TOfferPageProps) { +export default function FavoritePage() { + const offers = useAppSelector((state) => state.offers); + const favorites = getFavoritesByLocation(offers); return ( @@ -26,7 +25,7 @@ export default function FavoritePage({ offers }: TOfferPageProps) {
      {Object.entries(favorites).map(([location, gropedFavorites]) => ( - {gropedFavorites.map((favorite) => )} + {gropedFavorites.map((favorite) => )} ) )} diff --git a/src/pages/main-page.tsx b/src/pages/main-page.tsx index 0ec2d2f..484b2f1 100644 --- a/src/pages/main-page.tsx +++ b/src/pages/main-page.tsx @@ -1,20 +1,30 @@ import Container from '../components/container'; import { City } from '../types/city'; -import { Offer } from '../types/offer'; import ListCards from '../components/list-cards'; import Map from '../components/map'; -import { MouseEvent, useState } from 'react'; +import { SyntheticEvent, useState } from 'react'; import MainEmpty from '../components/main-empty'; import { OptionListCard } from '../const'; +import { OfferPreviews } from '../types/offer-preview'; +import { locations } from '../mocks/locations'; +import ListLocation from '../components/list-location'; +import { useAppDispatch, useAppSelector } from '../hooks'; +import { selectCity, sortOffer } from '../store/action'; +import { offers } from '../mocks/offers'; +import PlacesOptions from '../components/places-options'; -type TMainPageProps = { - offers: Offer[]; -} +export default function MainPage() { + const baseOffers = offers; + const selectOffers = useAppSelector((state) => state.offers); + const currentCity = useAppSelector((state) => state.city); + const dispatch = useAppDispatch(); -export default function MainPage({ offers }: TMainPageProps) { + const [selectedOffer, setSelectedOffer] = useState( + null + ); - const [selectedOffer, setSelectedOffer] = useState( - undefined + const [isOpenSort, setIsOpenSort] = useState( + false ); const [selectedCity, setSelectedCity] = useState({ @@ -26,23 +36,37 @@ export default function MainPage({ offers }: TMainPageProps) { } }); - const handleListItemHover = (currentCard: Offer) => { - const currentPoint = offers.find((offer) => offer.title === currentCard.title); - if (currentPoint !== undefined) { - setSelectedOffer(currentPoint); - } + const [selectedLocation, setSelectedLocation] = useState(currentCity); + + const handelSortOfferClick = (sortType: string) => { + dispatch(sortOffer(sortType)); + setIsOpenSort(!isOpenSort); }; - const handleCurrentCityClick = (evt: MouseEvent) => { + const handelOpenPlacesClick = () => { + setIsOpenSort(!isOpenSort); + }; + + const handleListItemHover = (currentCard: OfferPreviews | null) => { + setSelectedOffer(currentCard); + }; + + const handleCurrentCityClick = (evt: SyntheticEvent) => { evt.preventDefault(); - const currentOffer = offers.find((offer) => offer.city.name === evt.target.textContent); + const currentOffer = baseOffers.find((offer) => offer.city.name === evt.currentTarget.textContent); + if (currentOffer !== undefined) { - setSelectedCity({ ...currentOffer?.city }); - offers.filter((offer) => offer.city.name === currentOffer.city.name); + setSelectedCity({ ...currentOffer.city }); + } + + if (evt.currentTarget.tagName === 'SPAN' && evt.currentTarget.textContent !== null) { + setSelectedLocation(evt.currentTarget.textContent); + dispatch(selectCity(evt.currentTarget.textContent)); } + }; return ( @@ -50,74 +74,39 @@ export default function MainPage({ offers }: TMainPageProps) {

      Cities

      - {offers.length !== 0 ? + {selectOffers.length !== 0 ?

      Places

      - {offers.length} places to stay in Amsterdam + {selectOffers.length} place{selectOffers.length > 1 ? 's' : ''} to stay in Amsterdam
      Sort by - + Popular -
        -
      • Popular
      • -
      • Price: low to high
      • -
      • Price: high to low
      • -
      • Top rated first
      • -
      + - +
      : } - {offers.length && -
      -
      - -
      -
      } +
      +
      + {selectOffers.length && + } +
      +
      ); } + diff --git a/src/pages/not-found-page/not-found-page-model.css b/src/pages/not-found-page/not-found-page-model.css new file mode 100644 index 0000000..c9d56ff --- /dev/null +++ b/src/pages/not-found-page/not-found-page-model.css @@ -0,0 +1,6 @@ +.not-found-page-model { + position: absolute; + top: 50%; + left: 50%; + text-align: center; +} diff --git a/src/pages/not-found-page.tsx b/src/pages/not-found-page/not-found-page.tsx similarity index 68% rename from src/pages/not-found-page.tsx rename to src/pages/not-found-page/not-found-page.tsx index dc10c41..364a635 100644 --- a/src/pages/not-found-page.tsx +++ b/src/pages/not-found-page/not-found-page.tsx @@ -1,17 +1,11 @@ import { Link } from 'react-router-dom'; -import { AppRoute } from '../const'; +import { AppRoute } from '../../const'; import { Helmet } from 'react-helmet-async'; - -const divStyle = { - position: 'absolute', - top: '50%', - left: '50%', - textAlign: 'center' -}; +import './not-found-page-model.css'; export default function NotFoundPage() { return ( -
      +
      Not found page diff --git a/src/pages/offer-page.tsx b/src/pages/offer-page.tsx index 1bf16b2..8b5185e 100644 --- a/src/pages/offer-page.tsx +++ b/src/pages/offer-page.tsx @@ -2,7 +2,6 @@ import Container from '../components/container'; import OfferInside from '../components/offer-inside'; import Rating from '../components/rating'; import { Helmet } from 'react-helmet-async'; -import { Offer } from '../types/offer'; import CommentsTemplate from '../components/comments-template'; import { CountStar } from '../const'; import { useParams } from 'react-router-dom'; @@ -12,20 +11,19 @@ import Map from '../components/map'; import { useState } from 'react'; import { OptionListCard } from '../const'; import ListCards from '../components/list-cards'; +import { OfferPreviews } from '../types/offer-preview'; +import { useAppSelector } from '../hooks'; -type TOfferPageProps = { - offers: Offer[]; -} +export default function OfferPage() { + const offers = useAppSelector((state) => state.offers); -export default function OfferPage({ offers }: TOfferPageProps) { - const [selectedOffer, setSelectedOffer] = useState( - undefined + const [selectedOffer, setSelectedOffer] = useState( + null ); - const handleListItemHover = (currentCard: Offer) => { - const currentPoint = offers.find((offer) => offer.title === currentCard.title); - if (currentPoint !== undefined) { - setSelectedOffer(currentPoint); + const handleListItemHover = (currentCard: OfferPreviews | null) => { + if (currentCard !== null) { + setSelectedOffer(currentCard); } }; diff --git a/src/store/action.ts b/src/store/action.ts new file mode 100644 index 0000000..66ea6c7 --- /dev/null +++ b/src/store/action.ts @@ -0,0 +1,6 @@ +import { createAction } from '@reduxjs/toolkit'; + +const selectCity = createAction('mainPage/selectCity'); +const sortOffer = createAction('mainPage/sortOffer'); + +export { selectCity, sortOffer }; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..b6e9107 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,6 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { reducer } from './reducer'; + +const store = configureStore({ reducer }); + +export { store }; diff --git a/src/store/reducer.ts b/src/store/reducer.ts new file mode 100644 index 0000000..594c2fb --- /dev/null +++ b/src/store/reducer.ts @@ -0,0 +1,37 @@ +import { createReducer } from '@reduxjs/toolkit'; +import { selectCity, sortOffer } from './action'; +import { offers } from '../mocks/offers'; +import { placesOption } from '../const'; +import { sortPriceLow, sortPriceHigh, sortRating } from '../utils/utils'; + +const initialState = { + city: 'Paris', + offers: [...offers].filter((offer) => offer.city.name === 'Paris'), +}; + +const reducer = createReducer(initialState, (builder) => { + builder + .addCase(selectCity, (state, action) => { + state.city = action.payload; + state.offers = [...offers].filter((offer) => offer.city.name === state.city); + }) + .addCase(sortOffer, (state, action) => { + switch (action.payload) { + case placesOption[0]: + state.offers = [...offers].filter((offer) => offer.city.name === state.city); + break; + case placesOption[1]: + state.offers = state.offers.sort(sortPriceHigh); + break; + case placesOption[2]: + state.offers = state.offers.sort(sortPriceLow); + break; + case placesOption[3]: + state.offers = state.offers.sort(sortRating); + break; + } + }); +}); + +export { reducer }; + diff --git a/src/types/state.ts b/src/types/state.ts new file mode 100644 index 0000000..70af949 --- /dev/null +++ b/src/types/state.ts @@ -0,0 +1,5 @@ +import { store } from '../store'; + +export type State = ReturnType; + +export type AppDispatch = typeof store.dispatch; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 275a9e8..d9bc3b9 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -19,4 +19,16 @@ function humanizeOrderData(date: string, format: string) { return date ? dayjs(date).format(format) : ''; } -export { getFavoritesByLocation, humanizeOrderData }; +function sortPriceLow(offerA: Offer, offerB: Offer,) { + return offerA.price - offerB.price; +} + +function sortPriceHigh(offerA: Offer, offerB: Offer,) { + return offerB.price - offerA.price; +} + +function sortRating(offerA: Offer, offerB: Offer,) { + return offerB.rating - offerA.rating; +} + +export { getFavoritesByLocation, humanizeOrderData, sortPriceLow, sortPriceHigh, sortRating };