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