added new start page

This commit is contained in:
SlimusMinus
2026-07-06 23:59:48 +03:00
parent 538a7f65eb
commit abdb7c021c
17 changed files with 705 additions and 24 deletions

View File

@@ -1,118 +0,0 @@
import { useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useChats } from "../hooks/useChats";
import { useAuthContext } from "../context/AuthContext";
import { useUnread } from "../context/UnreadContext";
import { getPostForChat } from "../stores/chatPostStore";
import { API_URL } from "../config/authConstants";
import { useDeleteChat } from "../hooks/useDeleteChat";
import TrashIcon from "./icons/TrashIcon";
export default function ChatListPage() {
const navigate = useNavigate();
const { chats, loading, fetchChats } = useChats();
const { isAuthenticated } = useAuthContext();
const { updateFromChats, unreadMap } = useUnread();
const { deleteChat } = useDeleteChat();
useEffect(() => {
if (isAuthenticated) {
fetchChats();
}
}, [isAuthenticated, fetchChats]);
useEffect(() => {
if (chats.length > 0) {
updateFromChats(chats);
}
}, [chats, updateFromChats]);
const handleDeleteChat = useCallback(async (chatId) => {
if (!window.confirm("Вы уверены, что хотите удалить этот чат?")) return;
const ok = await deleteChat(chatId);
if (ok) fetchChats();
}, [deleteChat, fetchChats]);
if (!isAuthenticated) {
return (
<div className="f-app">
<div className="chat-page">
<div className="chat-header">
<button className="chat-back-btn" onClick={() => navigate("/feed")}>
←
</button>
<div className="chat-header-title">Чаты</div>
<div />
</div>
<div className="chat-empty-full">Войдите, чтобы видеть чаты</div>
</div>
</div>
);
}
return (
<div className="f-app">
<div className="chat-page">
<div className="chat-header">
<button className="chat-back-btn" onClick={() => navigate("/feed")}>
←
</button>
<div className="chat-header-title">Чаты</div>
<div />
</div>
<div className="chat-list">
{loading && (
<div className="chat-empty-full">Загрузка...</div>
)}
{!loading && chats.length === 0 && (
<div className="chat-empty-full">У вас пока нет чатов</div>
)}
{!loading && chats.map((chat) => {
const post = getPostForChat(chat.id);
const title = post?.title || chat.otherUser?.name || "Пользователь";
const imgSrc = post?.images?.[0] ? `${API_URL}${post.images[0]}` : null;
const emoji = post?.emoji;
const unreadCount = unreadMap[chat.id] ?? chat.unreadCount ?? chat.unreadMessages ?? 0;
return (
<div
key={chat.id}
className={`chat-list-item${unreadCount > 0 ? " unread" : ""}`}
onClick={() => navigate(`/chats/${chat.id}`)}
>
<div className="chat-list-avatar">
{imgSrc ? (
<img src={imgSrc} alt="" className="chat-list-avatar-img" />
) : (
emoji || chat.otherUser?.name?.[0] || "?"
)}
</div>
<div className="chat-list-info">
<div className="chat-list-name">{title}</div>
<div className="chat-list-preview">
{chat.lastMessage?.content || "Нет сообщений"}
</div>
</div>
{unreadCount > 0 && (
<div className="chat-list-unread-badge">{unreadCount > 99 ? "99+" : unreadCount}</div>
)}
<button
className="chat-list-delete-btn"
title="Удалить чат"
onClick={(e) => {
e.stopPropagation();
handleDeleteChat(chat.id);
}}
>
<TrashIcon />
</button>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,148 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useChats } from "../hooks/useChats";
import { useChatSocket } from "../hooks/useChatSocket";
import { getPostForChat } from "../stores/chatPostStore";
import { setActiveChatId } from "../stores/activeChatStore";
import { useUnread } from "../context/UnreadContext";
import { API_URL } from "../config/authConstants";
import Picker from "@emoji-mart/react";
import emojiData from "@emoji-mart/data";
export default function ChatPage() {
const { chatId } = useParams();
const navigate = useNavigate();
const { fetchMessages, markAsRead } = useChats();
const { clearChatUnread } = useUnread();
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [showEmoji, setShowEmoji] = useState(false);
const messagesEndRef = useRef(null);
const emojiPickerRef = useRef(null);
useEffect(() => {
const handleClick = (e) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) {
setShowEmoji(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const myUserId = (() => {
try { return JSON.parse(localStorage.getItem("user"))?.id; }
catch { return null; }
})();
const handleIncoming = useCallback((msg) => {
setMessages((prev) => [...prev, msg]);
}, []);
const { sendMessage, connected } = useChatSocket(chatId, handleIncoming);
useEffect(() => {
if (chatId) {
setActiveChatId(chatId);
fetchMessages(chatId).then(setMessages);
markAsRead(chatId);
clearChatUnread(chatId);
}
return () => setActiveChatId(null);
}, [chatId, fetchMessages, markAsRead, clearChatUnread]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = (e) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage(input);
setInput("");
};
const handleEmojiSelect = (emoji) => {
setInput((prev) => prev + emoji.native);
};
const post = getPostForChat(chatId);
const postImg = post?.images?.[0] ? `${API_URL}${post.images[0]}` : null;
return (
<div className="f-app">
<div className="chat-page">
<div className="chat-header">
<button className="chat-back-btn" onClick={() => navigate("/chats")}>
←
</button>
<div className="chat-header-avatar">
{postImg ? (
<img src={postImg} alt="" className="chat-header-avatar-img" />
) : (
post?.emoji || "📋"
)}
</div>
<div className="chat-header-title">{post?.title || "Чат"}</div>
<div className={`chat-status${connected ? " connected" : ""}`}>
{connected ? "В сети" : "Отключено"}
</div>
</div>
<div className="chat-messages">
{messages.length === 0 && (
<div className="chat-empty">Нет сообщений</div>
)}
{messages.map((m) => (
<div
key={m.id}
className={`chat-bubble${m.senderId === myUserId ? " mine" : ""}`}
>
{m.content}
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-area">
{showEmoji && (
<div className="chat-emoji-picker" ref={emojiPickerRef}>
<Picker
data={emojiData}
onEmojiSelect={handleEmojiSelect}
theme="dark"
previewPosition="none"
skinTonePosition="none"
searchPosition="sticky"
maxSearchResults={30}
perLine={8}
/>
</div>
)}
<form onSubmit={handleSend} className="chat-input-row">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Сообщение..."
className="chat-input"
/>
<button
type="button"
className={`chat-emoji-btn${showEmoji ? " active" : ""}`}
onClick={() => setShowEmoji((v) => !v)}
>
😊
</button>
<button
type="submit"
className="chat-send-btn"
disabled={!connected}
>
➤
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,533 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import "../styles/index.css";
import { API_URL } from "../config/authConstants";
import Sidebar from "./Sidebar";
import RightSidebar from "./RightSidebar";
import PostOnFeed from "./PostOnFeed";
import CreateUpdatePost from "./CreateUpdatePost";
import ImageViewer from "./ImageViewer";
import MenuIcon from "./icons/MenuIcon";
import ProfileAvatar from "./profile/ProfileAvatar";
import { ProfileModal } from "./profile";
import AuthModal from "./AuthModal";
import RulesModal from "./RulesModal";
import { useAuthContext } from "../context/AuthContext";
import { useDeletePost } from "../hooks/useDeletePost";
import { useFavorites } from "../hooks/useFavorites";
import { usePostStatus } from "../hooks/usePostStatus";
/**
* MainFeed — корневой компонент приложения.
* Управляет состоянием ленты, модалок и сайдбара;
* остальные компоненты получают данные через пропсы.
*/
export default function MainFeed() {
const navigate = useNavigate();
const { user, token, isAuthenticated } = useAuthContext();
const { deletePost } = useDeletePost();
const { toggleFavorite, loadFavorites } = useFavorites();
const { markAsFound } = usePostStatus();
// Открыт ли левый сайдбар (мобильный слайд-аут)
const [sidebarOpen, setSidebarOpen] = useState(false);
// Активный пункт навигации в сайдбаре
const [activeNav, setActiveNav] = useState("Главная");
// Фильтр по типу объявления: null (все) | "loss" | "found"
const [typeFilter, setTypeFilter] = useState(null);
// Поисковый запрос (фильтр по title / description)
const [searchQuery, setSearchQuery] = useState("");
// Открыта ли модалка создания нового объявления
const [modalOpen, setModalOpen] = useState(false);
// Объявление, которое сейчас редактируется (null — модалка закрыта)
const [editAd, setEditAd] = useState(null);
// Открыто ли модальное окно редактирования профиля
const [profileModalOpen, setProfileModalOpen] = useState(false);
// Открыто ли модальное окно аутентификации (login / register)
const [authModalOpen, setAuthModalOpen] = useState(false);
// Открыто ли модальное окно правил площадки
const [rulesModalOpen, setRulesModalOpen] = useState(false);
// Просмотр изображений: null — закрыто, { images, index } — открыто
const [viewerData, setViewerData] = useState(null);
// Массив объявлений из API (основная лента)
const [ads, setAds] = useState([]);
// Объявления текущего пользователя (загружаются отдельно по userId)
const [myAds, setMyAds] = useState([]);
// Set id объявлений, которые пользователь лайкнул (только локально)
const [liked, setLiked] = useState(new Set());
// Состояния загрузки и ошибки для ленты
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Пагинация ленты
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const PAGE_SIZE = 20;
// Данные профиля — из контекста авторизации (или пустой объект для гостя)
const profileData = useMemo(() => {
if (!user) return {};
// Если бэк присылает имя одним полем "name" — разбиваем на firstName/lastName
// Если бэк присылает отдельные поля firstName/lastName — используем их напрямую
const nameParts = (user.name || "").trim().split(/\s+/);
const firstName = user.firstName || nameParts[0] || "";
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
// Аватар может быть относительным путём (/uploads/avatars/...) — добавляем API_URL
const rawAvatar = user.avatar || null;
const avatar = rawAvatar && rawAvatar.startsWith("/")
? `${API_URL}${rawAvatar}`
: rawAvatar;
return {
login: user.login || "",
firstName,
lastName,
email: user.email || "",
phone: user.phone || "",
avatar,
id: user.id,
};
}, [user]);
/** Загружает ленту объявлений с бэкенда. */
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
if (pageNum === 0) {
setLoading(true);
} else {
setLoadingMore(true);
}
setError("");
try {
const params = new URLSearchParams({
page: pageNum,
size: PAGE_SIZE,
});
if (search) params.set("search", search);
const res = await fetch(`${API_URL}/api/v1/posts?${params}`);
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
const data = await res.json();
const content = data.content ?? [];
const normalizedAds = content.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setAds(prev => append ? [...prev, ...normalizedAds] : normalizedAds);
setHasMore(!data.last); // data.last — стандартное поле Spring Page
setPage(pageNum);
} catch (err) {
setError(err.message || "Не удалось загрузить объявления");
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
// Загружаем лайки пользователя при авторизации
useEffect(() => {
if (isAuthenticated && token) {
loadFavorites(token).then(favoriteIds => {
setLiked(new Set(favoriteIds));
});
} else {
setLiked(new Set());
}
}, [isAuthenticated, token, loadFavorites]);
// Первичная загрузка при монтировании компонента
useEffect(() => {
const timer = setTimeout(() => {
loadFeed(0, searchQuery, false);
}, 400);
return () => clearTimeout(timer);
}, [searchQuery, loadFeed]);
/** Загружает объявления текущего пользователя по userId с бэкенда. */
const loadMyPosts = useCallback(async () => {
if (!user?.id) return;
setLoading(true);
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/posts/${user.id}`);
if (res.status === 404) {
// У пользователя пока нет объявлений — это нормальная ситуация
setMyAds([]);
return;
}
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
const data = await res.json();
const posts = Array.isArray(data) ? data : (data.items ?? []);
const normalizedPosts = posts.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setMyAds(normalizedPosts);
} catch (err) {
setError(err.message || "Не удалось загрузить ваши объявления");
} finally {
setLoading(false);
}
}, [user?.id]);
/**
* Переключает лайк на объявлении через API.
*/
const toggleLike = useCallback(async (id) => {
if (!isAuthenticated || !token) {
setAuthModalOpen(true);
return;
}
const success = await toggleFavorite(id, token);
if (success) {
// Перезагружаем ленту для получения обновлённых данных
loadFeed();
if (activeNav === "Мои объявления") {
loadMyPosts();
}
}
}, [isAuthenticated, token, toggleFavorite, loadFeed, loadMyPosts, activeNav]);
/** Удаляет объявление и перезагружает ленту */
const handleDelete = useCallback(async (postId) => {
if (!window.confirm("Вы уверены, что хотите удалить это объявление?")) {
return;
}
const success = await deletePost(postId);
if (success) {
loadFeed();
loadMyPosts();
}
}, [deletePost, loadFeed, loadMyPosts]);
/** Отмечает объявление как "Вещь найдена" и перезагружает ленту */
const handleMarkFound = useCallback(async (postId) => {
if (!window.confirm("Отметить это объявление как \"Вещь найдена\"?")) {
return;
}
const success = await markAsFound(postId, token);
if (success) {
loadFeed();
loadMyPosts();
}
}, [markAsFound, token, loadFeed, loadMyPosts]);
// После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) {
setViewerData({ images, index, ad });
}
}, []);
/**
* Обработчик нажатия «Разместить объявление».
* Если пользователь не авторизован — открывает модалку входа.
*/
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setModalOpen(true);
}
}, [isAuthenticated]);
/**
* Обработчик редактирования — разрешён только автору объявления.
*/
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setEditAd(ad);
}
}, [isAuthenticated]);
// Сентинел для бесконечного скролла — подгружает следующую страницу
const observerRef = useRef(null);
const sentinelRef = useCallback(node => {
if (loadingMore) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadFeed(page + 1, searchQuery, true);
}
}, { rootMargin: "200px" });
if (node) observerRef.current.observe(node);
}, [loadingMore, hasMore, loading, page, searchQuery, loadFeed]);
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId
useEffect(() => {
if (activeNav === "Мои объявления" && isAuthenticated && user?.id) {
loadMyPosts();
}
}, [activeNav, isAuthenticated, user?.id, loadMyPosts]);
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
const filteredAds = (() => {
let result;
if (activeNav === "Мои объявления") {
// Используем объявления, загруженные с бэкенда по userId
result = myAds;
} else if (activeNav === "Главная") {
result = ads;
} else {
result = ads.filter(ad =>
activeNav === "Вещи"
? ["Вещь", "Другое"].includes(ad.category)
: activeNav === "Питомцы"
? ad.category === "Питомец"
: false
);
}
// Применяем поисковый фильтр по title / description
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
// Применяем фильтр по типу объявления
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
return result;
})();
/**
* Рендерит содержимое ленты в зависимости от текущего состояния:
* — спиннер во время загрузки
* — сообщение об ошибке
* — заглушка при пустой ленте
* — список карточек объявлений
*/
const renderContent = () => {
if (loading) {
return (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
Загружаем объявления...
</div>
</div>
);
}
if (error) {
return (
<div className="f-center">
<div className="f-center-icon">⚠️</div>
<div className="f-center-title">Не удалось загрузить ленту</div>
<div className="f-center-sub">{error}</div>
</div>
);
}
if (!filteredAds.length) {
const emptyMsg = activeNav === "Мои объявления"
? { title: "У вас нет объявлений", sub: "Создайте первое — нажмите кнопку выше" }
: { title: "Объявлений пока нет", sub: "Будьте первым — разместите объявление<br />о потере или находке" };
return (
<div className="f-center">
<div className="f-center-icon">🔍</div>
<div className="f-center-title">{emptyMsg.title}</div>
<div className="f-center-sub"
dangerouslySetInnerHTML={{ __html: emptyMsg.sub }}
/>
</div>
);
}
return filteredAds.map(ad => (
<PostOnFeed
key={ad.id}
ad={ad}
liked={liked.has(ad.id)}
onLike={toggleLike}
onEdit={activeNav === "Мои объявления" ? handleEdit : undefined}
onDelete={activeNav === "Мои объявления" ? handleDelete : undefined}
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
isOwner={activeNav === "Мои объявления"}
onImageClick={handleImageClick}
onAuthRequired={() => setAuthModalOpen(true)}
/>
));
};
return (
<div className="f-app">
{/* Левый сайдбар с навигацией */}
<Sidebar
open={sidebarOpen}
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
onNavClick={setActiveNav}
isAuthenticated={isAuthenticated}
onLoginClick={() => setAuthModalOpen(true)}
onRulesClick={() => setRulesModalOpen(true)}
onReviewsClick={() => navigate("/reviews")}
/>
<div className="f-main">
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
<div className="f-topbar">
<button className="f-menu-btn"
onClick={() => setSidebarOpen(v => !v)}>
<MenuIcon/>
</button>
<div className="f-topbar-center">
<button className="f-post-btn" onClick={handleCreatePost}>
<span style={{
fontSize: "1.15rem",
fontWeight: 900,
lineHeight: 1
}}>+</span>
Разместить объявление
</button>
</div>
{/* Иконка профиля в правом углу */}
{isAuthenticated ? (
<div className="f-topbar-profile"
onClick={() => setProfileModalOpen(true)}>
<ProfileAvatar avatar={profileData.avatar}
name={`${profileData.firstName
|| ""} ${profileData.lastName || ""}`}/>
</div>
) : (
<button className="f-login-btn"
onClick={() => setAuthModalOpen(true)}>
Войти
</button>
)}
</div>
{/* Строка поиска */}
<div className="f-search-bar">
<svg className="f-search-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input
className="f-search-input"
type="text"
placeholder="Поиск по названию или описанию..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button className="f-search-clear"
onClick={() => setSearchQuery("")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" width="18" height="18">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
)}
</div>
{/* Фильтр по типу объявления */}
<div className="f-filter-bar">
<button
className={`f-filter-btn${typeFilter === "loss" ? " active"
: ""}`}
onClick={() => setTypeFilter("loss")}
>
🔴 Потери
</button>
<button
className={`f-filter-btn${typeFilter === null ? " active"
: ""}`}
onClick={() => setTypeFilter(null)}
>
Все объявления
</button>
<button
className={`f-filter-btn${typeFilter === "found" ? " active"
: ""}`}
onClick={() => setTypeFilter("found")}
>
🟢 Находки
</button>
</div>
{/* Основная область: лента + правый сайдбар */}
<div className="f-wrap">
<div className="f-feed">
{renderContent()}
{activeNav === "Главная" && !loading && (
<div ref={sentinelRef} style={{height: "1px"}}/>
)}
{loadingMore && (
<div className="f-center" style={{padding: "1rem"}}>
<div className="f-spinner"/>
</div>
)}
</div>
{/* Правый сайдбар: промо-блоки / фильтры */}
<RightSidebar/>
</div>
</div>
{/* Модалка создания нового объявления (только для авторизованных) */}
{modalOpen && isAuthenticated && (
<CreateUpdatePost
onClose={() => setModalOpen(false)}
onSuccess={handleNewAd}
/>
)}
{/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && isAuthenticated && (
<CreateUpdatePost
initialData={editAd}
onClose={() => setEditAd(null)}
onSuccess={handleEditSave}
/>
)}
{/* Модальное окно редактирования профиля */}
{profileModalOpen && isAuthenticated && (
<ProfileModal
onClose={() => setProfileModalOpen(false)}
initialData={profileData}
/>
)}
{/* Модальное окно входа / регистрации */}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
/>
)}
{/* Модальное окно правил площадки */}
{rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} />
)}
{/* Просмотр изображений */}
{viewerData && (
<ImageViewer
images={viewerData.images}
initialIndex={viewerData.index}
onClose={() => setViewerData(null)}
postData={viewerData.ad}
/>
)}
</div>
);
}

View File

@@ -1,343 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL } from "../config/authConstants";
import { useAuthContext } from "../context/AuthContext";
import MenuIcon from "./icons/MenuIcon";
import Sidebar from "./Sidebar";
import ProfileAvatar from "./profile/ProfileAvatar";
import { ProfileModal } from "./profile";
import AuthModal from "./AuthModal";
import "../styles/reviews.css";
/* ── Star rating sub-component ── */
function StarRating({ value, onChange, size = 28, readOnly = false }) {
const [hovered, setHovered] = useState(0);
return (
<div className="rv-stars">
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= (hovered || value);
return (
<svg
key={star}
width={size}
height={size}
viewBox="0 0 24 24"
className={`rv-star${filled ? " filled" : ""}${!readOnly ? " interactive" : ""}`}
onClick={() => !readOnly && onChange(star)}
onMouseEnter={() => !readOnly && setHovered(star)}
onMouseLeave={() => !readOnly && setHovered(0)}
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
fill={filled ? "#f5a623" : "none"}
stroke={filled ? "#f5a623" : "currentColor"}
strokeWidth={1.5}
/>
</svg>
);
})}
</div>
);
}
/* ── Single review card ── */
function ReviewCard({ review }) {
const date = review.createdAt
? new Date(review.createdAt).toLocaleDateString("ru-RU", {
day: "numeric", month: "long", year: "numeric",
})
: "";
return (
<div className="rv-card">
<div className="rv-card-head">
<div className="rv-card-avatar">
{review.authorName ? review.authorName.charAt(0).toUpperCase() : "?"}
</div>
<div className="rv-card-meta">
<span className="rv-card-name">{review.authorName || "Аноним"}</span>
{date && <span className="rv-card-date">{date}</span>}
</div>
<div className="rv-card-rating">
<StarRating value={review.rating} readOnly size={18} />
<span className="rv-card-rating-num">{review.rating}/5</span>
</div>
</div>
{review.text && <p className="rv-card-text">{review.text}</p>}
</div>
);
}
/* ── Review form ── */
function ReviewForm({ onSubmit, submitting }) {
const [rating, setRating] = useState(0);
const [text, setText] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (rating === 0) {
setError("Поставьте оценку");
return;
}
setError("");
onSubmit({ rating, text: text.trim() });
setRating(0);
setText("");
};
return (
<form className="rv-form" onSubmit={handleSubmit}>
<h3 className="rv-form-title">Оставить отзыв</h3>
<div className="rv-form-rating">
<span className="rv-form-label">Оценка:</span>
<StarRating value={rating} onChange={setRating} size={32} />
</div>
{error && <div className="rv-form-error">{error}</div>}
<textarea
className="rv-form-textarea"
placeholder="Напишите ваш отзыв (необязательно)..."
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={500}
rows={4}
/>
<div className="rv-form-footer">
<span className="rv-form-counter">{text.length}/500</span>
<button className="rv-form-submit" type="submit" disabled={submitting}>
{submitting ? "Отправка..." : "Отправить"}
</button>
</div>
</form>
);
}
/* ── Main Reviews page ── */
export default function ReviewsPage() {
const navigate = useNavigate();
const { user, isAuthenticated } = useAuthContext();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [activeNav] = useState("Отзывы");
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
/* Profile data for sidebar */
const profileData = user
? {
login: user.login || "",
firstName: user.firstName || (user.name || "").trim().split(/\s+/)[0] || "",
lastName: user.lastName || (user.name || "").trim().split(/\s+/).slice(1).join(" ") || "",
email: user.email || "",
phone: user.phone || "",
avatar: user.avatar && user.avatar.startsWith("/") ? `${API_URL}${user.avatar}` : user.avatar || null,
id: user.id,
}
: {};
/** Load reviews from backend */
const loadReviews = useCallback(async () => {
setLoading(true);
setError("");
try {
console.log('[Reviews] GET request to:', `${API_URL}/api/v1/reviews`);
const res = await fetch(`${API_URL}/api/v1/reviews`);
console.log('[Reviews] Response status:', res.status, res.ok);
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
const data = await res.json();
console.log('[Reviews] Response data:', data);
const list = Array.isArray(data) ? data : data.items ?? data.reviews ?? [];
console.log('[Reviews] Parsed list:', list);
setReviews(list);
} catch (err) {
console.error('[Reviews] Error:', err);
setError(err.message || "Не удалось загрузить отзывы");
// Show empty state with mock data hint
setReviews([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadReviews(); }, [loadReviews]);
/** Submit a new review */
const handleSubmitReview = useCallback(async ({ rating, text }) => {
if (!isAuthenticated) {
setAuthModalOpen(true);
return;
}
setSubmitting(true);
try {
const token = localStorage.getItem("token");
console.log('[Reviews] POST request body:', { rating, text });
const res = await fetch(`${API_URL}/api/v1/reviews`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ rating, text }),
});
console.log('[Reviews] POST response status:', res.status, res.ok);
if (res.ok) {
const responseData = await res.json();
console.log('[Reviews] POST response data:', responseData);
}
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
// Reload reviews after successful submit
await loadReviews();
} catch (err) {
// Fallback: add review locally if API is unavailable
setReviews((prev) => [
{
id: Date.now(),
authorName: profileData.firstName || "Вы",
rating,
text,
createdAt: new Date().toISOString(),
},
...prev,
]);
} finally {
setSubmitting(false);
}
}, [isAuthenticated, loadReviews, profileData.firstName]);
/** Average rating */
const avgRating = reviews.length
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
: null;
/** Rating distribution */
const distribution = [5, 4, 3, 2, 1].map((star) => ({
star,
count: reviews.filter((r) => r.rating === star).length,
pct: reviews.length
? Math.round((reviews.filter((r) => r.rating === star).length / reviews.length) * 100)
: 0,
}));
return (
<div className="f-app">
<Sidebar
open={sidebarOpen}
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
onNavClick={(label) => {
if (label === "Главная") navigate("/feed");
}}
isAuthenticated={isAuthenticated}
onLoginClick={() => setAuthModalOpen(true)}
onRulesClick={() => {}}
/>
<div className="f-main">
{/* Top bar */}
<div className="f-topbar">
<button className="f-menu-btn" onClick={() => setSidebarOpen((v) => !v)}>
<MenuIcon />
</button>
<div className="f-topbar-center">
<h1 className="rv-page-title">Отзывы</h1>
</div>
{/* Иконка профиля в правом углу */}
{isAuthenticated ? (
<div className="f-topbar-profile" onClick={() => setProfileModalOpen(true)}>
<ProfileAvatar avatar={profileData.avatar} name={`${profileData.firstName || ""} ${profileData.lastName || ""}`} />
</div>
) : (
<button className="f-login-btn" onClick={() => setAuthModalOpen(true)}>
Войти
</button>
)}
</div>
<div className="rv-layout">
{/* Left: Summary + form */}
<div className="rv-sidebar">
{/* Summary */}
<div className="rv-summary">
<div className="rv-summary-score">
{avgRating ? (
<>
<span className="rv-summary-num">{avgRating}</span>
<StarRating value={Math.round(Number(avgRating))} readOnly size={22} />
</>
) : (
<span className="rv-summary-none">Пока нет отзывов</span>
)}
</div>
<span className="rv-summary-total">
{reviews.length} {reviews.length === 1 ? "отзыв" : reviews.length < 5 ? "отзыва" : "отзывов"}
</span>
{/* Distribution bars */}
{reviews.length > 0 && (
<div className="rv-distribution">
{distribution.map(({ star, count, pct }) => (
<div key={star} className="rv-dist-row">
<span className="rv-dist-star">{star} ★</span>
<div className="rv-dist-bar">
<div className="rv-dist-fill" style={{ width: `${pct}%` }} />
</div>
<span className="rv-dist-count">{count}</span>
</div>
))}
</div>
)}
</div>
{/* Add review form */}
{isAuthenticated ? (
<ReviewForm onSubmit={handleSubmitReview} submitting={submitting} />
) : (
<div className="rv-login-prompt">
<p>Войдите, чтобы оставить отзыв</p>
<button className="rv-login-btn" onClick={() => setAuthModalOpen(true)}>
Войти
</button>
</div>
)}
</div>
{/* Right: Reviews list */}
<div className="rv-list">
{loading ? (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
Загружаем отзывы...
</div>
</div>
) : error && reviews.length === 0 ? (
<div className="f-center">
<div className="f-center-icon">💬</div>
<div className="f-center-title">Отзывов пока нет</div>
<div className="f-center-sub">Станьте первым — оставьте отзыв о площадке</div>
</div>
) : (
reviews.map((review) => <ReviewCard key={review.id} review={review} />)
)}
</div>
</div>
</div>
{/* Profile modal */}
{profileModalOpen && isAuthenticated && (
<ProfileModal onClose={() => setProfileModalOpen(false)} initialData={profileData} />
)}
{/* Auth modal */}
{authModalOpen && (
<AuthModal onClose={() => setAuthModalOpen(false)} />
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function FinalCta({ onClick }) {
return (
<section className="nk-final-cta">
<h2 className="nk-h2">Кто-то прямо сейчас ищет то, что вы нашли</h2>
<button className="nk-cta-primary" onClick={onClick}>
Открыть Находку
</button>
</section>
);
}

View File

@@ -0,0 +1,7 @@
export default function Footer() {
return (
<footer className="nk-footer">
© {new Date().getFullYear()} Находка. Возвращаем найденное.
</footer>
);
}

View File

@@ -0,0 +1,50 @@
import { useState } from "react";
import Navbar from "./Navbar";
import RadarBeacon from "./RadarBeacon";
export default function Hero({ onCtaClick, onLoginClick }) {
const [hover, setHover] = useState(false);
return (
<section className="nk-hero">
<div className="nk-grid" />
<div className="nk-glow" />
<RadarBeacon />
<Navbar onLoginClick={onLoginClick || onCtaClick} />
<div className="nk-hero-text">
<span className="nk-eyebrow">Сервис поиска пропавших питомцев и вещей</span>
<h1 className="nk-h1">
Потерял — не значит
<br />
<span className="nk-accent">потерял навсегда</span>
</h1>
<p className="nk-lead">
Разместите объявление о пропаже или находке — Находка сама сопоставит
похожие записи по фото, месту и описанию и покажет совпадения рядом с вами.
</p>
<div className="nk-cta-row">
<button
className="nk-cta-primary"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onClick={onCtaClick}
>
Начать поиск
<span
style={{
marginLeft: 8,
display: "inline-block",
transform: hover ? "translateX(3px)" : "none",
transition: "transform .2s",
}}
>
→
</span>
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
export default function Icon({ type }) {
if (type === "paw") {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<circle cx="7" cy="7" r="2.1" />
<circle cx="12.3" cy="5.2" r="2.1" />
<circle cx="17.2" cy="7.4" r="2.1" />
<path d="M12 10.5c-3.4 0-6.2 2.4-6.2 5.4 0 1.7 1.4 2.9 3 2.5 1.2-.3 2-1 3.2-1s2 .7 3.2 1c1.6.4 3-.8 3-2.5 0-3-2.8-5.4-6.2-5.4z" />
</svg>
);
}
if (type === "bag") {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 10h16l-1.4 10.5a2 2 0 0 1-2 1.5H7.4a2 2 0 0 1-2-1.5L4 10z" />
<path d="M8 10V6a4 4 0 0 1 8 0v4" />
</svg>
);
}
if (type === "key") {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="8" cy="8" r="4.2" />
<path d="M11 11l9 9M16.5 16.5l2.5-2.5M19 19l2-2" strokeLinecap="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="7" y="2.5" width="10" height="19" rx="2.2" />
<line x1="11" y1="18.5" x2="13" y2="18.5" strokeLinecap="round" />
</svg>
);
}

View File

@@ -0,0 +1,10 @@
export default function Navbar({ onLoginClick }) {
return (
<nav className="nk-nav">
<span className="nk-logo">Находка</span>
<button className="nk-nav-btn" onClick={onLoginClick}>
Войти
</button>
</nav>
);
}

View File

@@ -0,0 +1,27 @@
import Icon from "./Icon";
import { markers } from "../../config/landingConstants";
export default function RadarBeacon() {
return (
<div className="nk-radar">
<span className="nk-ring" style={{ animationDelay: "0s" }} />
<span className="nk-ring" style={{ animationDelay: "1.3s" }} />
<span className="nk-ring" style={{ animationDelay: "2.6s" }} />
<div className="nk-core" />
{markers.map((m) => (
<div
key={m.id}
className="nk-marker"
style={{
left: `${m.x}%`,
top: `${m.y}%`,
animationDelay: m.delay,
}}
>
<Icon type={m.icon} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { steps } from "../../config/landingConstants";
export default function StepsSection() {
return (
<section className="nk-steps-section">
<h2 className="nk-h2">Как это работает</h2>
<div className="nk-steps-grid">
{steps.map((s) => (
<div className="nk-step-card" key={s.n}>
<span className="nk-step-num">{s.n}</span>
<h3 className="nk-step-title">{s.title}</h3>
<p className="nk-step-text">{s.text}</p>
</div>
))}
</div>
</section>
);
}