diff --git a/package-lock.json b/package-lock.json index 9e0465f..4e83b6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "classnames": "2.3.2", "history": "5.3.0", "http-status-codes": "2.3.0", - "leaflet": "1.7.1", + "leaflet": "^1.7.1", "react": "18.2.0", "react-dom": "18.2.0", "react-helmet-async": "1.3.0", @@ -26,7 +26,7 @@ "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.5.1", "@types/faker": "5.5.9", - "@types/leaflet": "1.9.3", + "@types/leaflet": "^1.9.3", "@types/react": "18.2.25", "@types/react-dom": "18.2.11", "@types/react-redux": "7.1.27", diff --git a/package.json b/package.json index 5bc924e..f4a6eb2 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "classnames": "2.3.2", "history": "5.3.0", "http-status-codes": "2.3.0", - "leaflet": "1.7.1", + "leaflet": "^1.7.1", "react": "18.2.0", "react-dom": "18.2.0", "react-helmet-async": "1.3.0", @@ -30,7 +30,7 @@ "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.5.1", "@types/faker": "5.5.9", - "@types/leaflet": "1.9.3", + "@types/leaflet": "^1.9.3", "@types/react": "18.2.25", "@types/react-dom": "18.2.11", "@types/react-redux": "7.1.27", diff --git a/src/app/index.tsx b/src/app/index.tsx index 90d3db6..99cceed 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -7,26 +7,29 @@ import { PrivateRoute } from '../components/private-route'; import { Page404 } from '../pages/errors'; import { AppRoutes } from '../constants/routes.ts'; import { Offer } from '../types/offer.ts'; +import { ErrorBoundary } from '../components/index.ts'; type Props = { offers: Offer[]; }; export const App = ({ offers }: Props) => ( - - - - } /> - } /> - - - - )} - /> - } /> - - } /> - - + + + + + } /> + } /> + + + + )} + /> + } /> + + } /> + + + ); diff --git a/src/components/city-offers/index.tsx b/src/components/city-offers/index.tsx new file mode 100644 index 0000000..f763bdf --- /dev/null +++ b/src/components/city-offers/index.tsx @@ -0,0 +1,54 @@ +import { useMemo, useState } from 'react'; +import type { Offer } from '../../types/offer'; +import { OffersList } from '../offers-list'; +import { Map } from '../map'; + +type Props = { + offers: Offer[]; +}; + +export const CityOffers = ({ offers }: Props) => { + const [hoveredOfferId, setHoveredOfferId] = useState(undefined); + const offersAmount = offers.length; + const ACTIVE_CITY = 'Amsterdam'; + + const handleOfferHover = (id: Offer['id'] | undefined) => { + setHoveredOfferId(id); + }; + + const hoveredOffer = useMemo(() => { + return offers.find((offer) => offer.id === hoveredOfferId); + }, [hoveredOfferId, offers]); + + return ( + + + + Places + {offersAmount} places to stay in Amsterdam + + Sort by + + Popular + + + + + + Popular + Price: low to high + Price: high to low + Top rated first + + + + + + + + + + + + ); +}; diff --git a/src/components/error-boundary/index.tsx b/src/components/error-boundary/index.tsx new file mode 100644 index 0000000..77d44bc --- /dev/null +++ b/src/components/error-boundary/index.tsx @@ -0,0 +1,32 @@ +import { ErrorInfo, PureComponent, ReactNode } from 'react'; + +type Props = { + FallbackComponent?: ReactNode; + children: ReactNode; +} + +type State = { + hasError: boolean; +} + +export class ErrorBoundary extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // eslint-disable-next-line no-console + console.error('ErrorBoundary поймал ошибку: ', error, errorInfo); + this.setState({ hasError: true }); + } + + render() { + if (this.state.hasError) { + const ErrorComponent = this.props.FallbackComponent ?? Something went wrong.; + return ErrorComponent; + } + + return this.props.children; + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 9203758..bb3cde0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,8 @@ export { OfferCard } from './offer-card'; export { OffersList } from './offers-list'; export { FavoriteCard } from './favorite-card'; export { CommentForm } from './comment-form'; +export { Map } from './map'; +export { ErrorBoundary } from './error-boundary'; +export { Tabs } from './tabs'; +export { Tab } from './tab'; +export { CityOffers } from './city-offers'; diff --git a/src/components/map/index.tsx b/src/components/map/index.tsx new file mode 100644 index 0000000..dcbfd9b --- /dev/null +++ b/src/components/map/index.tsx @@ -0,0 +1,70 @@ +import { useRef, useEffect, useState } from 'react'; +import { Marker, layerGroup } from 'leaflet'; +import { useMap } from '../../hooks/use-map'; +import type { Offer } from '../../types/offer'; +import type { MarkerRef } from '../../types/map'; +import { + defaultCustomIcon, + currentCustomIcon +} from '../../constants/map'; +import 'leaflet/dist/leaflet.css'; + +type Props = { + width?: string; + height?: string; + offers: Offer[]; + selectedOffer?: Offer; + activeCityName: string; +}; + +export const Map = ({ + width = '512px', + height = '100%', + offers, + activeCityName, + selectedOffer +}: Props): JSX.Element => { + const [{ city, points }] = useState(() => { + const filteredOffers = offers.filter((offer) => offer.city.name === activeCityName); + const offerData = filteredOffers.find((offer) => offer.city.name === activeCityName); + + return { + city: offerData?.city, + points: filteredOffers.map((offer) => ({ title: offer.title, ...offer.location })) + }; + }); + + const mapRef = useRef(null); + const map = useMap(mapRef, city); + const markerLayerRef = useRef(layerGroup()); + const markersRef = useRef([]); + + useEffect(() => { + if (map) { + if (!markersRef.current.length) { + points.forEach(({ latitude, longitude, title }) => { + const marker = new Marker({ lat: latitude, lng: longitude }) + .setIcon( + selectedOffer && title === selectedOffer.title + ? currentCustomIcon + : defaultCustomIcon + ) + .addTo(markerLayerRef.current); + markersRef.current.push({ marker, title }); + }); + + markerLayerRef.current.addTo(map); + } else { + markersRef.current.forEach(({ marker, title }) => { + marker.setIcon( + selectedOffer && title === selectedOffer.title + ? currentCustomIcon + : defaultCustomIcon + ); + }); + } + } + }, [map, points, selectedOffer]); + + return ; +}; diff --git a/src/components/offers-list/index.tsx b/src/components/offers-list/index.tsx index 36331dd..857f431 100644 --- a/src/components/offers-list/index.tsx +++ b/src/components/offers-list/index.tsx @@ -1,4 +1,3 @@ -import { useCallback, useState } from 'react'; import { Offer } from '../../types/offer.ts'; import { OfferCard } from '../offer-card'; import { FavoriteCard } from '../favorite-card'; @@ -6,28 +5,17 @@ import { FavoriteCard } from '../favorite-card'; type Props = { offers: Offer[]; favorites?: boolean; + onOfferHover?: (id?: Offer['id']) => void; }; -export const OffersList = ({ offers, favorites }: Props) => { - const [activeCardId, setActiveCardId] = useState(undefined); - - // eslint-disable-next-line no-console - console.log('activeCardId: ', activeCardId); - - const handleCardSetActiveStatus = useCallback((id: Offer['id'] | undefined) => { - setActiveCardId(id); - }, []); - +export const OffersList = ({ offers, favorites, onOfferHover }: Props) => { return ( - {offers.length ? offers.map((offer) => { - const { id } = offer; - - // TODO: Объединить карточки в один компонент + {offers.length && offers.map((offer) => { return favorites ? - : - ; - }) : null} + : + ; + })} ); }; diff --git a/src/components/tab/index.tsx b/src/components/tab/index.tsx new file mode 100644 index 0000000..838fc77 --- /dev/null +++ b/src/components/tab/index.tsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router-dom'; + +type Props = { + name: string; + isActive?: boolean; +}; + +// TODO: заменить ссылку +export const Tab = ({ name, isActive }: Props) => { + // TODO: Забирать значение активного таба из стора или из роутера + isActive = name === 'Amsterdam'; + + return ( + + + {name} + + + ); +}; diff --git a/src/components/tabs/index.tsx b/src/components/tabs/index.tsx new file mode 100644 index 0000000..93b6b2d --- /dev/null +++ b/src/components/tabs/index.tsx @@ -0,0 +1,21 @@ +import { Tab } from '../tab'; + +type Props = { + activeCity?: string; + citiesNames: string[]; +}; + +// eslint-disable-next-line +export const Tabs = ({ citiesNames, activeCity }: Props) => { + return ( + + + + {citiesNames && citiesNames.map((name) => { + return ; + })} + + + + ); +}; diff --git a/src/constants/map.ts b/src/constants/map.ts new file mode 100644 index 0000000..6afa26c --- /dev/null +++ b/src/constants/map.ts @@ -0,0 +1,22 @@ +import { Icon } from 'leaflet'; + +// Зум +export const DEFAULT_MAP_ZOOM = 10; + +// Маркеры +const URL_MARKER_DEFAULT = + 'https://assets.htmlacademy.ru/content/intensive/javascript-1/demo/interactive-map/pin.svg'; +const URL_MARKER_CURRENT = + 'https://assets.htmlacademy.ru/content/intensive/javascript-1/demo/interactive-map/main-pin.svg'; + +export const defaultCustomIcon = new Icon({ + iconUrl: URL_MARKER_DEFAULT, + iconSize: [40, 40], + iconAnchor: [20, 40] +}); + +export const currentCustomIcon = new Icon({ + iconUrl: URL_MARKER_CURRENT, + iconSize: [40, 40], + iconAnchor: [20, 40] +}); diff --git a/src/hooks/use-map.ts b/src/hooks/use-map.ts new file mode 100644 index 0000000..39becb3 --- /dev/null +++ b/src/hooks/use-map.ts @@ -0,0 +1,41 @@ +import { useEffect, useState, MutableRefObject, useRef } from 'react'; +import { Map, TileLayer } from 'leaflet'; +import { OfferCity } from '../types/offer'; +import { DEFAULT_MAP_ZOOM } from '../constants/map'; + +export const useMap = ( + mapRef: MutableRefObject, + city?: OfferCity +): Map | null => { + const [map, setMap] = useState(null); + const isRenderedRef = useRef(false); + + useEffect(() => { + if (mapRef.current && !isRenderedRef.current && city) { + const { latitude, longitude } = city.location; + + const instance = new Map(mapRef.current, { + center: { + lat: latitude, + lng: longitude + }, + zoom: DEFAULT_MAP_ZOOM + }); + + const layer = new TileLayer( + 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + { + attribution: + '© OpenStreetMap contributors © CARTO' + } + ); + + instance.addLayer(layer); + + setMap(instance); + isRenderedRef.current = true; + } + }, [mapRef, city]); + + return map; +}; diff --git a/src/mocks/offers.ts b/src/mocks/offers.ts index e710de0..9d410ab 100644 --- a/src/mocks/offers.ts +++ b/src/mocks/offers.ts @@ -3,7 +3,7 @@ import { Offer } from '../types/offer.ts'; export const offersMock: Offer[] = [ { id: '1', - title: 'Beautiful & luxurious studio at great location', + title: 'First Location', type: 'apartment', price: 120, city: { @@ -15,8 +15,8 @@ export const offersMock: Offer[] = [ } }, location: { - latitude: 52.35514938496378, - longitude: 4.673877537499948, + latitude: 52.3909553943508, + longitude: 4.85309666406198, zoom: 8 }, isFavorite: false, @@ -26,7 +26,7 @@ export const offersMock: Offer[] = [ }, { id: '2', - title: 'Beautiful & luxurious studio at great location', + title: 'Second Location', type: 'apartment', price: 120, city: { @@ -38,8 +38,8 @@ export const offersMock: Offer[] = [ } }, location: { - latitude: 52.35514938496378, - longitude: 4.673877537499948, + latitude: 52.3609553943508, + longitude: 4.85309666406198, zoom: 8 }, isFavorite: false, @@ -49,11 +49,11 @@ export const offersMock: Offer[] = [ }, { id: '3', - title: 'Beautiful & luxurious studio at great location', + title: 'Third Location', type: 'apartment', price: 120, city: { - name: 'Cologne', + name: 'Amsterdam', location: { latitude: 52.35514938496378, longitude: 4.673877537499948, @@ -61,8 +61,8 @@ export const offersMock: Offer[] = [ } }, location: { - latitude: 52.35514938496378, - longitude: 4.673877537499948, + latitude: 52.3909553943508, + longitude: 4.929309666406198, zoom: 8 }, isFavorite: false, @@ -72,11 +72,11 @@ export const offersMock: Offer[] = [ }, { id: '4', - title: 'Beautiful & luxurious studio at great location', + title: 'Fourth Location', type: 'apartment', price: 120, city: { - name: 'Cologne', + name: 'Amsterdam', location: { latitude: 52.35514938496378, longitude: 4.673877537499948, @@ -84,8 +84,8 @@ export const offersMock: Offer[] = [ } }, location: { - latitude: 52.35514938496378, - longitude: 4.673877537499948, + latitude: 52.3809553943508, + longitude: 4.939309666406198, zoom: 8 }, isFavorite: false, diff --git a/src/pages/favorites-page/index.tsx b/src/pages/favorites-page/index.tsx index a4f70f9..cd1854a 100644 --- a/src/pages/favorites-page/index.tsx +++ b/src/pages/favorites-page/index.tsx @@ -7,7 +7,7 @@ type Props = { } export const FavoritesPage = ({ offers }: Props) => { - const citiesSplittedByCity = useMemo(() => { + const offersSplittedByCity = useMemo(() => { return offers.reduce((acc, currOffer) => { if (!acc[currOffer.city.name]) { acc[currOffer.city.name] = []; @@ -24,7 +24,7 @@ export const FavoritesPage = ({ offers }: Props) => { - + @@ -53,7 +53,7 @@ export const FavoritesPage = ({ offers }: Props) => { Saved listing - {Object.keys(citiesSplittedByCity).length && Object.entries(citiesSplittedByCity).map(([cityName, offersForCity]) => ( + {Object.keys(offersSplittedByCity).length && Object.entries(offersSplittedByCity).map(([cityName, offersForCity]) => ( @@ -62,7 +62,7 @@ export const FavoritesPage = ({ offers }: Props) => { - + ))} @@ -71,7 +71,7 @@ export const FavoritesPage = ({ offers }: Props) => { diff --git a/src/pages/main-page/index.tsx b/src/pages/main-page/index.tsx index d008fba..ebf7d79 100644 --- a/src/pages/main-page/index.tsx +++ b/src/pages/main-page/index.tsx @@ -1,15 +1,16 @@ import { Link } from 'react-router-dom'; -import { OffersList } from '../../components'; +import { CityOffers } from '../../components'; import { AppRoutes } from '../../constants/routes.ts'; -import { Offer } from '../../types/offer.ts'; +import type { Offer } from '../../types/offer.ts'; +import { Tabs } from '../../components'; type Props = { offers: Offer[]; }; -export const MainPage = ({ offers }: Props) => { - const offersAmount = offers.length; +const CITIES_NAMES = ['Paris', 'Cologne', 'Brussels', 'Amsterdam', 'Hamburg', 'Dusseldorf']; +export const MainPage = ({ offers }: Props) => { return ( @@ -17,7 +18,7 @@ export const MainPage = ({ offers }: Props) => { - + @@ -43,69 +44,8 @@ export const MainPage = ({ offers }: Props) => { Cities - - - - - - Paris - - - - - Cologne - - - - - Brussels - - - - - Amsterdam - - - - - Hamburg - - - - - Dusseldorf - - - - - - - - - Places - {offersAmount} places to stay in Amsterdam - - Sort by - - Popular - - - - - - Popular - Price: low to high - Price: high to low - Top rated first - - - - - - - - - + + ); diff --git a/src/types/map.ts b/src/types/map.ts new file mode 100644 index 0000000..6d32d17 --- /dev/null +++ b/src/types/map.ts @@ -0,0 +1,8 @@ +import type { Marker } from 'leaflet'; +import { OfferLocation } from './offer'; + +export type Point = { + title: string; +} & OfferLocation; + +export type MarkerRef = { marker: Marker; title: string }; diff --git a/src/types/offer.ts b/src/types/offer.ts index 9814789..92698f0 100644 --- a/src/types/offer.ts +++ b/src/types/offer.ts @@ -1,12 +1,12 @@ type OfferType = 'apartment'; -type OfferLocation = { +export type OfferLocation = { latitude: number; longitude: number; zoom: number; }; -type OfferCity = { +export type OfferCity = { name: string; location: OfferLocation; };