| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- 'use client'
- import { useEffect, useRef, useState, useCallback } from 'react'
- import { createLeafletMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
- import { useAuth } from '@/hooks/useAuth'
- import { useWebSocket } from '@/hooks/useWebSocket'
- import type { Place } from '@/types'
- import { api } from '@/lib/api'
- /**
- * Основной компонент карты. Инициализирует Яндекс.Карту, загружает места,
- * отображает маркеры и показывает карточку места при клике.
- * Для неавторизованных пользователей через WebSocket показывает точки других посетителей на карте.
- */
- export default function MapView() {
- const containerRef = useRef<HTMLDivElement>(null)
- const mapRef = useRef<MapProvider | null>(null)
- const { user, isLoading } = useAuth()
- const [places, setPlaces] = useState<Place[]>([])
- const [selectedPlace, setSelectedPlace] = useState<Place | null>(null)
- const [mapError, setMapError] = useState(false)
- const fetchPlaces = useCallback(async (bounds?: MapBounds) => {
- try {
- const params = new URLSearchParams()
- if (bounds) {
- params.set('bounds', `${bounds.swLat},${bounds.swLng},${bounds.neLat},${bounds.neLng}`)
- }
- params.set('limit', '50')
- const res = await api.get<{ data: Place[] }>(`/places?${params}`)
- setPlaces(res.data)
- } catch {
- // Ошибка загрузки мест — карта продолжает работать с текущими данными
- }
- }, [])
- /** Реф для актуальной версии fetchPlaces — предотвращает проблему устаревшего замыкания */
- const fetchPlacesRef = useRef(fetchPlaces)
- fetchPlacesRef.current = fetchPlaces
- useEffect(() => {
- if (!containerRef.current || mapRef.current) return
- const provider = createLeafletMapProvider()
- mapRef.current = provider
- provider.init(containerRef.current, [37.6173, 55.7558], 10).then(() => {
- if (!provider.isReady()) {
- setMapError(true)
- return
- }
- if (navigator.geolocation) {
- navigator.geolocation.getCurrentPosition(
- (pos) => {
- provider.setCenter(pos.coords.latitude, pos.coords.longitude)
- provider.addMarker('_user_location', pos.coords.latitude, pos.coords.longitude, {
- type: 'location',
- })
- },
- () => {},
- )
- }
- provider.onMove((center, zoom, bounds) => {
- fetchPlacesRef.current(bounds)
- })
- const b = provider.getBounds?.()
- if (b) fetchPlaces(b)
- }).catch(() => {
- setMapError(true)
- })
- return () => {
- provider.destroy()
- mapRef.current = null
- }
- }, [])
- useEffect(() => {
- const provider = mapRef.current
- if (!provider || !provider.isReady()) return
- const isGuest = !user
- places.forEach((place) => {
- provider.addMarker(place.id, place.lat, place.lng, {
- type: place.type as 'place' | 'studio',
- title: isGuest ? undefined : place.title,
- onClick: isGuest ? undefined : () => setSelectedPlace(place),
- ghost: isGuest,
- })
- })
- return () => {
- places.forEach((place) => provider.removeMarker(place.id))
- }
- }, [places])
- const { visitors } = useWebSocket(!user)
- useEffect(() => {
- const provider = mapRef.current
- if (!provider || !provider.isReady()) return
- visitors.forEach((v, i) => {
- provider.addMarker(`ws-visitor-${i}`, v.lat, v.lng, { type: 'visitor' })
- })
- return () => {
- visitors.forEach((_, i) => provider.removeMarker(`ws-visitor-${i}`))
- }
- }, [visitors])
- return (
- <div className="relative h-full w-full">
- {mapError ? (
- <div className="flex h-full w-full items-center justify-center">
- <div className="text-center">
- <p className="mb-2 text-lg text-white/60">Карта временно недоступна</p>
- <p className="text-sm text-white/40">Попробуйте обновить страницу позже</p>
- </div>
- </div>
- ) : (
- <div ref={containerRef} className="z-0 h-full w-full" />
- )}
- {!user && !isLoading && !mapError && (
- <div className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white/10 px-6 py-3 text-center text-white backdrop-blur-md">
- <p className="text-sm">Зарегистрируйтесь, чтобы исследовать места для фотосъёмок</p>
- </div>
- )}
- {selectedPlace && (
- <PlaceCardModal
- place={selectedPlace}
- onClose={() => setSelectedPlace(null)}
- />
- )}
- </div>
- )
- }
- /**
- * Модальная карточка места, отображаемая в нижней части экрана.
- * Показывает название, адрес, описание, теги и рейтинг места.
- * @param place - Данные выбранного места
- * @param onClose - Функция закрытия карточки
- */
- function PlaceCardModal({ place, onClose }: { place: Place; onClose: () => void }) {
- return (
- <div className="absolute bottom-0 left-0 right-0 z-20 max-h-[60vh] overflow-y-auto rounded-t-2xl bg-[#1e293b] p-6 shadow-xl">
- <button onClick={onClose} className="mb-4 text-white/60 hover:text-white">
- ✕
- </button>
- <h2 className="mb-2 text-xl font-bold text-white">{place.title}</h2>
- {place.address && (
- <p className="mb-2 text-sm text-white/60">{place.address}</p>
- )}
- {place.description && (
- <p className="mb-4 text-sm text-white/80">{place.description}</p>
- )}
- <div className="mb-4 flex flex-wrap gap-2">
- {place.tags?.map((tag) => (
- <span key={tag.id} className="rounded-full bg-white/10 px-3 py-1 text-xs text-white/80">
- {tag.name}
- </span>
- ))}
- </div>
- <div className="flex items-center gap-4 text-sm text-white/60">
- <span>★ {place.rating?.toFixed(1) || 'Нет оценок'}</span>
- {place.type === 'studio' && place.pricing?.hourly_rate && (
- <span>{place.pricing.hourly_rate} ₽/час</span>
- )}
- </div>
- </div>
- )
- }
|