diff --git a/package-lock.json b/package-lock.json
index f4b70cc..d8673ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "react_front",
"version": "0.1.0",
"dependencies": {
+ "@emoji-mart/data": "^1.2.1",
+ "@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2",
"@pbe/react-yandex-maps": "^1.2.5",
"@stomp/stompjs": "^7.3.0",
@@ -2431,6 +2433,22 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@emoji-mart/data": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
+ "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==",
+ "license": "MIT"
+ },
+ "node_modules/@emoji-mart/react": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
+ "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "emoji-mart": "^5.2",
+ "react": "^16.8 || ^17 || ^18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -7534,6 +7552,13 @@
"url": "https://github.com/sindresorhus/emittery?sponsor=1"
}
},
+ "node_modules/emoji-mart": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
+ "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
diff --git a/package.json b/package.json
index 8cf36cf..7835568 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "@emoji-mart/data": "^1.2.1",
+ "@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2",
"@pbe/react-yandex-maps": "^1.2.5",
"@stomp/stompjs": "^7.3.0",
diff --git a/react_front/index.html b/react_front/index.html
new file mode 100644
index 0000000..0ba6111
--- /dev/null
+++ b/react_front/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ react_front
+
+
+
+
+
+
diff --git a/react_front/public/favicon.svg b/react_front/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/react_front/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/react_front/src/App.css b/react_front/src/App.css
new file mode 100644
index 0000000..f90339d
--- /dev/null
+++ b/react_front/src/App.css
@@ -0,0 +1,184 @@
+.counter {
+ font-size: 16px;
+ padding: 5px 10px;
+ border-radius: 5px;
+ color: var(--accent);
+ background: var(--accent-bg);
+ border: 2px solid transparent;
+ transition: border-color 0.3s;
+ margin-bottom: 24px;
+
+ &:hover {
+ border-color: var(--accent-border);
+ }
+ &:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+}
+
+.hero {
+ position: relative;
+
+ .base,
+ .framework,
+ .vite {
+ inset-inline: 0;
+ margin: 0 auto;
+ }
+
+ .base {
+ width: 170px;
+ position: relative;
+ z-index: 0;
+ }
+
+ .framework,
+ .vite {
+ position: absolute;
+ }
+
+ .framework {
+ z-index: 1;
+ top: 34px;
+ height: 28px;
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
+ scale(1.4);
+ }
+
+ .vite {
+ z-index: 0;
+ top: 107px;
+ height: 26px;
+ width: auto;
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
+ scale(0.8);
+ }
+}
+
+#center {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ place-content: center;
+ place-items: center;
+ flex-grow: 1;
+
+ @media (max-width: 1024px) {
+ padding: 32px 20px 24px;
+ gap: 18px;
+ }
+}
+
+#next-steps {
+ display: flex;
+ border-top: 1px solid var(--border);
+ text-align: left;
+
+ & > div {
+ flex: 1 1 0;
+ padding: 32px;
+ @media (max-width: 1024px) {
+ padding: 24px 20px;
+ }
+ }
+
+ .icon {
+ margin-bottom: 16px;
+ width: 22px;
+ height: 22px;
+ }
+
+ @media (max-width: 1024px) {
+ flex-direction: column;
+ text-align: center;
+ }
+}
+
+#docs {
+ border-right: 1px solid var(--border);
+
+ @media (max-width: 1024px) {
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ }
+}
+
+#next-steps ul {
+ list-style: none;
+ padding: 0;
+ display: flex;
+ gap: 8px;
+ margin: 32px 0 0;
+
+ .logo {
+ height: 18px;
+ }
+
+ a {
+ color: var(--text-h);
+ font-size: 16px;
+ border-radius: 6px;
+ background: var(--social-bg);
+ display: flex;
+ padding: 6px 12px;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+ transition: box-shadow 0.3s;
+
+ &:hover {
+ box-shadow: var(--shadow);
+ }
+ .button-icon {
+ height: 18px;
+ width: 18px;
+ }
+ }
+
+ @media (max-width: 1024px) {
+ margin-top: 20px;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ li {
+ flex: 1 1 calc(50% - 8px);
+ }
+
+ a {
+ width: 100%;
+ justify-content: center;
+ box-sizing: border-box;
+ }
+ }
+}
+
+#spacer {
+ height: 88px;
+ border-top: 1px solid var(--border);
+ @media (max-width: 1024px) {
+ height: 48px;
+ }
+}
+
+.ticks {
+ position: relative;
+ width: 100%;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ top: -4.5px;
+ border: 5px solid transparent;
+ }
+
+ &::before {
+ left: 0;
+ border-left-color: var(--border);
+ }
+ &::after {
+ right: 0;
+ border-right-color: var(--border);
+ }
+}
diff --git a/react_front/src/index.css b/react_front/src/index.css
new file mode 100644
index 0000000..5fb3313
--- /dev/null
+++ b/react_front/src/index.css
@@ -0,0 +1,111 @@
+:root {
+ --text: #6b6375;
+ --text-h: #08060d;
+ --bg: #fff;
+ --border: #e5e4e7;
+ --code-bg: #f4f3ec;
+ --accent: #aa3bff;
+ --accent-bg: rgba(170, 59, 255, 0.1);
+ --accent-border: rgba(170, 59, 255, 0.5);
+ --social-bg: rgba(244, 243, 236, 0.5);
+ --shadow:
+ rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
+
+ --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
+ --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
+ --mono: ui-monospace, Consolas, monospace;
+
+ font: 18px/145% var(--sans);
+ letter-spacing: 0.18px;
+ color-scheme: light dark;
+ color: var(--text);
+ background: var(--bg);
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+
+ @media (max-width: 1024px) {
+ font-size: 16px;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --text: #9ca3af;
+ --text-h: #f3f4f6;
+ --bg: #16171d;
+ --border: #2e303a;
+ --code-bg: #1f2028;
+ --accent: #c084fc;
+ --accent-bg: rgba(192, 132, 252, 0.15);
+ --accent-border: rgba(192, 132, 252, 0.5);
+ --social-bg: rgba(47, 48, 58, 0.5);
+ --shadow:
+ rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
+ }
+
+ #social .button-icon {
+ filter: invert(1) brightness(2);
+ }
+}
+
+#root {
+ width: 1126px;
+ max-width: 100%;
+ margin: 0 auto;
+ text-align: center;
+ border-inline: 1px solid var(--border);
+ min-height: 100svh;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+h1,
+h2 {
+ font-family: var(--heading);
+ font-weight: 500;
+ color: var(--text-h);
+}
+
+h1 {
+ font-size: 56px;
+ letter-spacing: -1.68px;
+ margin: 32px 0;
+ @media (max-width: 1024px) {
+ font-size: 36px;
+ margin: 20px 0;
+ }
+}
+h2 {
+ font-size: 24px;
+ line-height: 118%;
+ letter-spacing: -0.24px;
+ margin: 0 0 8px;
+ @media (max-width: 1024px) {
+ font-size: 20px;
+ }
+}
+p {
+ margin: 0;
+}
+
+code,
+.counter {
+ font-family: var(--mono);
+ display: inline-flex;
+ border-radius: 4px;
+ color: var(--text-h);
+}
+
+code {
+ font-size: 15px;
+ line-height: 135%;
+ padding: 4px 8px;
+ background: var(--code-bg);
+}
diff --git a/src/App.js b/src/App.js
index 38c4aca..e570c32 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,6 +1,8 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
+import { useAuthContext } from './context/AuthContext';
+import { useUnreadSocket } from './hooks/useUnreadSocket';
import RegisterPage from './components/Authentication';
import MainFeed from './components/MainFeed';
import ChatPage from './components/ChatPage';
@@ -8,10 +10,17 @@ import ChatListPage from './components/ChatListPage';
import ReviewsPage from './components/ReviewsPage';
import './styles/chat.css';
+function UnreadListener() {
+ const { user } = useAuthContext();
+ useUnreadSocket(user?.id);
+ return null;
+}
+
function App() {
return (
+
} />
diff --git a/src/components/AuthModal.jsx b/src/components/AuthModal.jsx
new file mode 100644
index 0000000..b538ed3
--- /dev/null
+++ b/src/components/AuthModal.jsx
@@ -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 (
+ e.target === e.currentTarget && onClose()}>
+
+ {/* Header */}
+
+
+ {mode === "login" ? "Вход в аккаунт" : "Регистрация"}
+
+
+
+
+
+
+ {mode === "login"
+ ?
setMode("register")}
+ />
+ : setMode("login")}
+ />
+ }
+
+
+ );
+}
+
+// ─── 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 (
+ <>
+
+ Логин
+ { setLogin(e.target.value); setError(""); }}
+ autoComplete="off"
+ />
+
+
+
+ Пароль
+ { setPassword(e.target.value); setError(""); }}
+ onKeyDown={e => e.key === "Enter" && handleSubmit()}
+ />
+
+
+ {error && {error}
}
+
+
+
+
+ Нет аккаунта?{" "}
+
+ Зарегистрироваться
+
+
+ >
+ );
+}
+
+// ─── 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 (
+ <>
+
+
Имя
+
setField("name", e.target.value)}
+ />
+ {errors.name &&
{errors.name}
}
+
+
+
+
Логин
+
setField("login", e.target.value)}
+ />
+ {errors.login &&
{errors.login}
}
+
+
+
+
+
Пароль
+
setField("password", e.target.value)}
+ />
+ {errors.password &&
{errors.password}
}
+
+
+
Повторите
+
setField("password2", e.target.value)}
+ />
+ {errors.password2 &&
{errors.password2}
}
+
+
+
+ {error && {error}
}
+
+
+
+
+ Уже есть аккаунт?{" "}
+
+ Войти
+
+
+ >
+ );
+}
+
+// ─── Shared submit button ──────────────────────────────────────────────────
+function SubmitButton({ loading, label, onClick }) {
+ return (
+
+
+ {loading &&
}
+ {label}
+
+
+ );
+}
diff --git a/src/components/ChatListPage.jsx b/src/components/ChatListPage.jsx
new file mode 100644
index 0000000..ec4a9e3
--- /dev/null
+++ b/src/components/ChatListPage.jsx
@@ -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 (
+
+
+
+
navigate("/feed")}>
+ ←
+
+
Чаты
+
+
+
Войдите, чтобы видеть чаты
+
+
+ );
+ }
+
+ return (
+
+
+
+
navigate("/feed")}>
+ ←
+
+
Чаты
+
+
+
+
+ {loading && (
+
Загрузка...
+ )}
+
+ {!loading && chats.length === 0 && (
+
У вас пока нет чатов
+ )}
+
+ {!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 (
+
0 ? " unread" : ""}`}
+ onClick={() => navigate(`/chats/${chat.id}`)}
+ >
+
+ {imgSrc ? (
+
+ ) : (
+ emoji || chat.otherUser?.name?.[0] || "?"
+ )}
+
+
+
{title}
+
+ {chat.lastMessage?.content || "Нет сообщений"}
+
+
+ {unreadCount > 0 && (
+
{unreadCount > 99 ? "99+" : unreadCount}
+ )}
+
{
+ e.stopPropagation();
+ handleDeleteChat(chat.id);
+ }}
+ >
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/ChatPage.jsx b/src/components/ChatPage.jsx
new file mode 100644
index 0000000..6b572aa
--- /dev/null
+++ b/src/components/ChatPage.jsx
@@ -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 (
+
+
+
+
navigate("/chats")}>
+ ←
+
+
+ {postImg ? (
+
+ ) : (
+ post?.emoji || "📋"
+ )}
+
+
{post?.title || "Чат"}
+
+ {connected ? "В сети" : "Отключено"}
+
+
+
+
+ {messages.length === 0 && (
+
Нет сообщений
+ )}
+ {messages.map((m) => (
+
+ {m.content}
+
+ ))}
+
+
+
+
+ {showEmoji && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/ImageViewer.jsx b/src/components/ImageViewer.jsx
new file mode 100644
index 0000000..f7af9e7
--- /dev/null
+++ b/src/components/ImageViewer.jsx
@@ -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 (
+ e.target === e.currentTarget && onClose()}>
+
+
+
+
+
+
+
+ {urls.length > 1 && (
+
+ {urls.map((_, i) => (
+ setIndex(i)}
+ />
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/PostOnFeed.jsx b/src/components/PostOnFeed.jsx
index cc61ec0..97e8ef3 100644
--- a/src/components/PostOnFeed.jsx
+++ b/src/components/PostOnFeed.jsx
@@ -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);
diff --git a/src/components/ReviewsPage.jsx b/src/components/ReviewsPage.jsx
new file mode 100644
index 0000000..2c5c0a7
--- /dev/null
+++ b/src/components/ReviewsPage.jsx
@@ -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 (
+
+ {[1, 2, 3, 4, 5].map((star) => {
+ const filled = star <= (hovered || value);
+ return (
+
!readOnly && onChange(star)}
+ onMouseEnter={() => !readOnly && setHovered(star)}
+ onMouseLeave={() => !readOnly && setHovered(0)}
+ >
+
+
+ );
+ })}
+
+ );
+}
+
+/* ── Single review card ── */
+function ReviewCard({ review }) {
+ const date = review.createdAt
+ ? new Date(review.createdAt).toLocaleDateString("ru-RU", {
+ day: "numeric", month: "long", year: "numeric",
+ })
+ : "";
+
+ return (
+
+
+
+ {review.authorName ? review.authorName.charAt(0).toUpperCase() : "?"}
+
+
+ {review.authorName || "Аноним"}
+ {date && {date} }
+
+
+
+ {review.rating}/5
+
+
+ {review.text &&
{review.text}
}
+
+ );
+}
+
+/* ── 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 (
+
+ );
+}
+
+/* ── 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 (
+
+
setSidebarOpen(false)}
+ onNavClick={(label) => {
+ if (label === "Главная") navigate("/feed");
+ }}
+ isAuthenticated={isAuthenticated}
+ onLoginClick={() => setAuthModalOpen(true)}
+ onRulesClick={() => {}}
+ />
+
+
+ {/* Top bar */}
+
+
setSidebarOpen((v) => !v)}>
+
+
+
+
Отзывы
+
+ {/* Иконка профиля в правом углу */}
+ {isAuthenticated ? (
+
setProfileModalOpen(true)}>
+
+
+ ) : (
+
setAuthModalOpen(true)}>
+ Войти
+
+ )}
+
+
+
+ {/* Left: Summary + form */}
+
+ {/* Summary */}
+
+
+ {avgRating ? (
+ <>
+ {avgRating}
+
+ >
+ ) : (
+ Пока нет отзывов
+ )}
+
+
+ {reviews.length} {reviews.length === 1 ? "отзыв" : reviews.length < 5 ? "отзыва" : "отзывов"}
+
+
+ {/* Distribution bars */}
+ {reviews.length > 0 && (
+
+ {distribution.map(({ star, count, pct }) => (
+
+ ))}
+
+ )}
+
+
+ {/* Add review form */}
+ {isAuthenticated ? (
+
+ ) : (
+
+
Войдите, чтобы оставить отзыв
+
setAuthModalOpen(true)}>
+ Войти
+
+
+ )}
+
+
+ {/* Right: Reviews list */}
+
+ {loading ? (
+
+
+
+ Загружаем отзывы...
+
+
+ ) : error && reviews.length === 0 ? (
+
+
💬
+
Отзывов пока нет
+
Станьте первым — оставьте отзыв о площадке
+
+ ) : (
+ reviews.map((review) =>
)
+ )}
+
+
+
+
+ {/* Profile modal */}
+ {profileModalOpen && isAuthenticated && (
+ setProfileModalOpen(false)} initialData={profileData} />
+ )}
+
+ {/* Auth modal */}
+ {authModalOpen && (
+ setAuthModalOpen(false)} />
+ )}
+
+ );
+}
diff --git a/src/components/RulesModal.jsx b/src/components/RulesModal.jsx
new file mode 100644
index 0000000..0b8bac5
--- /dev/null
+++ b/src/components/RulesModal.jsx
@@ -0,0 +1,177 @@
+import CloseIcon from "./icons/CloseIcon";
+import "../styles/rules.css";
+
+const RULES = [
+ {
+ num: "1",
+ title: "Общие положения",
+ content: (
+
+ Платформа «Находка» предназначена исключительно для помощи людям в поиске утерянных вещей
+ и возврате найденных предметов их владельцам. Размещая объявление, вы соглашаетесь
+ соблюдать настоящие правила и законодательство Российской Федерации.
+
+ ),
+ },
+ {
+ num: "2",
+ title: "Что можно размещать",
+ content: (
+
+ Объявления о потере личных вещей, документов, животных, транспортных средств
+ Объявления о находке чужого имущества с целью возврата владельцу
+ Фотографии найденного или утерянного предмета
+ Контактные данные для связи с владельцем или нашедшим
+
+ ),
+ },
+ {
+ num: "3",
+ title: "Что запрещено размещать",
+ content: (
+ <>
+ 3.1. Запрещённый контент
+
+ Объявления с целью продажи найденного имущества — это является присвоением чужого имущества и преследуется по ст. 160 УК РФ
+ Заведомо ложные объявления и фиктивные находки
+ Объявления с целью мошенничества (ст. 159 УК РФ )
+ Дубликаты одного и того же объявления
+
+ 3.2. Запрещённые предметы
+
+ Оружие, боеприпасы, взрывчатые вещества
+ Наркотические и психотропные вещества
+ Похищенное имущество
+ Документы третьих лиц с целью использования в корыстных целях
+
+ 3.3. Запрещённые действия
+
+ Указывать вознаграждение в обмен на молчание или отказ от обращения в полицию
+ Требовать оплату за возврат найденных документов — паспорт и иные документы подлежат безвозмездному возврату
+ Публиковать персональные данные третьих лиц без их согласия (ст. 137 УК РФ, 152-ФЗ )
+
+ >
+ ),
+ },
+ {
+ num: "4",
+ title: "Требования к объявлению",
+ content: (
+
+ Описание должно быть достоверным и точным
+ Фотографии должны соответствовать описанию
+ Контактные данные должны быть актуальными
+ Место и дата потери или находки указываются максимально точно
+ Запрещено использовать оскорбительные, нецензурные выражения
+
+ ),
+ },
+ {
+ num: "5",
+ title: "Вознаграждение",
+ content: (
+ <>
+ Предложение вознаграждения нашедшему является добровольным и законным правом владельца. При этом:
+
+ Размер вознаграждения определяет только владелец вещи
+ Нашедший не вправе требовать вознаграждение как условие возврата
+ Согласно ст. 229 ГК РФ нашедший имеет право на вознаграждение до 20% от стоимости вещи, если владелец публично его объявил
+
+ >
+ ),
+ },
+ {
+ num: "6",
+ title: "Найденные документы",
+ content: (
+ <>
+ Нахождение чужих документов обязывает вас согласно законодательству РФ:
+
+ Сдать документы в ближайшее отделение полиции, либо
+ Разместить объявление на платформе для возврата владельцу
+ Хранить документы до момента возврата или передачи в полицию
+
+ Продажа, использование или удержание чужих документов преследуется по закону.
+ >
+ ),
+ },
+ {
+ num: "7",
+ title: "Животные",
+ content: (
+
+ Найденное животное необходимо по возможности обеспечить едой и кровом до возврата владельцу
+ Запрещено размещать объявления о продаже найденных животных
+ Если владелец не найден в течение 6 месяцев, право собственности на животное может перейти к нашедшему согласно ст. 231 ГК РФ
+
+ ),
+ },
+ {
+ num: "8",
+ title: "Конфиденциальность",
+ content: (
+
+ Не публикуйте в объявлении избыточные персональные данные — достаточно способа связи
+ Не раскрывайте данные владельца или нашедшего третьим лицам без их согласия
+ Платформа не несёт ответственности за передачу данных пользователями напрямую
+
+ ),
+ },
+ {
+ num: "9",
+ title: "Модерация",
+ content: (
+ <>
+ Администрация платформы вправе:
+
+ Удалить объявление без предупреждения при нарушении правил
+ Заблокировать аккаунт пользователя при систематических нарушениях
+ Передать данные пользователя в правоохранительные органы при наличии признаков противоправных действий
+
+ >
+ ),
+ },
+ {
+ num: "10",
+ title: "Ответственность пользователей",
+ content: (
+
+ Пользователь самостоятельно несёт ответственность за содержание размещаемых объявлений.
+ Платформа «Находка» является информационным посредником и не несёт ответственности
+ за действия пользователей вне платформы.
+
+ ),
+ },
+];
+
+export default function RulesModal({ onClose }) {
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+ {/* Header */}
+
+
+ 📋
+ Правила площадки «Находка»
+
+
+
+
+
+
+ {/* Rules list */}
+
+ {RULES.map(rule => (
+
+
+ {rule.num}
+ {rule.title}
+
+
{rule.content}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index 888a5ea..3d2dbec 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -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 (
<>
@@ -49,6 +51,9 @@ export default function Sidebar({
onClick={handleClick}
>
{label}
+ {label === "Чат" && totalUnread > 0 && (
+ {totalUnread > 99 ? "99+" : totalUnread}
+ )}
);
})}
diff --git a/src/components/icons/TrashIcon.jsx b/src/components/icons/TrashIcon.jsx
new file mode 100644
index 0000000..5e2c806
--- /dev/null
+++ b/src/components/icons/TrashIcon.jsx
@@ -0,0 +1,10 @@
+export default function TrashIcon() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
new file mode 100644
index 0000000..b3621ae
--- /dev/null
+++ b/src/context/AuthContext.jsx
@@ -0,0 +1,67 @@
+import { createContext, useContext, useState, useCallback } from "react";
+
+const AuthContext = createContext(null);
+
+/**
+ * AuthProvider — глобальный контекст аутентификации.
+ * Хранит текущего пользователя и JWT-токен в localStorage.
+ * Предоставляет методы login / logout / isAuthenticated.
+ */
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(() => {
+ try {
+ const raw = localStorage.getItem("user");
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+ });
+
+ const [token, setToken] = useState(() => localStorage.getItem("token"));
+
+ /** Сохраняет данные пользователя и токен после успешного входа. */
+ const login = useCallback((userData, tokenStr) => {
+ localStorage.setItem("user", JSON.stringify(userData));
+ localStorage.setItem("token", tokenStr);
+ setUser(userData);
+ setToken(tokenStr);
+ }, []);
+
+ /** Очищает все данные аутентификации. */
+ const logout = useCallback(() => {
+ localStorage.removeItem("user");
+ localStorage.removeItem("token");
+ setUser(null);
+ setToken(null);
+ }, []);
+
+ /** Обновляет данные пользователя в контексте и localStorage. */
+ const updateUser = useCallback((userData) => {
+ localStorage.setItem("user", JSON.stringify(userData));
+ setUser(userData);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** Хук для доступа к контексту аутентификации. */
+export function useAuthContext() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error("useAuthContext must be used inside AuthProvider");
+ }
+ return ctx;
+}
diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx
new file mode 100644
index 0000000..ff81db5
--- /dev/null
+++ b/src/context/ThemeContext.jsx
@@ -0,0 +1,38 @@
+import { createContext, useContext, useState, useEffect, useCallback } from "react";
+
+const ThemeContext = createContext(null);
+
+const STORAGE_KEY = "theme";
+
+export function ThemeProvider({ children }) {
+ const [theme, setTheme] = useState(() => {
+ try {
+ return localStorage.getItem(STORAGE_KEY) || "dark";
+ } catch {
+ return "dark";
+ }
+ });
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme;
+ try {
+ localStorage.setItem(STORAGE_KEY, theme);
+ } catch {}
+ }, [theme]);
+
+ const toggle = useCallback(() => {
+ setTheme(prev => (prev === "dark" ? "light" : "dark"));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
+ return ctx;
+}
diff --git a/src/context/UnreadContext.jsx b/src/context/UnreadContext.jsx
new file mode 100644
index 0000000..c0b2b44
--- /dev/null
+++ b/src/context/UnreadContext.jsx
@@ -0,0 +1,50 @@
+import { createContext, useContext, useState, useCallback, useMemo } from 'react';
+
+const UnreadContext = createContext(null);
+
+export function UnreadProvider({ children }) {
+ const [unreadMap, setUnreadMap] = useState({});
+
+ const setChatUnread = useCallback((chatId, count) => {
+ setUnreadMap(prev => ({ ...prev, [chatId]: count }));
+ }, []);
+
+ const clearChatUnread = useCallback((chatId) => {
+ setUnreadMap(prev => {
+ if (prev[chatId] === undefined) return prev;
+ const next = { ...prev };
+ delete next[chatId];
+ return next;
+ });
+ }, []);
+
+ const updateFromChats = useCallback((chats) => {
+ setUnreadMap(prev => {
+ const next = { ...prev };
+ for (const chat of chats) {
+ const count = chat.unreadCount ?? chat.unreadMessages ?? 0;
+ if (count > 0) {
+ next[chat.id] = count;
+ }
+ }
+ return next;
+ });
+ }, []);
+
+ const totalUnread = useMemo(() =>
+ Object.values(unreadMap).reduce((sum, c) => sum + c, 0),
+ [unreadMap]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useUnread() {
+ const ctx = useContext(UnreadContext);
+ if (!ctx) throw new Error('useUnread must be used within UnreadProvider');
+ return ctx;
+}
diff --git a/src/hooks/useChatSocket.js b/src/hooks/useChatSocket.js
new file mode 100644
index 0000000..4b74dcd
--- /dev/null
+++ b/src/hooks/useChatSocket.js
@@ -0,0 +1,44 @@
+import { useEffect, useRef, useState, useCallback } from "react";
+import { Client } from "@stomp/stompjs";
+import SockJS from "sockjs-client";
+import { API_URL } from "../config/authConstants";
+
+export function useChatSocket(chatId, onMessage) {
+ const clientRef = useRef(null);
+ const [connected, setConnected] = useState(false);
+
+ useEffect(() => {
+ if (!chatId) return;
+
+ const token = localStorage.getItem("token");
+ const socket = new SockJS(`${API_URL}/ws?token=${token}`);
+
+ const client = new Client({
+ webSocketFactory: () => socket,
+ reconnectDelay: 3000,
+ onConnect: () => {
+ setConnected(true);
+ client.subscribe(`/topic/chat/${chatId}`, (msg) => {
+ const body = JSON.parse(msg.body);
+ onMessage(body);
+ });
+ },
+ onDisconnect: () => setConnected(false),
+ });
+
+ client.activate();
+ clientRef.current = client;
+
+ return () => client.deactivate();
+ }, [chatId, onMessage]);
+
+ const sendMessage = useCallback((content) => {
+ if (!clientRef.current?.connected) return;
+ clientRef.current.publish({
+ destination: "/app/chat.send",
+ body: JSON.stringify({ chatId, content }),
+ });
+ }, [chatId]);
+
+ return { connected, sendMessage };
+}
diff --git a/src/hooks/useChats.js b/src/hooks/useChats.js
new file mode 100644
index 0000000..737027e
--- /dev/null
+++ b/src/hooks/useChats.js
@@ -0,0 +1,62 @@
+import { useState, useCallback } from "react";
+import { API_URL } from "../config/authConstants";
+import { cachePostsFromChats, setPostForChat } from "../stores/chatPostStore";
+
+function authHeaders() {
+ return {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${localStorage.getItem("token")}`,
+ };
+}
+
+export function useChats() {
+ const [chats, setChats] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const fetchChats = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(`${API_URL}/api/v1/chats`, { headers: authHeaders() });
+ const data = await res.json();
+ // API may return an array directly or a wrapper object like { items: [] } / { content: [] }
+ const list = Array.isArray(data) ? data : (data.items ?? data.content ?? []);
+ cachePostsFromChats(list);
+ setChats(list);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const createOrGetChat = useCallback(async (otherUserId, postId, postInfo) => {
+ const url = new URL(`${API_URL}/api/v1/chats/with/${otherUserId}`);
+ if (postId) url.searchParams.set("postId", postId);
+
+ const res = await fetch(url, { method: "POST", headers: authHeaders() });
+ if (!res.ok) throw new Error("Не удалось создать чат");
+ const chat = await res.json();
+ if (postInfo && chat.id) {
+ setPostForChat(chat.id, postInfo);
+ }
+ return chat;
+ }, []);
+
+ const fetchMessages = useCallback(async (chatId) => {
+ const res = await fetch(`${API_URL}/api/v1/chats/${chatId}/messages`, { headers: authHeaders() });
+ const data = await res.json();
+ // Normalize: array or wrapper object
+ return Array.isArray(data) ? data : (data.items ?? data.content ?? []);
+ }, []);
+
+ const markAsRead = useCallback(async (chatId) => {
+ try {
+ await fetch(`${API_URL}/api/v1/chats/${chatId}/read`, {
+ method: "POST",
+ headers: authHeaders(),
+ });
+ } catch (e) {
+ console.error("Failed to mark as read:", e);
+ }
+ }, []);
+
+ return { chats, loading, fetchChats, createOrGetChat, fetchMessages, markAsRead };
+}
diff --git a/src/hooks/useDeleteChat.js b/src/hooks/useDeleteChat.js
new file mode 100644
index 0000000..cd4cd0d
--- /dev/null
+++ b/src/hooks/useDeleteChat.js
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { API_URL } from "../config/authConstants";
+
+export function useDeleteChat() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const deleteChat = useCallback(async (chatId) => {
+ setLoading(true);
+ setError("");
+ try {
+ const token = localStorage.getItem("token");
+ const res = await fetch(`${API_URL}/api/v1/chats/${chatId}`, {
+ method: "DELETE",
+ headers: {
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ });
+ if (!res.ok) {
+ throw new Error(`Ошибка ${res.status}`);
+ }
+ return true;
+ } catch (err) {
+ setError(err.message || "Не удалось удалить чат");
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ return { deleteChat, loading, error };
+}
diff --git a/src/hooks/useDeletePost.js b/src/hooks/useDeletePost.js
new file mode 100644
index 0000000..5566ee4
--- /dev/null
+++ b/src/hooks/useDeletePost.js
@@ -0,0 +1,40 @@
+import { useState, useCallback } from "react";
+import { API_URL } from "../config/authConstants";
+
+/**
+ * useDeletePost — хук для удаления объявления.
+ *
+ * @returns {Object} { deletePost, loading, error }
+ */
+export function useDeletePost() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const deletePost = useCallback(async (postId) => {
+ setLoading(true);
+ setError("");
+
+ try {
+ const token = localStorage.getItem("token");
+ const res = await fetch(`${API_URL}/api/v1/posts/${postId}`, {
+ method: "DELETE",
+ headers: {
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ });
+
+ if (!res.ok) {
+ throw new Error(`Ошибка ${res.status}`);
+ }
+
+ return true;
+ } catch (err) {
+ setError(err.message || "Не удалось удалить объявление");
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ return { deletePost, loading, error };
+}
diff --git a/src/hooks/useFavorites.js b/src/hooks/useFavorites.js
new file mode 100644
index 0000000..e7a02b0
--- /dev/null
+++ b/src/hooks/useFavorites.js
@@ -0,0 +1,74 @@
+import { useCallback } from 'react';
+import { API_URL } from '../config/authConstants';
+
+/**
+ * Хук для работы с лайками (избранное).
+ * Использует backend API для добавления/удаления лайков.
+ */
+export function useFavorites() {
+ /**
+ * Переключает лайк на посте через API.
+ * @param {number} postId - ID поста
+ * @param {string} token - JWT токен авторизации
+ * @returns {Promise} - true если операция успешна
+ */
+ const toggleFavorite = useCallback(async (postId, token) => {
+ if (!token) {
+ console.warn('Cannot toggle favorite: no token');
+ return false;
+ }
+
+ try {
+ const response = await fetch(`${API_URL}/api/v1/favorites/${postId}/toggle`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to toggle favorite: ${response.status}`);
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error toggling favorite:', error);
+ return false;
+ }
+ }, []);
+
+ /**
+ * Загружает список лайкнутых постов текущего пользователя.
+ * @param {string} token - JWT токен авторизации
+ * @returns {Promise} - массив ID лайкнутых постов
+ */
+ const loadFavorites = useCallback(async (token) => {
+ if (!token) {
+ return [];
+ }
+
+ try {
+ const response = await fetch(`${API_URL}/api/v1/favorites`, {
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to load favorites: ${response.status}`);
+ }
+
+ const data = await response.json();
+ // API возвращает массив постов, извлекаем ID
+ return Array.isArray(data) ? data.map(post => post.id) : [];
+ } catch (error) {
+ console.error('Error loading favorites:', error);
+ return [];
+ }
+ }, []);
+
+ return {
+ toggleFavorite,
+ loadFavorites,
+ };
+}
diff --git a/src/hooks/usePostStatus.js b/src/hooks/usePostStatus.js
new file mode 100644
index 0000000..deaeba0
--- /dev/null
+++ b/src/hooks/usePostStatus.js
@@ -0,0 +1,80 @@
+import { useCallback } from 'react';
+import { API_URL } from '../config/authConstants';
+
+/**
+ * Хук для изменения статуса поста (ACTIVE/CLOSED).
+ * Используется для отметки "Вещь найдена".
+ */
+export function usePostStatus() {
+ /**
+ * Изменяет статус поста на CLOSED (вещь найдена).
+ * @param {number} postId - ID поста
+ * @param {string} token - JWT токен авторизации
+ * @returns {Promise} - true если операция успешна
+ */
+ const markAsFound = useCallback(async (postId, token) => {
+ if (!token) {
+ console.warn('Cannot mark post as found: no token');
+ return false;
+ }
+
+ try {
+ // Используем PATCH endpoint для обновления статуса
+ const response = await fetch(`${API_URL}/api/v1/posts/${postId}/status`, {
+ method: 'PATCH',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ status: 'CLOSED' }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to update status: ${response.status}`);
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error marking post as found:', error);
+ return false;
+ }
+ }, []);
+
+ /**
+ * Возвращает статус поста в ACTIVE (если нужно reopen).
+ * @param {number} postId - ID поста
+ * @param {string} token - JWT токен авторизации
+ * @returns {Promise} - true если операция успешна
+ */
+ const reopenPost = useCallback(async (postId, token) => {
+ if (!token) {
+ console.warn('Cannot reopen post: no token');
+ return false;
+ }
+
+ try {
+ const response = await fetch(`${API_URL}/api/v1/posts/${postId}/status`, {
+ method: 'PATCH',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ status: 'ACTIVE' }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to update status: ${response.status}`);
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error reopening post:', error);
+ return false;
+ }
+ }, []);
+
+ return {
+ markAsFound,
+ reopenPost,
+ };
+}
diff --git a/src/hooks/useUnreadSocket.js b/src/hooks/useUnreadSocket.js
new file mode 100644
index 0000000..8ac17f1
--- /dev/null
+++ b/src/hooks/useUnreadSocket.js
@@ -0,0 +1,35 @@
+import { useEffect, useRef } from "react";
+import { Client } from "@stomp/stompjs";
+import SockJS from "sockjs-client";
+import { API_URL } from "../config/authConstants";
+import { useUnread } from "../context/UnreadContext";
+import { getActiveChatId } from "../stores/activeChatStore";
+
+export function useUnreadSocket(userId) {
+ const { setChatUnread } = useUnread();
+ const clientRef = useRef(null);
+
+ useEffect(() => {
+ if (!userId) return;
+
+ const token = localStorage.getItem("token");
+ const socket = new SockJS(`${API_URL}/ws?token=${token}`);
+
+ const client = new Client({
+ webSocketFactory: () => socket,
+ reconnectDelay: 3000,
+ onConnect: () => {
+ client.subscribe(`/topic/user/${userId}/unread`, (msg) => {
+ const body = JSON.parse(msg.body);
+ if (body.chatId === getActiveChatId()) return;
+ setChatUnread(body.chatId, body.unreadCount);
+ });
+ },
+ });
+
+ client.activate();
+ clientRef.current = client;
+
+ return () => client.deactivate();
+ }, [userId, setChatUnread]);
+}
diff --git a/src/hooks/useUpdateProfile.js b/src/hooks/useUpdateProfile.js
new file mode 100644
index 0000000..c5bc6d9
--- /dev/null
+++ b/src/hooks/useUpdateProfile.js
@@ -0,0 +1,57 @@
+import { useState } from "react";
+import { API_URL } from "../config/authConstants";
+
+/**
+ * useUpdateProfile — хук для обновления профиля пользователя.
+ * Отправляет PUT /api/v1/users/{id} с FormData (поддерживает загрузку аватара).
+ */
+export function useUpdateProfile() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const updateProfile = async (userId, profileData) => {
+ setLoading(true);
+ setError("");
+
+ try {
+ const token = localStorage.getItem("token");
+
+ // Используем FormData — чтобы отправить файл аватара вместе с текстовыми полями
+ const formData = new FormData();
+ formData.append("id", userId);
+ formData.append("firstName", profileData.firstName || "");
+ formData.append("lastName", profileData.lastName || "");
+ formData.append("login", profileData.login || "");
+ formData.append("phone", profileData.phone || "");
+ formData.append("email", profileData.email || "");
+
+ // Если avatar — это File (новый файл), отправляем как часть FormData
+ if (profileData.avatar instanceof File) {
+ formData.append("avatar", profileData.avatar);
+ }
+
+ const res = await fetch(`${API_URL}/api/v1/users/${userId}`, {
+ method: "PUT",
+ headers: {
+ "Authorization": `Bearer ${token}`,
+ },
+ body: formData,
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.message || `Ошибка ${res.status}`);
+ }
+
+ const updatedUser = await res.json();
+ return updatedUser;
+ } catch (err) {
+ setError(err.message || "Не удалось сохранить профиль");
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return { updateProfile, loading, error };
+}
diff --git a/src/index.js b/src/index.js
index d563c0f..09ebe3b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,12 +2,15 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
+import { UnreadProvider } from './context/UnreadContext';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
-
+
+
+
);
diff --git a/src/stores/activeChatStore.js b/src/stores/activeChatStore.js
new file mode 100644
index 0000000..7f6f547
--- /dev/null
+++ b/src/stores/activeChatStore.js
@@ -0,0 +1,9 @@
+let activeChatId = null;
+
+export function setActiveChatId(id) {
+ activeChatId = id;
+}
+
+export function getActiveChatId() {
+ return activeChatId;
+}
diff --git a/src/stores/chatPostStore.js b/src/stores/chatPostStore.js
new file mode 100644
index 0000000..0d65e39
--- /dev/null
+++ b/src/stores/chatPostStore.js
@@ -0,0 +1,17 @@
+const postByChatId = new Map();
+
+export function setPostForChat(chatId, post) {
+ postByChatId.set(chatId, post);
+}
+
+export function getPostForChat(chatId) {
+ return postByChatId.get(chatId);
+}
+
+export function cachePostsFromChats(chats) {
+ for (const chat of chats) {
+ if (chat.post) {
+ postByChatId.set(chat.id, chat.post);
+ }
+ }
+}
diff --git a/src/styles/chat.css b/src/styles/chat.css
new file mode 100644
index 0000000..5b359eb
--- /dev/null
+++ b/src/styles/chat.css
@@ -0,0 +1,346 @@
+/* ── Chat Page ── */
+.chat-page {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ height: 100vh;
+ background: linear-gradient(135deg, #1a2340 0%, #0d1018 100%);
+}
+
+/* Header */
+.chat-header {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(13, 16, 24, .7);
+ backdrop-filter: blur(12px);
+}
+.chat-back-btn {
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ color: var(--muted);
+ font-size: 1.2rem;
+ cursor: pointer;
+ padding: 6px 12px;
+ transition: background .15s, color .15s;
+}
+.chat-back-btn:hover {
+ background: rgba(255, 255, 255, .08);
+ color: #fff;
+}
+.chat-header-title {
+ flex: 1;
+ color: #fff;
+ font-weight: 900;
+ font-size: 1.1rem;
+}
+.chat-status {
+ font-size: .75rem;
+ font-weight: 700;
+ color: var(--muted);
+}
+.chat-status.connected {
+ color: #2de0c8;
+}
+
+.chat-header-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: rgba(45, 224, 200, .15);
+ border: 2px solid rgba(45, 224, 200, .3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1rem;
+ flex-shrink: 0;
+ overflow: hidden;
+}
+.chat-header-avatar-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 50%;
+}
+
+/* Messages area */
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.chat-messages::-webkit-scrollbar { width: 4px; }
+.chat-messages::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
+
+.chat-empty {
+ text-align: center;
+ color: var(--muted);
+ font-size: .9rem;
+ padding: 40px 0;
+}
+.chat-empty-full {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--muted);
+ font-size: .95rem;
+}
+
+/* Bubbles */
+.chat-bubble {
+ max-width: 70%;
+ padding: 10px 14px;
+ border-radius: 14px;
+ font-size: .93rem;
+ line-height: 1.45;
+ align-self: flex-start;
+ background: rgba(255, 255, 255, .08);
+ color: #fff;
+ word-break: break-word;
+}
+.chat-bubble.mine {
+ align-self: flex-end;
+ background: #2de0c8;
+ color: #082320;
+}
+
+/* Input row */
+.chat-input-row {
+ display: flex;
+ gap: 10px;
+ padding: 16px;
+ border-top: 1px solid var(--border);
+ background: rgba(13, 16, 24, .5);
+ backdrop-filter: blur(8px);
+}
+.chat-input {
+ flex: 1;
+ padding: 12px 16px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, .08);
+ color: #fff;
+ font-family: var(--font);
+ font-size: .9rem;
+ outline: none;
+ transition: border-color .2s;
+}
+.chat-input::placeholder { color: var(--muted); }
+.chat-input:focus { border-color: rgba(45, 224, 200, .5); }
+
+.chat-send-btn {
+ padding: 0 20px;
+ border-radius: 12px;
+ border: none;
+ background: #2de0c8;
+ color: #082320;
+ cursor: pointer;
+ font-size: 1.1rem;
+ font-weight: 900;
+ transition: background .2s, opacity .2s;
+}
+.chat-send-btn:hover:not(:disabled) { background: #24c9b2; }
+.chat-send-btn:disabled { opacity: .4; cursor: default; }
+
+/* ── Emoji picker ── */
+.chat-input-area {
+ position: relative;
+}
+.chat-emoji-picker {
+ position: absolute;
+ bottom: 100%;
+ right: 0;
+ margin-bottom: 8px;
+ z-index: 100;
+}
+.chat-emoji-picker em-emoji-picker {
+ width: 320px;
+ max-height: 350px;
+ --rgb-background: 13, 16, 24;
+ --rgb-accent: 45, 224, 200;
+}
+.chat-emoji-btn {
+ background: rgba(255, 255, 255, .08);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 0 12px;
+ cursor: pointer;
+ font-size: 1.3rem;
+ line-height: 1;
+ transition: background .15s, border-color .15s;
+}
+.chat-emoji-btn:hover,
+.chat-emoji-btn.active {
+ background: rgba(255, 255, 255, .14);
+ border-color: rgba(45, 224, 200, .5);
+}
+
+/* ── Chat List ── */
+.chat-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+}
+.chat-list::-webkit-scrollbar { width: 4px; }
+.chat-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
+
+.chat-list-item {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 16px;
+ border-radius: 14px;
+ cursor: pointer;
+ transition: background .15s;
+ border: 1px solid transparent;
+}
+.chat-list-item:hover {
+ background: rgba(255, 255, 255, .05);
+ border-color: rgba(45, 224, 200, .2);
+}
+
+.chat-list-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: rgba(45, 224, 200, .15);
+ border: 2px solid rgba(45, 224, 200, .3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.15rem;
+ font-weight: 800;
+ color: #2de0c8;
+ flex-shrink: 0;
+ overflow: hidden;
+}
+.chat-list-avatar-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 50%;
+}
+
+.chat-list-info {
+ flex: 1;
+ min-width: 0;
+}
+.chat-list-item.unread .chat-list-name {
+ color: #2de0c8;
+}
+.chat-list-item.unread .chat-list-preview {
+ color: rgba(45, 224, 200, .6);
+ font-weight: 600;
+}
+.chat-list-name {
+ color: #fff;
+ font-weight: 700;
+ font-size: .9rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.chat-list-preview {
+ color: var(--muted);
+ font-size: .78rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 3px;
+}
+
+/* ── Unread Badge ── */
+.chat-list-unread-badge {
+ background: #2de0c8;
+ color: #082320;
+ font-size: .7rem;
+ font-weight: 900;
+ min-width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 6px;
+ flex-shrink: 0;
+}
+
+/* ── Sidebar Unread Badge ── */
+.f-sb-unread-badge {
+ background: #2de0c8;
+ color: #082320;
+ font-size: .65rem;
+ font-weight: 900;
+ min-width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 5px;
+ margin-left: auto;
+}
+
+/* ── Contact Button on Card ── */
+.f-card-contact {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ background: rgba(45, 224, 200, .12);
+ border: 1px solid rgba(45, 224, 200, .3);
+ border-radius: 20px;
+ padding: 5px 14px;
+ cursor: pointer;
+ font-family: var(--font);
+ font-size: .8rem;
+ font-weight: 700;
+ color: #2de0c8;
+ transition: all .2s;
+}
+.f-card-contact:hover {
+ background: rgba(45, 224, 200, .22);
+ border-color: rgba(45, 224, 200, .5);
+}
+
+.chat-list-delete-btn {
+ background: rgba(255, 71, 87, .1);
+ border: 1px solid rgba(255, 71, 87, .2);
+ border-radius: 10px;
+ color: rgba(255, 71, 87, .5);
+ cursor: pointer;
+ padding: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity .2s, background .2s, border-color .2s, color .2s;
+ flex-shrink: 0;
+}
+.chat-list-item:hover .chat-list-delete-btn {
+ opacity: 1;
+}
+.chat-list-delete-btn:hover {
+ background: rgba(255, 71, 87, .2);
+ border-color: rgba(255, 71, 87, .4);
+ color: #ff4757;
+}
+
+/* Mobile adjustments */
+@media (max-width: 600px) {
+ .chat-header {
+ padding: 12px 14px;
+ }
+ .chat-input-row {
+ padding: 12px;
+ }
+ .chat-bubble {
+ max-width: 85%;
+ }
+}
diff --git a/src/styles/reviews.css b/src/styles/reviews.css
new file mode 100644
index 0000000..69bf325
--- /dev/null
+++ b/src/styles/reviews.css
@@ -0,0 +1,324 @@
+/* ── Reviews Page ── */
+.rv-page-title {
+ font-size: 1.15rem;
+ font-weight: 900;
+ color: #fff;
+ margin: 0;
+}
+
+.rv-layout {
+ display: flex;
+ gap: 24px;
+ padding: 20px;
+ flex: 1;
+ overflow-y: auto;
+}
+
+/* ── Left sidebar (summary + form) ── */
+.rv-sidebar {
+ width: 300px;
+ min-width: 280px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+/* ── Summary block ── */
+.rv-summary {
+ background: rgba(255, 255, 255, .04);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.rv-summary-score {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.rv-summary-num {
+ font-size: 2.6rem;
+ font-weight: 900;
+ color: #f5a623;
+ line-height: 1;
+}
+.rv-summary-none {
+ color: var(--muted);
+ font-size: .9rem;
+ font-weight: 700;
+}
+.rv-summary-total {
+ color: var(--muted);
+ font-size: .8rem;
+ font-weight: 600;
+}
+
+/* Distribution */
+.rv-distribution {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 6px;
+}
+.rv-dist-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.rv-dist-star {
+ font-size: .75rem;
+ color: var(--muted);
+ min-width: 32px;
+ text-align: right;
+ font-weight: 700;
+}
+.rv-dist-bar {
+ flex: 1;
+ height: 6px;
+ background: rgba(255, 255, 255, .06);
+ border-radius: 3px;
+ overflow: hidden;
+}
+.rv-dist-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #f5a623, #f7c948);
+ border-radius: 3px;
+ transition: width .4s ease;
+}
+.rv-dist-count {
+ font-size: .7rem;
+ color: var(--muted);
+ min-width: 16px;
+ text-align: right;
+ font-weight: 700;
+}
+
+/* ── Star Rating ── */
+.rv-stars {
+ display: flex;
+ gap: 3px;
+}
+.rv-star {
+ color: rgba(255, 255, 255, .2);
+ transition: color .15s, transform .15s;
+ flex-shrink: 0;
+}
+.rv-star.filled {
+ color: #f5a623;
+}
+.rv-star.interactive {
+ cursor: pointer;
+}
+.rv-star.interactive:hover {
+ transform: scale(1.2);
+}
+
+/* ── Review Form ── */
+.rv-form {
+ background: rgba(255, 255, 255, .04);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.rv-form-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 800;
+ color: #fff;
+}
+.rv-form-rating {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.rv-form-label {
+ font-size: .85rem;
+ font-weight: 700;
+ color: var(--muted);
+}
+.rv-form-error {
+ color: #ff6b6b;
+ font-size: .8rem;
+ font-weight: 700;
+}
+.rv-form-textarea {
+ background: rgba(255, 255, 255, .05);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 12px 14px;
+ color: #fff;
+ font-family: var(--font);
+ font-size: .88rem;
+ resize: vertical;
+ transition: border-color .15s, box-shadow .15s;
+}
+.rv-form-textarea:focus {
+ outline: none;
+ border-color: var(--teal);
+ box-shadow: 0 0 0 3px rgba(45, 224, 200, .12);
+}
+.rv-form-textarea::placeholder {
+ color: rgba(255, 255, 255, .32);
+}
+.rv-form-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.rv-form-counter {
+ font-size: .72rem;
+ color: var(--muted);
+ font-weight: 600;
+}
+.rv-form-submit {
+ background: linear-gradient(135deg, var(--teal2), var(--teal));
+ border: none;
+ border-radius: 10px;
+ padding: 10px 22px;
+ color: #111;
+ font-family: var(--font);
+ font-size: .85rem;
+ font-weight: 800;
+ cursor: pointer;
+ transition: opacity .15s, transform .15s;
+}
+.rv-form-submit:hover:not(:disabled) {
+ opacity: .9;
+ transform: translateY(-1px);
+}
+.rv-form-submit:disabled {
+ opacity: .5;
+ cursor: not-allowed;
+}
+
+/* ── Login prompt ── */
+.rv-login-prompt {
+ background: rgba(255, 255, 255, .04);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 20px;
+ text-align: center;
+}
+.rv-login-prompt p {
+ color: var(--muted);
+ font-size: .88rem;
+ margin: 0 0 14px;
+}
+.rv-login-btn {
+ background: linear-gradient(135deg, var(--teal2), var(--teal));
+ border: none;
+ border-radius: 10px;
+ padding: 10px 24px;
+ color: #111;
+ font-family: var(--font);
+ font-size: .85rem;
+ font-weight: 800;
+ cursor: pointer;
+ transition: opacity .15s;
+}
+.rv-login-btn:hover {
+ opacity: .9;
+}
+
+/* ── Reviews List ── */
+.rv-list {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+}
+
+/* ── Review Card ── */
+.rv-card {
+ background: rgba(255, 255, 255, .04);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 18px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ transition: border-color .15s;
+ animation: rvCardIn .5s cubic-bezier(.34, 1.56, .64, 1) both;
+}
+.rv-card:hover {
+ border-color: rgba(45, 224, 200, .25);
+}
+@keyframes rvCardIn {
+ from { opacity: 0; transform: translateY(16px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.rv-card-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.rv-card-avatar {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ background: rgba(45, 224, 200, .15);
+ border: 2px solid rgba(45, 224, 200, .3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+ font-weight: 800;
+ color: var(--teal);
+ flex-shrink: 0;
+}
+.rv-card-meta {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.rv-card-name {
+ font-size: .88rem;
+ font-weight: 700;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rv-card-date {
+ font-size: .72rem;
+ color: var(--muted);
+}
+.rv-card-rating {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ flex-shrink: 0;
+}
+.rv-card-rating-num {
+ font-size: .7rem;
+ color: var(--muted);
+ font-weight: 700;
+}
+.rv-card-text {
+ margin: 0;
+ font-size: .88rem;
+ color: rgba(255, 255, 255, .78);
+ line-height: 1.55;
+}
+
+/* ── Responsive ── */
+@media (max-width: 768px) {
+ .rv-layout {
+ flex-direction: column;
+ padding: 14px;
+ }
+ .rv-sidebar {
+ width: 100%;
+ min-width: 0;
+ }
+}
diff --git a/src/styles/rules.css b/src/styles/rules.css
new file mode 100644
index 0000000..d005ce0
--- /dev/null
+++ b/src/styles/rules.css
@@ -0,0 +1,132 @@
+/* ── Rules Modal overrides ── */
+.rules-modal {
+ width: 560px;
+ max-width: 96vw;
+}
+
+.rules-modal-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.rules-modal-icon {
+ font-size: 1.3rem;
+ line-height: 1;
+}
+
+/* Rules list container */
+.rules-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+/* Single section */
+.rules-section {
+ border: 1px solid rgba(255,255,255,.07);
+ border-radius: 14px;
+ overflow: hidden;
+ background: rgba(255,255,255,.025);
+}
+
+.rules-section-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px 10px;
+ border-bottom: 1px solid rgba(255,255,255,.06);
+}
+
+.rules-num {
+ min-width: 26px;
+ height: 26px;
+ background: linear-gradient(135deg, #1fc9b0, #2de0c8);
+ color: #082320;
+ border-radius: 8px;
+ font-size: .75rem;
+ font-weight: 900;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.rules-title {
+ color: #fff;
+ font-weight: 800;
+ font-size: .92rem;
+ letter-spacing: -.1px;
+}
+
+/* Body */
+.rules-body {
+ padding: 10px 16px 12px 16px;
+ color: var(--muted);
+ font-size: .83rem;
+ line-height: 1.65;
+}
+
+.rules-body p {
+ margin: 0 0 8px;
+}
+
+.rules-body p:last-child {
+ margin-bottom: 0;
+}
+
+.rules-body ul {
+ margin: 4px 0 8px;
+ padding-left: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.rules-body ul:last-child {
+ margin-bottom: 0;
+}
+
+.rules-body li {
+ line-height: 1.55;
+}
+
+/* Sub-section titles (3.1, 3.2 …) */
+.rules-sub-title {
+ color: rgba(255,255,255,.55);
+ font-size: .75rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: .5px;
+ margin: 10px 0 4px !important;
+}
+
+.rules-sub-title:first-child {
+ margin-top: 0 !important;
+}
+
+/* Legal reference highlight */
+.rules-law {
+ color: var(--teal);
+ font-weight: 700;
+}
+
+/* Warning paragraph */
+.rules-warn {
+ background: rgba(255,107,107,.1);
+ border: 1px solid rgba(255,107,107,.2);
+ border-radius: 8px;
+ padding: 8px 12px;
+ color: #ff9999;
+ font-size: .8rem;
+ margin-top: 8px !important;
+}
+
+@media (max-width: 600px) {
+ .rules-section-head {
+ padding: 10px 12px 8px;
+ }
+ .rules-body {
+ padding: 8px 12px 10px;
+ }
+}