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

25
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "react_front", "name": "react_front",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@pbe/react-yandex-maps": "^1.2.5", "@pbe/react-yandex-maps": "^1.2.5",
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",
@@ -2431,6 +2433,22 @@
"tslib": "^2.4.0" "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": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "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" "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": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",

View File

@@ -3,6 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@pbe/react-yandex-maps": "^1.2.5", "@pbe/react-yandex-maps": "^1.2.5",
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",

13
react_front/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react_front</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

184
react_front/src/App.css Normal file
View File

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

111
react_front/src/index.css Normal file
View File

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

View File

@@ -1,6 +1,8 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext'; import { ThemeProvider } from './context/ThemeContext';
import { useAuthContext } from './context/AuthContext';
import { useUnreadSocket } from './hooks/useUnreadSocket';
import RegisterPage from './components/Authentication'; import RegisterPage from './components/Authentication';
import MainFeed from './components/MainFeed'; import MainFeed from './components/MainFeed';
import ChatPage from './components/ChatPage'; import ChatPage from './components/ChatPage';
@@ -8,10 +10,17 @@ import ChatListPage from './components/ChatListPage';
import ReviewsPage from './components/ReviewsPage'; import ReviewsPage from './components/ReviewsPage';
import './styles/chat.css'; import './styles/chat.css';
function UnreadListener() {
const { user } = useAuthContext();
useUnreadSocket(user?.id);
return null;
}
function App() { function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<UnreadListener />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/feed" replace />} /> <Route path="/" element={<Navigate to="/feed" replace />} />

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) => { const handleContact = async (e) => {
e.stopPropagation(); e.stopPropagation();
try { 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}`); navigate(`/chats/${chat.id}`);
} catch (err) { } catch (err) {
console.error(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 {NavIcons} from "./icons/NavIcons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTheme } from "../context/ThemeContext"; import { useTheme } from "../context/ThemeContext";
import { useUnread } from "../context/UnreadContext";
export default function Sidebar({ export default function Sidebar({
open, open,
@@ -16,6 +17,7 @@ export default function Sidebar({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { theme, toggle: toggleTheme } = useTheme(); const { theme, toggle: toggleTheme } = useTheme();
const { totalUnread } = useUnread();
return ( return (
<> <>
<div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} /> <div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} />
@@ -49,6 +51,9 @@ export default function Sidebar({
onClick={handleClick} onClick={handleClick}
> >
<Ic /> {label} <Ic /> {label}
{label === "Чат" && totalUnread > 0 && (
<span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span>
)}
</div> </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>
);
}

View File

@@ -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 (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
login,
logout,
updateUser,
}}
>
{children}
</AuthContext.Provider>
);
}
/** Хук для доступа к контексту аутентификации. */
export function useAuthContext() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuthContext must be used inside AuthProvider");
}
return ctx;
}

View File

@@ -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 (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}

View File

@@ -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 (
<UnreadContext.Provider value={{ unreadMap, totalUnread, setChatUnread, clearChatUnread, updateFromChats }}>
{children}
</UnreadContext.Provider>
);
}
export function useUnread() {
const ctx = useContext(UnreadContext);
if (!ctx) throw new Error('useUnread must be used within UnreadProvider');
return ctx;
}

View File

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

62
src/hooks/useChats.js Normal file
View File

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

View File

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

View File

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

74
src/hooks/useFavorites.js Normal file
View File

@@ -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<boolean>} - 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<Array>} - массив 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,
};
}

View File

@@ -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<boolean>} - 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<boolean>} - 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,
};
}

View File

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

View File

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

View File

@@ -2,12 +2,15 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import { UnreadProvider } from './context/UnreadContext';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<UnreadProvider>
<App /> <App />
</UnreadProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -0,0 +1,9 @@
let activeChatId = null;
export function setActiveChatId(id) {
activeChatId = id;
}
export function getActiveChatId() {
return activeChatId;
}

View File

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

346
src/styles/chat.css Normal file
View File

@@ -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%;
}
}

324
src/styles/reviews.css Normal file
View File

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

132
src/styles/rules.css Normal file
View File

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