MapView.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. 'use client'
  2. import { useEffect, useRef, useState, useCallback } from 'react'
  3. import { createLeafletMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
  4. import { useAuth } from '@/hooks/useAuth'
  5. import { useWebSocket } from '@/hooks/useWebSocket'
  6. import type { Place } from '@/types'
  7. import { api } from '@/lib/api'
  8. /**
  9. * Основной компонент карты. Инициализирует Яндекс.Карту, загружает места,
  10. * отображает маркеры и показывает карточку места при клике.
  11. * Для неавторизованных пользователей через WebSocket показывает точки других посетителей на карте.
  12. */
  13. export default function MapView() {
  14. const containerRef = useRef<HTMLDivElement>(null)
  15. const mapRef = useRef<MapProvider | null>(null)
  16. const { user, isLoading } = useAuth()
  17. const [places, setPlaces] = useState<Place[]>([])
  18. const [selectedPlace, setSelectedPlace] = useState<Place | null>(null)
  19. const [mapError, setMapError] = useState(false)
  20. const fetchPlaces = useCallback(async (bounds?: MapBounds) => {
  21. try {
  22. const params = new URLSearchParams()
  23. if (bounds) {
  24. params.set('bounds', `${bounds.swLat},${bounds.swLng},${bounds.neLat},${bounds.neLng}`)
  25. }
  26. params.set('limit', '50')
  27. const res = await api.get<{ data: Place[] }>(`/places?${params}`)
  28. setPlaces(res.data)
  29. } catch {
  30. // Ошибка загрузки мест — карта продолжает работать с текущими данными
  31. }
  32. }, [])
  33. /** Реф для актуальной версии fetchPlaces — предотвращает проблему устаревшего замыкания */
  34. const fetchPlacesRef = useRef(fetchPlaces)
  35. fetchPlacesRef.current = fetchPlaces
  36. useEffect(() => {
  37. if (!containerRef.current || mapRef.current) return
  38. const provider = createLeafletMapProvider()
  39. mapRef.current = provider
  40. provider.init(containerRef.current, [37.6173, 55.7558], 10).then(() => {
  41. if (!provider.isReady()) {
  42. setMapError(true)
  43. return
  44. }
  45. if (navigator.geolocation) {
  46. navigator.geolocation.getCurrentPosition(
  47. (pos) => {
  48. provider.setCenter(pos.coords.latitude, pos.coords.longitude)
  49. provider.addMarker('_user_location', pos.coords.latitude, pos.coords.longitude, {
  50. type: 'location',
  51. })
  52. },
  53. () => {},
  54. )
  55. }
  56. provider.onMove((center, zoom, bounds) => {
  57. fetchPlacesRef.current(bounds)
  58. })
  59. const b = provider.getBounds?.()
  60. if (b) fetchPlaces(b)
  61. }).catch(() => {
  62. setMapError(true)
  63. })
  64. return () => {
  65. provider.destroy()
  66. mapRef.current = null
  67. }
  68. }, [])
  69. useEffect(() => {
  70. const provider = mapRef.current
  71. if (!provider || !provider.isReady()) return
  72. const isGuest = !user
  73. places.forEach((place) => {
  74. provider.addMarker(place.id, place.lat, place.lng, {
  75. type: place.type as 'place' | 'studio',
  76. title: isGuest ? undefined : place.title,
  77. onClick: isGuest ? undefined : () => setSelectedPlace(place),
  78. ghost: isGuest,
  79. })
  80. })
  81. return () => {
  82. places.forEach((place) => provider.removeMarker(place.id))
  83. }
  84. }, [places])
  85. const { visitors } = useWebSocket(!user)
  86. useEffect(() => {
  87. const provider = mapRef.current
  88. if (!provider || !provider.isReady()) return
  89. visitors.forEach((v, i) => {
  90. provider.addMarker(`ws-visitor-${i}`, v.lat, v.lng, { type: 'visitor' })
  91. })
  92. return () => {
  93. visitors.forEach((_, i) => provider.removeMarker(`ws-visitor-${i}`))
  94. }
  95. }, [visitors])
  96. return (
  97. <div className="relative h-full w-full">
  98. {mapError ? (
  99. <div className="flex h-full w-full items-center justify-center">
  100. <div className="text-center">
  101. <p className="mb-2 text-lg text-white/60">Карта временно недоступна</p>
  102. <p className="text-sm text-white/40">Попробуйте обновить страницу позже</p>
  103. </div>
  104. </div>
  105. ) : (
  106. <div ref={containerRef} className="z-0 h-full w-full" />
  107. )}
  108. {!user && !isLoading && !mapError && (
  109. <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">
  110. <p className="text-sm">Зарегистрируйтесь, чтобы исследовать места для фотосъёмок</p>
  111. </div>
  112. )}
  113. {selectedPlace && (
  114. <PlaceCardModal
  115. place={selectedPlace}
  116. onClose={() => setSelectedPlace(null)}
  117. />
  118. )}
  119. </div>
  120. )
  121. }
  122. /**
  123. * Модальная карточка места, отображаемая в нижней части экрана.
  124. * Показывает название, адрес, описание, теги и рейтинг места.
  125. * @param place - Данные выбранного места
  126. * @param onClose - Функция закрытия карточки
  127. */
  128. function PlaceCardModal({ place, onClose }: { place: Place; onClose: () => void }) {
  129. return (
  130. <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">
  131. <button onClick={onClose} className="mb-4 text-white/60 hover:text-white">
  132. </button>
  133. <h2 className="mb-2 text-xl font-bold text-white">{place.title}</h2>
  134. {place.address && (
  135. <p className="mb-2 text-sm text-white/60">{place.address}</p>
  136. )}
  137. {place.description && (
  138. <p className="mb-4 text-sm text-white/80">{place.description}</p>
  139. )}
  140. <div className="mb-4 flex flex-wrap gap-2">
  141. {place.tags?.map((tag) => (
  142. <span key={tag.id} className="rounded-full bg-white/10 px-3 py-1 text-xs text-white/80">
  143. {tag.name}
  144. </span>
  145. ))}
  146. </div>
  147. <div className="flex items-center gap-4 text-sm text-white/60">
  148. <span>★ {place.rating?.toFixed(1) || 'Нет оценок'}</span>
  149. {place.type === 'studio' && place.pricing?.hourly_rate && (
  150. <span>{place.pricing.hourly_rate} ₽/час</span>
  151. )}
  152. </div>
  153. </div>
  154. )
  155. }