added files in git

This commit is contained in:
SlimusMinus
2026-07-03 00:04:56 +03:00
parent a5d457b07a
commit b924bc5f94
33 changed files with 2916 additions and 2 deletions

View File

@@ -0,0 +1,288 @@
import { useState, useCallback } from "react";
import CloseIcon from "./icons/CloseIcon";
import { useAuthContext } from "../context/AuthContext";
import { API_URL } from "../config/authConstants";
/**
* AuthModal — модальное окно входа и регистрации прямо на странице ленты.
* Открывается когда неавторизованный пользователь пытается создать объявление.
*/
export default function AuthModal({ onClose }) {
const [mode, setMode] = useState("login"); // "login" | "register"
const { login: saveAuth } = useAuthContext();
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal" style={{ width: 420 }}>
{/* Header */}
<div className="f-modal-head">
<span className="f-modal-title">
{mode === "login" ? "Вход в аккаунт" : "Регистрация"}
</span>
<button className="f-close-btn" onClick={onClose}>
<CloseIcon />
</button>
</div>
{mode === "login"
? <LoginFormInline
saveAuth={saveAuth}
onClose={onClose}
onSwitch={() => setMode("register")}
/>
: <RegisterFormInline
saveAuth={saveAuth}
onClose={onClose}
onSwitch={() => setMode("login")}
/>
}
</div>
</div>
);
}
// ─── Login form (inline modal) ──────────────────────────────────────────────
function LoginFormInline({ saveAuth, onClose, onSwitch }) {
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (!login || !password) { setError("Введите логин и пароль"); return; }
setLoading(true); setError("");
try {
const res = await fetch(`${API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || "Неверный логин или пароль");
}
const { token, user } = await res.json();
saveAuth(user, token);
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<>
<div className="f-m-group">
<label className="f-m-label">Логин</label>
<input
className="f-m-field"
type="text"
placeholder="Ваш логин"
value={login}
onChange={e => { setLogin(e.target.value); setError(""); }}
autoComplete="off"
/>
</div>
<div className="f-m-group">
<label className="f-m-label">Пароль</label>
<input
className="f-m-field"
type="password"
placeholder="Ваш пароль"
value={password}
onChange={e => { setPassword(e.target.value); setError(""); }}
onKeyDown={e => e.key === "Enter" && handleSubmit()}
/>
</div>
{error && <p className="f-err-msg" style={{ marginBottom: 10 }}>{error}</p>}
<SubmitButton
loading={loading}
label="Войти"
onClick={handleSubmit}
/>
<p style={{
textAlign: "center", marginTop: 14,
color: "rgba(255,255,255,.45)", fontSize: ".85rem"
}}>
Нет аккаунта?{" "}
<button
onClick={onSwitch}
style={{
background: "none", border: "none", color: "#2de0c8",
cursor: "pointer", fontWeight: 700, fontSize: ".85rem",
fontFamily: "var(--font)",
}}
>
Зарегистрироваться
</button>
</p>
</>
);
}
// ─── Register form (inline modal) ──────────────────────────────────────────
function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
const [form, setForm] = useState({
name: "", login: "", password: "", password2: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [errors, setErrors] = useState({});
const setField = useCallback((key, val) => {
setForm(p => ({ ...p, [key]: val }));
setErrors(p => ({ ...p, [key]: "" }));
}, []);
const validate = () => {
const e = {};
if (!form.name.trim()) e.name = "Введите имя";
if (!form.login.trim()) e.login = "Введите логин";
else if (form.login.length < 3) e.login = "Минимум 3 символа";
if (form.password.length < 6) e.password = "Минимум 6 символов";
if (form.password !== form.password2) e.password2 = "Пароли не совпадают";
setErrors(e);
return !Object.keys(e).length;
};
const handleSubmit = async () => {
if (!validate()) return;
setLoading(true); setError("");
try {
// 1. Регистрация
const regRes = await fetch(`${API_URL}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
login: form.login,
password: form.password,
}),
});
const regData = await regRes.json();
if (!regRes.ok) throw new Error(regData.message || "Ошибка регистрации");
// 2. Автоматический вход после регистрации
const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login: form.login, password: form.password }),
});
if (!loginRes.ok) throw new Error("Аккаунт создан. Войдите вручную.");
const { token, user } = await loginRes.json();
saveAuth(user, token);
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<>
<div className="f-m-group">
<label className="f-m-label">Имя</label>
<input
className={`f-m-field${errors.name ? " err" : ""}`}
type="text"
placeholder="Как вас зовут?"
value={form.name}
onChange={e => setField("name", e.target.value)}
/>
{errors.name && <p className="f-err-msg">{errors.name}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">Логин</label>
<input
className={`f-m-field${errors.login ? " err" : ""}`}
type="text"
placeholder="Придумайте логин"
value={form.login}
onChange={e => setField("login", e.target.value)}
/>
{errors.login && <p className="f-err-msg">{errors.login}</p>}
</div>
<div className="f-m-row">
<div className="f-m-group">
<label className="f-m-label">Пароль</label>
<input
className={`f-m-field${errors.password ? " err" : ""}`}
type="password"
placeholder="Мин. 6 символов"
value={form.password}
onChange={e => setField("password", e.target.value)}
/>
{errors.password && <p className="f-err-msg">{errors.password}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">Повторите</label>
<input
className={`f-m-field${errors.password2 ? " err" : ""}`}
type="password"
placeholder="Ещё раз"
value={form.password2}
onChange={e => setField("password2", e.target.value)}
/>
{errors.password2 && <p className="f-err-msg">{errors.password2}</p>}
</div>
</div>
{error && <p className="f-err-msg" style={{ marginBottom: 10 }}>{error}</p>}
<SubmitButton
loading={loading}
label="Создать аккаунт"
onClick={handleSubmit}
/>
<p style={{
textAlign: "center", marginTop: 14,
color: "rgba(255,255,255,.45)", fontSize: ".85rem"
}}>
Уже есть аккаунт?{" "}
<button
onClick={onSwitch}
style={{
background: "none", border: "none", color: "#2de0c8",
cursor: "pointer", fontWeight: 700, fontSize: ".85rem",
fontFamily: "var(--font)",
}}
>
Войти
</button>
</p>
</>
);
}
// ─── Shared submit button ──────────────────────────────────────────────────
function SubmitButton({ loading, label, onClick }) {
return (
<button
className="f-m-submit"
onClick={onClick}
disabled={loading}
style={{ marginTop: 6 }}
>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner" />}
{label}
</div>
</button>
);
}

