diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 8fdc0f4..0f95ab8 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { API_URL, getImageUrl } from "../config/authConstants"; +import { useGeolocation, GEO_STATUS } from "../hooks/useGeolocation"; const MARKER_COLORS = { LOST: "#ff3b30", @@ -42,9 +43,12 @@ const COORD_MATCH_TOLERANCE = 0.005; export default function MapView({ focusPostId, focusLat, focusLng, onPostClick }) { const mapRef = useRef(null); const ymapRef = useRef(null); + const userMarkerRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { coords: geoCoords, status: geoStatus } = useGeolocation({ onMount: true }); + const onPostClickRef = useRef(onPostClick); onPostClickRef.current = onPostClick; @@ -157,9 +161,35 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick } destroyed.current = true; ymapRef.current?.destroy(); ymapRef.current = null; + userMarkerRef.current = null; }; }, [focusPostId, focusLat, focusLng]); + // Геолокация: если юзер разрешил и карта уже создана — центр на его месте. + // Не трогаем, если карта была открыта с фокусом на конкретном объявлении. + const hasFocus = focusPostId != null || (Number.isFinite(focusLat) && Number.isFinite(focusLng)); + useEffect(() => { + if (hasFocus) return; + if (geoStatus !== GEO_STATUS.SUCCESS || !geoCoords) return; + const map = ymapRef.current; + if (!map) return; + + const [lat, lng] = [geoCoords.latitude, geoCoords.longitude]; + map.setCenter([lat, lng], 14); + + if (userMarkerRef.current) { + userMarkerRef.current.geometry.setCoordinates([lat, lng]); + } else { + const pm = new window.ymaps.Placemark( + [lat, lng], + {}, + { preset: "islands#blueCircleIcon" } + ); + map.geoObjects.add(pm); + userMarkerRef.current = pm; + } + }, [hasFocus, geoStatus, geoCoords, loading]); + return (
{loading && ( @@ -173,6 +203,18 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick }
)}
+ {geoStatus === GEO_STATUS.LOADING && !hasFocus && ( +
Определение местоположения…
+ )} + {geoStatus === GEO_STATUS.DENIED && ( +
Доступ к геолокации запрещён
+ )} + {geoStatus === GEO_STATUS.TIMEOUT && ( +
Геолокация не определилась
+ )} + {geoStatus === GEO_STATUS.UNSUPPORTED && ( +
Геолокация недоступна
+ )}
); } \ No newline at end of file diff --git a/src/hooks/useYandexMap.js b/src/hooks/useYandexMap.js index 17a575d..333cc96 100644 --- a/src/hooks/useYandexMap.js +++ b/src/hooks/useYandexMap.js @@ -2,12 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; const YANDEX_GEOCODER_API_KEY = process.env.REACT_APP_YANDEX_GEOCODER_KEY; +const DEFAULT_CENTER = [55.751574, 37.573856]; + export function useYandexMap(enabled, initialCoords = null, initialAddress = "") { const mapRef = useRef(null); const ymapRef = useRef(null); const placemarkRef = useRef(null); const initializedRef = useRef(false); // защита от двойного вызова StrictMode const requestIdRef = useRef(0); // защита от гонки при быстрых кликах/драгах + const pendingFocusRef = useRef(null); // фокус, который ждёт, пока карта инициализируется const [coords, setCoords] = useState(initialCoords); const [address, setAddress] = useState(initialAddress ?? ""); @@ -48,60 +51,84 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "") }); }, []); + const attachPlacemarkDrag = useCallback((map, pm) => { + pm.events.add("dragend", () => { + const [lat, lng] = pm.geometry.getCoordinates(); + setCoords({ latitude: lat, longitude: lng }); + resolveAddress(lat, lng); + }); + }, [resolveAddress]); + + /** + * Устанавливает маркер и центр карты на указанные координаты. + * Если карта ещё не инициализирована — откладывает фокус до её создания. + */ + const moveTo = useCallback((lat, lng) => { + const map = ymapRef.current; + + if (!map) { + pendingFocusRef.current = { lat, lng }; + return; + } + + pendingFocusRef.current = null; + setCoords({ latitude: lat, longitude: lng }); + + if (placemarkRef.current) { + placemarkRef.current.geometry.setCoordinates([lat, lng]); + } else { + const pm = new window.ymaps.Placemark([lat, lng], {}, { draggable: true }); + map.geoObjects.add(pm); + placemarkRef.current = pm; + attachPlacemarkDrag(map, pm); + } + + map.setCenter([lat, lng], 14); + resolveAddress(lat, lng); + }, [attachPlacemarkDrag, resolveAddress]); + + const init = useCallback(() => { + if (ymapRef.current) return; + + const startCoords = coordsRef.current; + const center = startCoords + ? [startCoords.latitude, startCoords.longitude] + : DEFAULT_CENTER; + + const map = new window.ymaps.Map(mapRef.current, { + center, + zoom: 14, + controls: ["zoomControl", "geolocationControl"], + }); + ymapRef.current = map; + + if (startCoords) { + const pm = new window.ymaps.Placemark(center, {}, { draggable: true }); + map.geoObjects.add(pm); + placemarkRef.current = pm; + attachPlacemarkDrag(map, pm); + resolveAddress(startCoords.latitude, startCoords.longitude); + } + + map.events.add("click", e => { + const [lat, lng] = e.get("coords"); + moveTo(lat, lng); + }); + + // Если геолокация успела вернуть координаты раньше, чем создалась карта, + // применяем отложенный фокус сразу после инициализации. + if (pendingFocusRef.current) { + const pending = pendingFocusRef.current; + pendingFocusRef.current = null; + moveTo(pending.lat, pending.lng); + } + }, [attachPlacemarkDrag, resolveAddress, moveTo]); + useEffect(() => { if (!enabled || !mapRef.current) return; if (initializedRef.current) return; // уже инициализировали initializedRef.current = true; - const init = () => { - if (ymapRef.current) return; - - const startCoords = coordsRef.current; - const center = startCoords - ? [startCoords.latitude, startCoords.longitude] - : [55.751574, 37.573856]; - - const map = new window.ymaps.Map(mapRef.current, { - center, - zoom: 14, - controls: ["zoomControl", "geolocationControl"], - }); - ymapRef.current = map; - - if (startCoords) { - const pm = new window.ymaps.Placemark(center, {}, { draggable: true }); - map.geoObjects.add(pm); - placemarkRef.current = pm; - resolveAddress(startCoords.latitude, startCoords.longitude); - - pm.events.add("dragend", () => { - const [lat, lng] = pm.geometry.getCoordinates(); - setCoords({ latitude: lat, longitude: lng }); - resolveAddress(lat, lng); - }); - } - - map.events.add("click", e => { - const [lat, lng] = e.get("coords"); - setCoords({ latitude: lat, longitude: lng }); - resolveAddress(lat, lng); - - if (placemarkRef.current) { - placemarkRef.current.geometry.setCoordinates([lat, lng]); - } else { - const pm = new window.ymaps.Placemark([lat, lng], {}, { draggable: true }); - map.geoObjects.add(pm); - placemarkRef.current = pm; - - pm.events.add("dragend", () => { - const [la, ln] = pm.geometry.getCoordinates(); - setCoords({ latitude: la, longitude: ln }); - resolveAddress(la, ln); - }); - } - }); - }; - if (window.ymaps?.ready) { window.ymaps.ready(init); } @@ -111,12 +138,13 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "") ymapRef.current = null; placemarkRef.current = null; initializedRef.current = false; + pendingFocusRef.current = null; }; // enabled намеренно в deps — карта инициализируется/разрушается при открытии // и закрытии модалки. Начальные координаты читаются из coordsRef, чтобы // при повторном открытии маркер вставал на последний выбранный адрес. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, resolveAddress]); + }, [enabled, init]); - return { mapRef, coords, address, addressRef, loading }; -} \ No newline at end of file + return { mapRef, coords, address, addressRef, loading, moveTo }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 2253aa9..f9c5f4f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -106,6 +106,11 @@ "mapEmpty": "Location not selected yet", "selectedAddress": "Selected address", "saveLocation": "Save location", + "geoLoading": "Determining location…", + "geoSuccess": "Your location has been determined", + "geoDenied": "Location access denied", + "geoUnsupported": "Geolocation is not available in this browser", + "geoTimeout": "Unable to determine your location", "addressLabel": "Address", "addressPlaceholder": "E.g. 123 Main St, New York", "phoneLabel": "Phone number", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0f4a87d..fb9f1ea 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -106,6 +106,11 @@ "mapEmpty": "Место ещё не выбрано", "selectedAddress": "Выбранный адрес", "saveLocation": "Сохранить местоположение", + "geoLoading": "Определение местоположения…", + "geoSuccess": "Ваше местоположение определено", + "geoDenied": "Доступ к геолокации запрещён", + "geoUnsupported": "Геолокация недоступна в этом браузере", + "geoTimeout": "Не удалось определить местоположение", "addressLabel": "Адрес", "addressPlaceholder": "Например: Москва, ул. Ленина, 5", "phoneLabel": "Номер телефона", diff --git a/src/pages/CreateUpdatePost.jsx b/src/pages/CreateUpdatePost.jsx index ee76aba..7098492 100644 --- a/src/pages/CreateUpdatePost.jsx +++ b/src/pages/CreateUpdatePost.jsx @@ -8,6 +8,7 @@ import { useImageUpload, ACCEPT, MAX_SIZE_MB } from "../hooks/useImageUpload"; import { validatePostForm } from "../utils/validatePostForm"; import { formatPhone } from "../utils/formatPhone"; import { useYandexMap } from "../hooks/useYandexMap"; +import { useGeolocation, GEO_STATUS } from "../hooks/useGeolocation"; import MapModal from "./MapModal"; import ClearableField from "../components/ClearableField"; @@ -62,12 +63,29 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { ? { latitude: initialData.latitude, longitude: initialData.longitude } : null; - const { mapRef, coords, address, addressRef, loading: mapLoading } = useYandexMap( + const { mapRef, coords, address, addressRef, loading: mapLoading, moveTo } = useYandexMap( mapModalOpen, initialCoords, isEdit ? (initialData.location || "") : "" ); + const { coords: geoCoords, status: geoStatus, requestLocation: requestGeoLocation } = + useGeolocation(); + // При открытии окна карты запрашиваем геолокацию; если пользователь дал + // разрешение — автоматически центрируем карту и ставим маркер на его место. + // При отказе/недоступности API ничего не делаем — остаётся дефолтный центр. + useEffect(() => { + if (mapModalOpen) { + requestGeoLocation(); + } + }, [mapModalOpen, requestGeoLocation]); + + useEffect(() => { + if (mapModalOpen && geoStatus === GEO_STATUS.SUCCESS && geoCoords) { + moveTo(geoCoords.latitude, geoCoords.longitude); + } + }, [mapModalOpen, geoStatus, geoCoords, moveTo]); + useEffect(() => { if (address) { setForm(prev => ({ ...prev, address })); @@ -394,6 +412,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { mapRef={mapRef} address={address} mapLoading={mapLoading} + geoStatus={geoStatus} onClose={() => setMapModalOpen(false)} onSave={() => setMapModalOpen(false)} /> diff --git a/src/pages/MapModal.jsx b/src/pages/MapModal.jsx index 577f384..9f4db65 100644 --- a/src/pages/MapModal.jsx +++ b/src/pages/MapModal.jsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import CloseIcon from "../components/icons/CloseIcon"; +import { GEO_STATUS } from "../hooks/useGeolocation"; /** * MapModal — модальное окно с картой для выбора местоположения. @@ -8,10 +9,11 @@ import CloseIcon from "../components/icons/CloseIcon"; * @param {object} mapRef — ref, в который монтируется Яндекс.Карта * @param {string} address — текущий (выбранный) адрес * @param {boolean} mapLoading — идёт ли подгрузка адреса по координатам + * @param {string} geoStatus — статус геолокации (GEO_STATUS.*) * @param {function} onClose — закрыть окно без сохранения * @param {function} onSave — сохранить выбранное местоположение */ -export default function MapModal({ mapRef, address, mapLoading, onClose, onSave }) { +export default function MapModal({ mapRef, address, mapLoading, geoStatus, onClose, onSave }) { const { t } = useTranslation(); useEffect(() => { @@ -20,6 +22,14 @@ export default function MapModal({ mapRef, address, mapLoading, onClose, onSave return () => { document.body.style.overflow = prev; }; }, []); + const geoHint = + geoStatus === GEO_STATUS.SUCCESS ? t('post.geoSuccess') + : geoStatus === GEO_STATUS.LOADING ? t('post.geoLoading') + : geoStatus === GEO_STATUS.DENIED ? t('post.geoDenied') + : geoStatus === GEO_STATUS.UNSUPPORTED ? t('post.geoUnsupported') + : geoStatus === GEO_STATUS.TIMEOUT ? t('post.geoTimeout') + : null; + return (
e.target === e.currentTarget && onClose()}>
@@ -36,6 +46,10 @@ export default function MapModal({ mapRef, address, mapLoading, onClose, onSave
+ {geoHint && ( +
{geoHint}
+ )} + {/* Selected address */}
{t('post.selectedAddress')} @@ -51,3 +65,4 @@ export default function MapModal({ mapRef, address, mapLoading, onClose, onSave
); } + diff --git a/src/styles/layout.css b/src/styles/layout.css index f5adf79..7049e64 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -440,6 +440,22 @@ font-size: .9rem; color: var(--err); } +/* Geolocation hint on map view */ +.f-mapview-geo { + position: absolute; + top: 12px; left: 50%; transform: translateX(-50%); + z-index: 6; + padding: 8px 14px; border-radius: 999px; + font-size: .8rem; font-weight: 600; + background: var(--bg-surface-solid); + border: 1px solid var(--border); + color: var(--text-primary); + box-shadow: 0 2px 10px rgba(0,0,0,.12); + white-space: nowrap; +} +.f-mapview-geo-info { color: var(--muted); } +.f-mapview-geo-warn { color: var(--err); border-color: rgba(237,73,86,.35); } + /* ── Feed Wrap + Scroll ── */ .f-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; } diff --git a/src/styles/modal.css b/src/styles/modal.css index eb65f9e..8b2f386 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -463,6 +463,18 @@ select.f-m-field::selection { background: transparent; } word-break: break-word; } +/* Geolocation hint inside map modal */ +.f-map-geo { + margin-top: 10px; padding: 8px 12px; + border-radius: 10px; + font-size: .78rem; font-weight: 700; + background: rgba(var(--teal-rgb), .08); + border: 1px solid rgba(var(--teal-rgb), .25); + color: var(--teal); + text-align: center; +} + + .f-modal-sm { width: 420px; }