Files
nahodka-front/src/pages/MainFeed.tsx
2026-10-06 01:24:28 +03:00

597 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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