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;
}