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; const CreateUpdatePost = CreateUpdatePostLegacy as unknown as ComponentType; // 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([]); const [searchQuery, setSearchQuery] = useState(""); const profileFetchedFor = useRef(null); const feedRef = useRef(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; 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(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) => { 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) => { 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) => { 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 (
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} />
{isSearchView && ( setSidebarOpen(v => !v)} typeFilter={typeFilter} onTypeFilterChange={setTypeFilter} /> )}
{activeNav === "home" && (
)}
{isMapView ? ( ) : ( <> {/* Скролл-контейнер ленты. Маска затухания висит на .f-feed-scroll: верхние карточки, доезжающие до стеклянной шапки, плавно растворяются; высота зоны --feed-fade-height согласована с фактической высотой FeedTopBar (ResizeObserver). */}
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 && (
)} {feed.loadingMore && (
)}
{!isSearchView && ( setSidebarOpen(v => !v)} typeFilter={typeFilter} onTypeFilterChange={setTypeFilter} topBarRef={topBarRef} /> )}
modals.open("profile")} onLoginClick={() => modals.open("auth")} onNavClick={(key: string) => { setActiveNav(key); setSearchQuery(""); setSelectedCities([]); if (isMapView || isSearchView) navigate("/feed"); }} /> )}
{modals.isOpen("create") && isAuthenticated && ( )} {modals.isOpen("edit") && isAuthenticated && ( )} {modals.isOpen("profile") && isAuthenticated && ( )} {modals.isOpen("auth") && } {modals.isOpen("rules") && } {modals.isOpen("viewer") && ( )} {modals.isOpen("postDetail") && ( ) => { modals.open("viewer", { images, index, ad }); }} onLocationClick={(ad: Record) => { modals.close(); void handleLocationClick(ad); }} onAuthRequired={() => modals.open("auth")} /> )} {fullscreenAd && } {/* Ошибки удаления/отметки «найдено»: без этого тоста неудачный запрос выглядел бы как «ничего не произошло». */} { actions.clearError(); setLocationError(""); }} />
); }