diff --git a/src/components/Toast.d.ts b/src/components/Toast.d.ts
new file mode 100644
index 0000000..4332da6
--- /dev/null
+++ b/src/components/Toast.d.ts
@@ -0,0 +1,22 @@
+/**
+ * Типы для Toast.
+ *
+ * Почему отдельный .d.ts, а не JSDoc в Toast.jsx: у функции с деструктурированным
+ * параметром TS сворачивает тип в `string` (JSDoc-теги применяются к самому
+ * объекту параметра, а не к его полям). Из-за этого любой .tsx-файл, рендерящий
+ * , падал с TS2322. Объявление отделяет контракт от реализации.
+ */
+type ToastType = "error" | "success";
+
+interface ToastProps {
+ /** Текст сообщения. Пустая строка/null — тост не рендерится. */
+ message: string | null | undefined;
+ type?: ToastType;
+ /** Вызывается по истечении duration и по клику на крестик. */
+ onDismiss?: () => void;
+ duration?: number;
+}
+
+declare function Toast(props: ToastProps): JSX.Element | null;
+
+export default Toast;
\ No newline at end of file
diff --git a/src/legacy.d.ts b/src/legacy.d.ts
new file mode 100644
index 0000000..acfbd2e
--- /dev/null
+++ b/src/legacy.d.ts
@@ -0,0 +1,14 @@
+/**
+ * Глобальные (asset) типы для проекта.
+ *
+ * Проект — гибрид JSX + TS: основная кодовая база пока на .js/.jsx, и TS
+ * инферирует их типы «best-effort». Новые файлы пишутся на TypeScript,
+ * а legacy-импорты в них «закорачиваются» кастами прямо в месте использования
+ * (см. MainFeed.tsx), поэтому для .js/.jsx здесь не нужно объявлений.
+ */
+declare module "*.css";
+declare module "*.scss";
+declare module "*.png";
+declare module "*.jpg";
+declare module "*.jpeg";
+declare module "*.svg";
\ No newline at end of file
diff --git a/src/locales/en.json b/src/locales/en.json
index 4a45d0a..14d8291 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -135,7 +135,8 @@
"serverError": "The server returned an error ({{status}})",
"unknownError": "Unknown error",
"nextStep": "Next",
- "backStep": "Back"
+ "backStep": "Back",
+ "mapNoCoords": "This listing has no coordinates, so there is no location to show on the map."
},
"feed": {
"loading": "Loading ads...",
@@ -435,8 +436,29 @@
"rulesRequired": "You must agree to the platform rules"
},
"calendar": {
- "months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
- "days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
+ "months": [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December"
+ ],
+ "days": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
},
"support": {
"title": "Support",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 1dbf102..55405bb 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -135,8 +135,8 @@
"serverError": "Сервер вернул ошибку {{status}}",
"unknownError": "Неизвестная ошибка",
"nextStep": "Далее",
- "backStep": "Назад"
-
+ "backStep": "Назад",
+ "mapNoCoords": "У объявления не указаны координаты — показывать место на карте нечего."
},
"feed": {
"loading": "Загружаем объявления...",
@@ -436,8 +436,29 @@
"rulesRequired": "Необходимо согласиться с правилами размещения площадки"
},
"calendar": {
- "months": ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"],
- "days": ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
+ "months": [
+ "Январь",
+ "Февраль",
+ "Март",
+ "Апрель",
+ "Май",
+ "Июнь",
+ "Июль",
+ "Август",
+ "Сентябрь",
+ "Октябрь",
+ "Ноябрь",
+ "Декабрь"
+ ],
+ "days": [
+ "Пн",
+ "Вт",
+ "Ср",
+ "Чт",
+ "Пт",
+ "Сб",
+ "Вс"
+ ]
},
"support": {
"title": "Служба поддержки",
diff --git a/src/pages/MainFeed.tsx b/src/pages/MainFeed.tsx
index 06f2720..7b7632e 100644
--- a/src/pages/MainFeed.tsx
+++ b/src/pages/MainFeed.tsx
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "../styles/index.css";
-import { getImageUrl } from "../config/authConstants";
+import { getImageUrl, API_URL } from "../config/authConstants";
+import { apiFetch } from "../api/apiClient";
import Sidebar from "../components/Sidebar";
import BottomNav from "../components/nav/BottomNav";
import RightSidebar from "../components/RightSidebar";
@@ -73,11 +74,14 @@ export default function MainFeed() {
};
const { fetchProfile } = useProfile();
const { cities } = useCities() as { cities: string[]; loading: boolean; error: string };
+ // t в этом файле отдаёт DefaultTFuncReturn (string | ReactElement), поэтому
+ // состояние ошибки типизируем как текст и приводим результат через String().
const { t } = useTranslation();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modals = useModals() as any;
const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [locationError, setLocationError] = useState("");
const [activeNav, setActiveNav] = useState("home");
const [typeFilter, setTypeFilter] = useState<"loss" | "found" | null>(null);
const [selectedCities, setSelectedCities] = useState([]);
@@ -302,18 +306,65 @@ export default function MainFeed() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapView, feed.ads, cityFeed.cityAds, feed.loading, cityFeed.cityLoading]);
- // Клик по локации на карточке — карта с фокусом на этом объявлении
- const handleLocationClick = useCallback((ad: Record) => {
+ // Приводит значение к числу координат или null.
+ //
+ // Number(null) === 0, а не NaN: без явной проверки на null/пустую строку
+ // объявление без координат уехало бы в точку (0, 0) вместо догрузки из
+ // /detail/{id}. Ноль (экватор/Гринвич) сам по себе допустим, поэтому
+ // отбрасываем только отсутствие значения.
+ const toCoordinate = (value: unknown): number | null => {
+ if (value === null || value === undefined) return null;
+ if (typeof value === "string" && value.trim() === "") return null;
+ const num = Number(value);
+ return Number.isFinite(num) ? num : null;
+ };
+
+ // Клик по локации на карточке — карта с фокусом на этом объявлении.
+ //
+ // Координаты берём из карточки, а если их там нет (объявление создано без
+ // карты, либо лента отдаёт null) — догружаем их из /api/v1/posts/detail/{id}.
+ // Без этого карта открывалась на дефолтном центре Москвы: MapView получает
+ // lat/lng из URL, а без них инициализируется на MAP_CENTER, и маркера
+ // объявления в загруженных границах нет — фокус не к чему было привязать.
+ const handleLocationClick = useCallback(async (ad: Record) => {
saveFeedState();
+ setLocationError("");
+
+ let focusLat = toCoordinate(ad.latitude);
+ let focusLng = toCoordinate(ad.longitude);
+
+ if (focusLat === null || focusLng === null) {
+ try {
+ // apiFetch, а не fetch: /detail/{id} владельцу отдаёт его собственные
+ // неопубликованные посты, а анонимному запросу бэкенд ответит 404.
+ const res = await apiFetch(`${API_URL}/api/v1/posts/detail/${ad.id}`);
+ if (res.ok) {
+ const data = await res.json();
+ const detailLat = toCoordinate(data?.latitude);
+ const detailLng = toCoordinate(data?.longitude);
+ if (detailLat !== null && detailLng !== null) {
+ focusLat = detailLat;
+ focusLng = detailLng;
+ }
+ }
+ } catch {
+ focusLat = null;
+ focusLng = null;
+ }
+ }
+
+ if (focusLat === null || focusLng === null) {
+ setLocationError(String(t('post.mapNoCoords')));
+ return;
+ }
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params = new URLSearchParams({ view: "map", post: ad.id as any });
- if (ad.latitude != null && ad.longitude != null) {
- params.set("lat", String(ad.latitude));
- params.set("lng", String(ad.longitude));
- }
+ params.set("lat", String(focusLat));
+ params.set("lng", String(focusLng));
setActiveNav("map");
navigate(`/feed?${params.toString()}`);
- }, [navigate, saveFeedState]);
+ }, [navigate, saveFeedState, t]);
const handleProfileClick = useCallback(() => {
if (!isAuthenticated) modals.open("auth");
@@ -511,7 +562,7 @@ export default function MainFeed() {
}}
onLocationClick={(ad: Record) => {
modals.close();
- handleLocationClick(ad);
+ void handleLocationClick(ad);
}}
onAuthRequired={() => modals.open("auth")}
/>
@@ -522,9 +573,12 @@ export default function MainFeed() {
{/* Ошибки удаления/отметки «найдено»: без этого тоста неудачный запрос
выглядел бы как «ничего не произошло». */}
{
+ actions.clearError();
+ setLocationError("");
+ }}
/>
,
+ * поэтому к моменту первого рендера window.ymaps может быть ещё undefined:
+ * скрипт не скачался, медленный CDN, блокировщик рекламы, офлайн.
+ *
+ * Без ожидания карта просто не создаётся — пользователь видит вечный спиннер
+ * без единой ошибки. Поэтому ждём появления API, но с таймаутом, и отдаём
+ * вызывающему явную ошибку вместо бесконечной загрузки.
+ */
+
+const DEFAULT_TIMEOUT_MS = 10000;
+const POLL_INTERVAL_MS = 100;
+
+export function whenYmapsReady(timeoutMs = DEFAULT_TIMEOUT_MS) {
+ if (window.ymaps?.ready) return Promise.resolve(window.ymaps);
+
+ return new Promise((resolve, reject) => {
+ const startedAt = Date.now();
+
+ const timer = setInterval(() => {
+ if (window.ymaps?.ready) {
+ clearInterval(timer);
+ resolve(window.ymaps);
+ return;
+ }
+ if (Date.now() - startedAt >= timeoutMs) {
+ clearInterval(timer);
+ reject(new Error("YMAPS_LOAD_FAILED"));
+ }
+ }, POLL_INTERVAL_MS);
+ });
+}