Merge pull request #76 from SlimusMinus/fix-lan_lat_post
fixed lan and lon on post
This commit is contained in:
22
src/components/Toast.d.ts
vendored
Normal file
22
src/components/Toast.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Типы для Toast.
|
||||
*
|
||||
* Почему отдельный .d.ts, а не JSDoc в Toast.jsx: у функции с деструктурированным
|
||||
* параметром TS сворачивает тип в `string` (JSDoc-теги применяются к самому
|
||||
* объекту параметра, а не к его полям). Из-за этого любой .tsx-файл, рендерящий
|
||||
* <Toast .../>, падал с 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;
|
||||
14
src/legacy.d.ts
vendored
Normal file
14
src/legacy.d.ts
vendored
Normal file
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Служба поддержки",
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
@@ -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<string, unknown>) => {
|
||||
// Приводит значение к числу координат или 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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
modals.close();
|
||||
handleLocationClick(ad);
|
||||
void handleLocationClick(ad);
|
||||
}}
|
||||
onAuthRequired={() => modals.open("auth")}
|
||||
/>
|
||||
@@ -522,9 +573,12 @@ export default function MainFeed() {
|
||||
{/* Ошибки удаления/отметки «найдено»: без этого тоста неудачный запрос
|
||||
выглядел бы как «ничего не произошло». */}
|
||||
<Toast
|
||||
message={actions.error}
|
||||
message={actions.error || locationError}
|
||||
type="error"
|
||||
onDismiss={actions.clearError}
|
||||
onDismiss={() => {
|
||||
actions.clearError();
|
||||
setLocationError("");
|
||||
}}
|
||||
/>
|
||||
|
||||
<BottomNav
|
||||
|
||||
32
src/utils/ymapsApi.js
Normal file
32
src/utils/ymapsApi.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* public/index.html подключает API Яндекс.Карт через <script defer>,
|
||||
* поэтому к моменту первого рендера 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user