25
package-lock.json
generated
25
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
13
react_front/index.html
Normal 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>
|
||||||
1
react_front/public/favicon.svg
Normal file
1
react_front/public/favicon.svg
Normal file
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
184
react_front/src/App.css
Normal 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
111
react_front/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
288
src/components/AuthModal.jsx
Normal file
288
src/components/AuthModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/ChatListPage.jsx
Normal file
118
src/components/ChatListPage.jsx
Normal 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
148
src/components/ChatPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/ImageViewer.jsx
Normal file
66
src/components/ImageViewer.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
343
src/components/ReviewsPage.jsx
Normal file
343
src/components/ReviewsPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/components/RulesModal.jsx
Normal file
177
src/components/RulesModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
10
src/components/icons/TrashIcon.jsx
Normal file
10
src/components/icons/TrashIcon.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/context/AuthContext.jsx
Normal file
67
src/context/AuthContext.jsx
Normal 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;
|
||||||
|
}
|
||||||
38
src/context/ThemeContext.jsx
Normal file
38
src/context/ThemeContext.jsx
Normal 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;
|
||||||
|
}
|
||||||
50
src/context/UnreadContext.jsx
Normal file
50
src/context/UnreadContext.jsx
Normal 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;
|
||||||
|
}
|
||||||
44
src/hooks/useChatSocket.js
Normal file
44
src/hooks/useChatSocket.js
Normal 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
62
src/hooks/useChats.js
Normal 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 };
|
||||||
|
}
|
||||||
32
src/hooks/useDeleteChat.js
Normal file
32
src/hooks/useDeleteChat.js
Normal 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 };
|
||||||
|
}
|
||||||
40
src/hooks/useDeletePost.js
Normal file
40
src/hooks/useDeletePost.js
Normal 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
74
src/hooks/useFavorites.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
src/hooks/usePostStatus.js
Normal file
80
src/hooks/usePostStatus.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
35
src/hooks/useUnreadSocket.js
Normal file
35
src/hooks/useUnreadSocket.js
Normal 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]);
|
||||||
|
}
|
||||||
57
src/hooks/useUpdateProfile.js
Normal file
57
src/hooks/useUpdateProfile.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
9
src/stores/activeChatStore.js
Normal file
9
src/stores/activeChatStore.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
let activeChatId = null;
|
||||||
|
|
||||||
|
export function setActiveChatId(id) {
|
||||||
|
activeChatId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveChatId() {
|
||||||
|
return activeChatId;
|
||||||
|
}
|
||||||
17
src/stores/chatPostStore.js
Normal file
17
src/stores/chatPostStore.js
Normal 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
346
src/styles/chat.css
Normal 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
324
src/styles/reviews.css
Normal 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
132
src/styles/rules.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user