597 lines
26 KiB
TypeScript
597 lines
26 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||
import { useNavigate, useLocation } from "react-router-dom";
|
||
import "../styles/index.css";
|
||
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";
|
||
import CreateUpdatePostLegacy from "./CreateUpdatePost";
|
||
import ImageViewer from "../components/ImageViewer";
|
||
import CityFilterLegacy from "../components/CityFilter";
|
||
import { ProfileModal } from "../components/profile";
|
||
import AuthModal from "../components/AuthModal";
|
||
import RulesModal from "./RulesModal";
|
||
import FeedTopBar from "../components/feed/FeedTopBar";
|
||
import FeedSearchBar from "../components/feed/FeedSearchBar";
|
||
|
||
import FeedContent from "../components/feed/FeedContent";
|
||
import PullToRefresh from "../components/feed/PullToRefresh";
|
||
import MapView from "../components/MapView";
|
||
import PostDetailModal from "../components/PostDetailModal";
|
||
import { useAuthContext } from "../context/AuthContext";
|
||
import { useCities } from "../hooks/useCities";
|
||
import { useFeedPosts } from "../hooks/useFeedPosts";
|
||
import { useMyPosts } from "../hooks/useMyPosts";
|
||
import { useCityFeed } from "../hooks/useCityFeed";
|
||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
|
||
import { useModals } from "../hooks/useModals";
|
||
import { useFeedActions } from "../hooks/useFeedActions";
|
||
import { useTranslation } from "react-i18next";
|
||
import { CATEGORY_ITEM, CATEGORY_OTHER, CATEGORY_PET } from "../config/feedConstants";
|
||
import { useProfile } from "../hooks/useProfile";
|
||
import { useFullscreenAdTrigger } from "../hooks/useFullscreenAdTrigger";
|
||
import FullscreenAdOverlay from "../components/FullscreenAdOverlay";
|
||
import Toast from "../components/Toast";
|
||
import type { ComponentType } from "react";
|
||
|
||
// Legacy .jsx-модули: TS инферирует их типы из JS неточно (бывают «странные»
|
||
// сигнатуры). Приводим к типовому виду вручную — никакой логики не меняет.
|
||
const CityFilter = CityFilterLegacy as unknown as ComponentType<any>;
|
||
const CreateUpdatePost = CreateUpdatePostLegacy as unknown as ComponentType<any>;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
type AnyAds = any[];
|
||
|
||
/**
|
||
* MainFeed — корневой компонент ленты.
|
||
*
|
||
* Эффект «стеклянной шапки с затуханием ленты»:
|
||
* - `.f-feed` (скролл-контейнер) получает mask-image (linear-gradient),
|
||
* из-за чего верхние карточки, доезжающие под шапку, плавно растворяются;
|
||
* - высота зоны затухания управляется CSS-переменной --feed-fade-height,
|
||
* которую мы здесь анимируем через @property (см. layout.css);
|
||
* - значение берётся из РЕАЛЬНОЙ высоты FeedTopBar (ResizeObserver),
|
||
* что делает маску и шапку всегда согласованными, в т.ч. при safe-area;
|
||
* - FeedTopBar закреплён наверху экрана и всегда виден (без скрытия по скроллу).
|
||
*/
|
||
export default function MainFeed() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { user, isAuthenticated, updateUser } = useAuthContext() as {
|
||
user: {
|
||
id?: number;
|
||
name?: string;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
login?: string;
|
||
email?: string;
|
||
phone?: string;
|
||
avatar?: string;
|
||
} | null;
|
||
isAuthenticated: boolean;
|
||
updateUser: (data: unknown) => void;
|
||
};
|
||
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[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const profileFetchedFor = useRef<number | null>(null);
|
||
|
||
const feedRef = useRef<HTMLDivElement>(null);
|
||
// Состояние ленты перед переходом на карту — чтобы вернуть юзера на то же место
|
||
const feedStateRef = useRef({ scrollTop: 0, url: "/feed", activeNav: "home" });
|
||
const restoreScrollRef = useRef(false);
|
||
|
||
const isSearchView = new URLSearchParams(location.search).get("view") === "search";
|
||
const isMapView = new URLSearchParams(location.search).get("view") === "map";
|
||
// параметры фокуса карты на конкретном объявлении (?view=map&post=ID&lat=..&lng=..)
|
||
const mapFocusParams = new URLSearchParams(location.search);
|
||
const focusPostId = isMapView ? mapFocusParams.get("post") : null;
|
||
const focusLat = parseFloat(mapFocusParams.get("lat") ?? "");
|
||
const focusLng = parseFloat(mapFocusParams.get("lng") ?? "");
|
||
const hasActiveFilters = searchQuery.trim().length > 0 || selectedCities.length > 0;
|
||
const hasSearchParams = searchQuery.trim().length > 0 || selectedCities.length > 0;
|
||
const searchPrompt = isSearchView && !hasSearchParams;
|
||
|
||
// Реклама показывается на главной ленте (без фильтров/городов). Эта же
|
||
// переменная включает обычные AdSlot-карточки в ленте и полноэкранную рекламу.
|
||
const showAds = activeNav === "home" && selectedCities.length === 0 && !searchQuery.trim();
|
||
|
||
// Полноэкранная реклама по активному скроллу — работает так же на всех
|
||
// устройствах (явных ограничений desktop-only не было). Триггер: 20 секунд
|
||
// суммарного активного скролла ленты.
|
||
const { ad: fullscreenAd, close: closeFullscreenAd } = useFullscreenAdTrigger(20, showAds);
|
||
|
||
const feed = useFeedPosts();
|
||
const myPosts = useMyPosts(user?.id);
|
||
const cityFeed = useCityFeed(selectedCities);
|
||
|
||
const profileData = useMemo(() => {
|
||
if (!user) return {} as Record<string, unknown>;
|
||
const nameParts = (user.name || "").trim().split(/\s+/);
|
||
const firstName = user.firstName || nameParts[0] || "";
|
||
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
|
||
return {
|
||
login: user.login || "",
|
||
firstName,
|
||
lastName,
|
||
email: user.email || "",
|
||
phone: user.phone || "",
|
||
avatar: getImageUrl(user.avatar),
|
||
id: user.id,
|
||
};
|
||
}, [user]);
|
||
|
||
const refreshCurrentList = useCallback(() => {
|
||
if (activeNav === "myads") {
|
||
return Promise.resolve(myPosts.loadMyPosts());
|
||
}
|
||
if (selectedCities.length > 0) {
|
||
return cityFeed.searchCities(selectedCities);
|
||
}
|
||
return feed.loadFeed();
|
||
}, [activeNav, myPosts, selectedCities, cityFeed, feed]);
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const actions = useFeedActions({
|
||
refresh: refreshCurrentList,
|
||
loadMyPosts: myPosts.loadMyPosts,
|
||
onAuthRequired: () => modals.open("auth"),
|
||
isAuthenticated,
|
||
t,
|
||
} as any);
|
||
|
||
// ── «Стеклянная шапка»: закреплена наверху, высоту зоны затухания маски
|
||
// ленты подгоняем под РЕАЛЬНУЮ высоту шапки.
|
||
// Это гарантирует согласованность: где заканчивается стекло — там лента
|
||
// уже полностью непрозрачна (нет резкого разрыва).
|
||
const topBarRef = useRef<HTMLDivElement>(null);
|
||
const [measuredFade, setMeasuredFade] = useState(48);
|
||
|
||
useEffect(() => {
|
||
if (isMapView) return;
|
||
const el = topBarRef.current;
|
||
if (!el) return;
|
||
const update = () => {
|
||
// Реальная высота бара (равна 0, если бар скрыт display:none — десктоп/поиск)
|
||
setMeasuredFade(Math.round(el.getBoundingClientRect().height));
|
||
};
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [isMapView, location.search]);
|
||
|
||
const fadeHeight = measuredFade;
|
||
|
||
// Открываем модалку аутентификации если пришли с лендинга
|
||
useEffect(() => {
|
||
if (location.state?.openAuth) {
|
||
modals.open("auth");
|
||
window.history.replaceState({}, document.title);
|
||
}
|
||
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// При загрузке приложения подтягиваем свежие данные профиля (аватар и т.д.)
|
||
// с бэка, чтобы нижняя навигация и профиль отражали изменения с других
|
||
// устройств, а не устаревший кэш контекста. Дергаем один раз на пользователя.
|
||
useEffect(() => {
|
||
if (isAuthenticated && user?.id && profileFetchedFor.current !== user.id) {
|
||
profileFetchedFor.current = user.id;
|
||
fetchProfile(user.id).then((result: { success: boolean; data?: unknown }) => {
|
||
if (result.success) updateUser(result.data);
|
||
});
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isAuthenticated, user?.id]);
|
||
|
||
// При открытии профиля подтягиваем свежие данные (аватар/профиль) с бэка,
|
||
// чтобы изменения с других устройств отражались, а не брались из кэша контекста
|
||
useEffect(() => {
|
||
if (modals.isOpen("profile") && isAuthenticated && user?.id) {
|
||
fetchProfile(user.id).then((result: { success: boolean; data?: unknown }) => {
|
||
if (result.success) updateUser(result.data);
|
||
});
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [modals.isOpen("profile"), isAuthenticated, user?.id]);
|
||
|
||
// Загрузка основной ленты (не в режиме поиска)
|
||
useEffect(() => {
|
||
if (!isSearchView && selectedCities.length === 0 && !searchQuery.trim()) {
|
||
feed.loadFeed(0, "", false);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isSearchView]);
|
||
|
||
// Дебаунс поиска: только когда в search view и есть параметры
|
||
useEffect(() => {
|
||
if (!isSearchView) return;
|
||
if (!hasSearchParams) return;
|
||
const timer = setTimeout(() => {
|
||
feed.loadFeed(0, searchQuery, false);
|
||
}, 400);
|
||
return () => clearTimeout(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [searchQuery, selectedCities, isSearchView]);
|
||
|
||
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
|
||
useEffect(() => {
|
||
if (activeNav === "myads" && isAuthenticated && user?.id) {
|
||
myPosts.loadMyPosts();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeNav, isAuthenticated, user?.id]);
|
||
|
||
const sentinelRef = useInfiniteScroll(
|
||
() => feed.loadFeed(feed.page + 1, searchQuery, true),
|
||
activeNav === "home" && selectedCities.length === 0 && feed.hasMore && !feed.loading && !feed.loadingMore
|
||
);
|
||
|
||
const handleImageClick = useCallback((images: string[], index: number, ad: Record<string, unknown>) => {
|
||
if (images?.length) modals.open("viewer", { images, index, ad });
|
||
}, [modals]);
|
||
|
||
const handleCreatePost = useCallback(() => {
|
||
if (!isAuthenticated) modals.open("auth");
|
||
else modals.open("create");
|
||
}, [isAuthenticated, modals]);
|
||
|
||
const handleEdit = useCallback((ad: Record<string, unknown>) => {
|
||
if (!isAuthenticated) modals.open("auth");
|
||
else modals.open("edit", ad);
|
||
}, [isAuthenticated, modals]);
|
||
|
||
const handleHomeClick = useCallback(() => {
|
||
setActiveNav("home");
|
||
setSearchQuery("");
|
||
setSelectedCities([]);
|
||
if (isSearchView || isMapView) navigate("/feed");
|
||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||
}, [isSearchView, isMapView, navigate]);
|
||
|
||
const handleSearchClick = useCallback(() => {
|
||
setActiveNav("home");
|
||
setSearchQuery("");
|
||
setSelectedCities([]);
|
||
if (!isSearchView) navigate("/feed?view=search");
|
||
else if (isMapView) navigate("/feed?view=search");
|
||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||
}, [isSearchView, isMapView, navigate]);
|
||
|
||
// Карта с объявлениями
|
||
const saveFeedState = useCallback(() => {
|
||
feedStateRef.current = {
|
||
scrollTop: feedRef.current?.scrollTop ?? 0,
|
||
url: `${location.pathname}${location.search}`,
|
||
activeNav,
|
||
};
|
||
restoreScrollRef.current = true;
|
||
}, [location.pathname, location.search, activeNav]);
|
||
|
||
const handleMapClick = useCallback(() => {
|
||
saveFeedState();
|
||
setActiveNav("map");
|
||
if (!isMapView) navigate("/feed?view=map");
|
||
}, [isMapView, navigate, saveFeedState]);
|
||
|
||
const handleMapPostClick = useCallback((postId: number) => {
|
||
modals.open("postDetail", { postId });
|
||
}, [modals]);
|
||
|
||
// Возврат с карты на ленту — на то же место, где юзер был до открытия карты
|
||
const handleMapBack = useCallback(() => {
|
||
const { url, activeNav: prevNav } = feedStateRef.current;
|
||
setActiveNav(prevNav || "home");
|
||
navigate(url || "/feed", { replace: true });
|
||
}, [navigate]);
|
||
|
||
// Восстанавливаем прокрутку ленты после возврата с карты
|
||
useEffect(() => {
|
||
if (isMapView || !restoreScrollRef.current) return;
|
||
const el = feedRef.current;
|
||
if (el) el.scrollTop = feedStateRef.current.scrollTop;
|
||
restoreScrollRef.current = false;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isMapView, feed.ads, cityFeed.cityAds, feed.loading, cityFeed.cityLoading]);
|
||
|
||
// Приводит значение к числу координат или 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 });
|
||
params.set("lat", String(focusLat));
|
||
params.set("lng", String(focusLng));
|
||
setActiveNav("map");
|
||
navigate(`/feed?${params.toString()}`);
|
||
}, [navigate, saveFeedState, t]);
|
||
|
||
const handleProfileClick = useCallback(() => {
|
||
if (!isAuthenticated) modals.open("auth");
|
||
else modals.open("profile");
|
||
}, [isAuthenticated, modals]);
|
||
|
||
const filteredAds = useMemo((): AnyAds => {
|
||
let result: AnyAds;
|
||
if (activeNav === "myads") {
|
||
result = myPosts.myAds;
|
||
} else if (selectedCities.length > 0) {
|
||
result = cityFeed.cityAds;
|
||
} else {
|
||
result = feed.ads;
|
||
}
|
||
|
||
// Сравниваем с теми же значениями, что уходят на бэкенд (CATEGORIES),
|
||
// а не с русскими литералами: интерфейс-то может быть на английском.
|
||
if (activeNav === "items") {
|
||
result = result.filter((ad: AnyAds[number]) =>
|
||
[CATEGORY_ITEM, CATEGORY_OTHER].includes(ad.category));
|
||
} else if (activeNav === "pets") {
|
||
result = result.filter((ad: AnyAds[number]) => ad.category === CATEGORY_PET);
|
||
}
|
||
|
||
if (typeFilter) {
|
||
result = result.filter(ad => ad.type === typeFilter);
|
||
}
|
||
return result;
|
||
}, [activeNav, myPosts.myAds, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
|
||
|
||
const listLoading = activeNav === "myads"
|
||
? myPosts.loading
|
||
: feed.loading;
|
||
const listError = activeNav === "myads"
|
||
? myPosts.error
|
||
: feed.error;
|
||
|
||
return (
|
||
<div
|
||
className={`f-app${sidebarOpen ? " f-sb-open" : ""}${isSearchView ? " f-search-view" : ""}${isMapView ? " f-map-view" : ""}`}
|
||
>
|
||
<Sidebar
|
||
open={sidebarOpen}
|
||
activeNav={activeNav}
|
||
onClose={() => setSidebarOpen(false)}
|
||
onLogoClick={handleHomeClick}
|
||
onNavClick={(key: string) => {
|
||
setActiveNav(key);
|
||
setSearchQuery("");
|
||
setSelectedCities([]);
|
||
if (isMapView || isSearchView) navigate("/feed");
|
||
}}
|
||
isAuthenticated={isAuthenticated}
|
||
onLoginClick={() => modals.open("auth")}
|
||
onRulesClick={() => modals.open("rules")}
|
||
onReviewsClick={() => navigate("/reviews")}
|
||
onCreatePost={handleCreatePost}
|
||
onProfileClick={() => modals.open("profile")}
|
||
profileData={profileData}
|
||
/>
|
||
|
||
<div className="f-main f-main-feed">
|
||
{isSearchView && (
|
||
<FeedTopBar
|
||
onMenuClick={() => setSidebarOpen(v => !v)}
|
||
typeFilter={typeFilter}
|
||
onTypeFilterChange={setTypeFilter}
|
||
/>
|
||
)}
|
||
<div className="f-search-panel">
|
||
<FeedSearchBar value={searchQuery} onChange={setSearchQuery} t={t} />
|
||
|
||
{activeNav === "home" && (
|
||
<div className="f-city-filter-wrap">
|
||
<CityFilter cities={cities} value={selectedCities} onChange={setSelectedCities} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="f-wrap">
|
||
{isMapView ? (
|
||
<MapView
|
||
focusPostId={focusPostId}
|
||
focusLat={focusLat}
|
||
focusLng={focusLng}
|
||
onPostClick={handleMapPostClick}
|
||
onBack={handleMapBack}
|
||
/>
|
||
) : (
|
||
<>
|
||
{/* Скролл-контейнер ленты. Маска затухания висит на .f-feed-scroll:
|
||
верхние карточки, доезжающие до стеклянной шапки, плавно
|
||
растворяются; высота зоны --feed-fade-height согласована
|
||
с фактической высотой FeedTopBar (ResizeObserver). */}
|
||
<div className="f-feed">
|
||
<div
|
||
className="f-feed-scroll"
|
||
ref={feedRef}
|
||
style={{ ["--feed-fade-height" as any]: `${fadeHeight}px` }}
|
||
>
|
||
<PullToRefresh
|
||
containerRef={feedRef}
|
||
onRefresh={refreshCurrentList}
|
||
disabled={isMapView || isSearchView || (activeNav !== "home" && activeNav !== "myads")}
|
||
/>
|
||
<FeedContent
|
||
loading={listLoading}
|
||
cityLoading={cityFeed.cityLoading}
|
||
citySearchActive={selectedCities.length > 0}
|
||
error={listError}
|
||
filteredAds={filteredAds}
|
||
activeNav={activeNav}
|
||
searchPrompt={searchPrompt}
|
||
showAds={showAds}
|
||
onEdit={handleEdit}
|
||
onDelete={actions.handleDelete}
|
||
onMarkFound={actions.handleMarkFound}
|
||
onImageClick={handleImageClick}
|
||
onLocationClick={handleLocationClick}
|
||
onAuthRequired={() => modals.open("auth")}
|
||
t={t}
|
||
/>
|
||
{activeNav === "home" && selectedCities.length === 0 && !feed.loading && (
|
||
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||
)}
|
||
{feed.loadingMore && (
|
||
<div className="f-center" style={{ padding: "1rem" }}>
|
||
<div className="f-spinner" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!isSearchView && (
|
||
<FeedTopBar
|
||
onMenuClick={() => setSidebarOpen(v => !v)}
|
||
typeFilter={typeFilter}
|
||
onTypeFilterChange={setTypeFilter}
|
||
topBarRef={topBarRef}
|
||
/>
|
||
)}
|
||
</div>
|
||
<RightSidebar
|
||
ads={filteredAds}
|
||
profileData={profileData}
|
||
isAuthenticated={isAuthenticated}
|
||
onProfileClick={() => modals.open("profile")}
|
||
onLoginClick={() => modals.open("auth")}
|
||
onNavClick={(key: string) => {
|
||
setActiveNav(key);
|
||
setSearchQuery("");
|
||
setSelectedCities([]);
|
||
if (isMapView || isSearchView) navigate("/feed");
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{modals.isOpen("create") && isAuthenticated && (
|
||
<CreateUpdatePost onClose={modals.close} onSuccess={actions.handleNewAd} />
|
||
)}
|
||
|
||
{modals.isOpen("edit") && isAuthenticated && (
|
||
<CreateUpdatePost
|
||
initialData={modals.modal.payload}
|
||
onClose={modals.close}
|
||
onSuccess={actions.handleEditSave}
|
||
/>
|
||
)}
|
||
|
||
{modals.isOpen("profile") && isAuthenticated && (
|
||
<ProfileModal onClose={modals.close} initialData={profileData} />
|
||
)}
|
||
|
||
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
|
||
|
||
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
|
||
|
||
{modals.isOpen("viewer") && (
|
||
<ImageViewer
|
||
images={modals.modal.payload.images}
|
||
initialIndex={modals.modal.payload.index}
|
||
onClose={modals.close}
|
||
postData={modals.modal.payload.ad}
|
||
/>
|
||
)}
|
||
|
||
{modals.isOpen("postDetail") && (
|
||
<PostDetailModal
|
||
postId={modals.modal.payload.postId}
|
||
onClose={modals.close}
|
||
onImageClick={(images: string[], index: number, ad: Record<string, unknown>) => {
|
||
modals.open("viewer", { images, index, ad });
|
||
}}
|
||
onLocationClick={(ad: Record<string, unknown>) => {
|
||
modals.close();
|
||
void handleLocationClick(ad);
|
||
}}
|
||
onAuthRequired={() => modals.open("auth")}
|
||
/>
|
||
)}
|
||
|
||
{fullscreenAd && <FullscreenAdOverlay ad={fullscreenAd} onClose={closeFullscreenAd} />}
|
||
|
||
{/* Ошибки удаления/отметки «найдено»: без этого тоста неудачный запрос
|
||
выглядел бы как «ничего не произошло». */}
|
||
<Toast
|
||
message={actions.error || locationError}
|
||
type="error"
|
||
onDismiss={() => {
|
||
actions.clearError();
|
||
setLocationError("");
|
||
}}
|
||
/>
|
||
|
||
<BottomNav
|
||
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
|
||
hasFilters={hasActiveFilters}
|
||
profileData={profileData}
|
||
onHome={handleHomeClick}
|
||
onMap={handleMapClick}
|
||
onCreatePost={handleCreatePost}
|
||
onSearch={handleSearchClick}
|
||
onProfile={handleProfileClick}
|
||
t={t}
|
||
/>
|
||
</div>
|
||
);
|
||
} |