View File

@@ -0,0 +1,118 @@
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>
);
}

148
src/components/ChatPage.jsx Normal file
View File

@@ -0,0 +1,148 @@
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

@@ -0,0 +1,66 @@
import { useState, useCallback, useEffect } from "react";
import CloseIcon from "./icons/CloseIcon";
import { API_URL } from "../config/authConstants";
export default function ImageViewer({ images, initialIndex = 0, onClose }) {
const [index, setIndex] = useState(initialIndex);
const urls = images.map(u => (u.startsWith("http") ? u : `${API_URL}${u}`));
const next = useCallback(() => {
setIndex(i => (i + 1) % urls.length);
}, [urls.length]);
const prev = useCallback(() => {
setIndex(i => (i - 1 + urls.length) % urls.length);
}, [urls.length]);
const handleImgClick = useCallback(() => {
next();
}, [next]);
useEffect(() => {
const handleKey = (e) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowRight") next();
if (e.key === "ArrowLeft") prev();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, next, prev]);
useEffect(() => {
setIndex(initialIndex);
}, [initialIndex]);
if (!urls.length) return null;
return (
<div className="f-viewer-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<button className="f-viewer-close" onClick={onClose}>
<CloseIcon />
</button>
<div className="f-viewer">
<img
src={urls[index]}
alt=""
className="f-viewer-img"
onClick={handleImgClick}
/>
{urls.length > 1 && (
<div className="f-viewer-dots">
{urls.map((_, i) => (
<button
key={i}
className={`f-viewer-dot${i === index ? " active" : ""}`}
onClick={() => setIndex(i)}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -17,7 +17,11 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
const handleContact = async (e) => {
e.stopPropagation();
try {
const chat = await createOrGetChat(ad.userId, ad.id);
const chat = await createOrGetChat(ad.userId, ad.id, {
title: ad.title,
images: ad.images,
emoji: ad.emoji,
});
navigate(`/chats/${chat.id}`);
} catch (err) {
console.error(err);

View File

@@ -0,0 +1,343 @@
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,177 @@
import CloseIcon from "./icons/CloseIcon";
import "../styles/rules.css";
const RULES = [
{
num: "1",
title: "Общие положения",
content: (
<p>
Платформа «Находка» предназначена исключительно для помощи людям в поиске утерянных вещей
и возврате найденных предметов их владельцам. Размещая объявление, вы соглашаетесь
соблюдать настоящие правила и законодательство Российской Федерации.
</p>
),
},
{
num: "2",
title: "Что можно размещать",
content: (
<ul>
<li>Объявления о потере личных вещей, документов, животных, транспортных средств</li>
<li>Объявления о находке чужого имущества с целью возврата владельцу</li>
<li>Фотографии найденного или утерянного предмета</li>
<li>Контактные данные для связи с владельцем или нашедшим</li>
</ul>
),
},
{
num: "3",
title: "Что запрещено размещать",
content: (
<>
<p className="rules-sub-title">3.1. Запрещённый контент</p>
<ul>
<li>Объявления с целью продажи найденного имущества — это является присвоением чужого имущества и преследуется по <span className="rules-law">ст. 160 УК РФ</span></li>
<li>Заведомо ложные объявления и фиктивные находки</li>
<li>Объявления с целью мошенничества (<span className="rules-law">ст. 159 УК РФ</span>)</li>
<li>Дубликаты одного и того же объявления</li>
</ul>
<p className="rules-sub-title">3.2. Запрещённые предметы</p>
<ul>
<li>Оружие, боеприпасы, взрывчатые вещества</li>
<li>Наркотические и психотропные вещества</li>
<li>Похищенное имущество</li>
<li>Документы третьих лиц с целью использования в корыстных целях</li>
</ul>
<p className="rules-sub-title">3.3. Запрещённые действия</p>
<ul>
<li>Указывать вознаграждение в обмен на молчание или отказ от обращения в полицию</li>
<li>Требовать оплату за возврат найденных документов — паспорт и иные документы подлежат безвозмездному возврату</li>
<li>Публиковать персональные данные третьих лиц без их согласия (<span className="rules-law">ст. 137 УК РФ, 152-ФЗ</span>)</li>
</ul>
</>
),
},
{
num: "4",
title: "Требования к объявлению",
content: (
<ul>
<li>Описание должно быть достоверным и точным</li>
<li>Фотографии должны соответствовать описанию</li>
<li>Контактные данные должны быть актуальными</li>
<li>Место и дата потери или находки указываются максимально точно</li>
<li>Запрещено использовать оскорбительные, нецензурные выражения</li>
</ul>
),
},
{
num: "5",
title: "Вознаграждение",
content: (
<>
<p>Предложение вознаграждения нашедшему является добровольным и законным правом владельца. При этом:</p>
<ul>
<li>Размер вознаграждения определяет только владелец вещи</li>
<li>Нашедший не вправе требовать вознаграждение как условие возврата</li>
<li>Согласно <span className="rules-law">ст. 229 ГК РФ</span> нашедший имеет право на вознаграждение до 20% от стоимости вещи, если владелец публично его объявил</li>
</ul>
</>
),
},
{
num: "6",
title: "Найденные документы",
content: (
<>
<p>Нахождение чужих документов обязывает вас согласно законодательству РФ:</p>
<ul>
<li>Сдать документы в ближайшее отделение полиции, либо</li>
<li>Разместить объявление на платформе для возврата владельцу</li>
<li>Хранить документы до момента возврата или передачи в полицию</li>
</ul>
<p className="rules-warn">Продажа, использование или удержание чужих документов преследуется по закону.</p>
</>
),
},
{
num: "7",
title: "Животные",
content: (
<ul>
<li>Найденное животное необходимо по возможности обеспечить едой и кровом до возврата владельцу</li>
<li>Запрещено размещать объявления о продаже найденных животных</li>
<li>Если владелец не найден в течение 6 месяцев, право собственности на животное может перейти к нашедшему согласно <span className="rules-law">ст. 231 ГК РФ</span></li>
</ul>
),
},
{
num: "8",
title: "Конфиденциальность",
content: (
<ul>
<li>Не публикуйте в объявлении избыточные персональные данные — достаточно способа связи</li>
<li>Не раскрывайте данные владельца или нашедшего третьим лицам без их согласия</li>
<li>Платформа не несёт ответственности за передачу данных пользователями напрямую</li>
</ul>
),
},
{
num: "9",
title: "Модерация",
content: (
<>
<p>Администрация платформы вправе:</p>
<ul>
<li>Удалить объявление без предупреждения при нарушении правил</li>
<li>Заблокировать аккаунт пользователя при систематических нарушениях</li>
<li>Передать данные пользователя в правоохранительные органы при наличии признаков противоправных действий</li>
</ul>
</>
),
},
{
num: "10",
title: "Ответственность пользователей",
content: (
<p>
Пользователь самостоятельно несёт ответственность за содержание размещаемых объявлений.
Платформа «Находка» является информационным посредником и не несёт ответственности
за действия пользователей вне платформы.
</p>
),
},
];
export default function RulesModal({ onClose }) {
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal rules-modal">
{/* Header */}
<div className="f-modal-head">
<div className="rules-modal-title-wrap">
<span className="rules-modal-icon">📋</span>
<span className="f-modal-title">Правила площадки «Находка»</span>
</div>
<button className="f-close-btn" onClick={onClose}>
<CloseIcon />
</button>
</div>
{/* Rules list */}
<div className="rules-list">
{RULES.map(rule => (
<div key={rule.num} className="rules-section">
<div className="rules-section-head">
<span className="rules-num">{rule.num}</span>
<span className="rules-title">{rule.title}</span>
</div>
<div className="rules-body">{rule.content}</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import {NAV_BOTTOM, NAV_ITEMS} from "../config/feedConstants";
import {NavIcons} from "./icons/NavIcons";
import { useNavigate } from "react-router-dom";
import { useTheme } from "../context/ThemeContext";
import { useUnread } from "../context/UnreadContext";
export default function Sidebar({
open,
@@ -16,6 +17,7 @@ export default function Sidebar({
}) {
const navigate = useNavigate();
const { theme, toggle: toggleTheme } = useTheme();
const { totalUnread } = useUnread();
return (
<>
<div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} />
@@ -49,6 +51,9 @@ export default function Sidebar({
onClick={handleClick}
>
<Ic /> {label}
{label === "Чат" && totalUnread > 0 && (
<span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span>
)}
</div>
);
})}

View File

@@ -0,0 +1,10 @@
export default function TrashIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
}