fixed code

This commit is contained in:
SlimusMinus
2026-08-11 00:24:07 +03:00
parent c50b75eece
commit 7f59ee1f38
95 changed files with 3623 additions and 2830 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3

View File

@@ -4,7 +4,6 @@ import { ThemeProvider } from './context/ThemeContext';
import { useAuthContext } from './context/AuthContext';
import { useUnreadSocket } from './hooks/useUnreadSocket';
import LandingPage from './pages/NakhodkaLanding';
import RegisterPage from './components/Authentication';
import MainFeed from './pages/MainFeed';
import ChatPage from './pages/ChatPage';
import ChatListPage from './pages/ChatListPage';
@@ -25,8 +24,6 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/feed" element={<MainFeed />} />
<Route path="/reviews" element={<ReviewsPage />} />
<Route path="/chats" element={<ChatListPage />} />

View File

@@ -1,3 +1,4 @@
// src/api/apiClient.jsx
import { API_URL } from "../config/authConstants";
function getAccessToken() {
@@ -22,8 +23,6 @@ function clearTokens() {
let refreshPromise = null;
export async function refreshAccessToken() {
// Если несколько запросов одновременно словили 401 — не долбим /refresh параллельно,
// а ждём один и тот же промис
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
@@ -82,4 +81,35 @@ export async function apiFetch(url, options = {}) {
return res;
}
/**
* login/register — неавторизованные эндпоинты, без apiFetch (нет токена на входе).
* Обе бросают Error с сообщением от сервера или переданным fallback-текстом.
*/
export async function login(loginValue, password, fallbackErrorMessage) {
const res = await fetch(`${API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login: loginValue, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || fallbackErrorMessage);
}
return res.json(); // { token, refreshToken, user }
}
export async function register(name, loginValue, password, fallbackErrorMessage) {
const res = await fetch(`${API_URL}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, login: loginValue, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || fallbackErrorMessage);
return data;
}
export { getAccessToken, getRefreshToken, saveTokens, clearTokens };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1,9 +1,9 @@
import { useState, useCallback } from "react";
// src/components/AuthModal.jsx
import { useState } from "react";
import { useTranslation } from 'react-i18next';
import CloseIcon from "./icons/CloseIcon";
import { useAuthContext } from "../context/AuthContext";
import { API_URL } from "../config/authConstants";
import { saveTokens } from "../api/apiClient";
import { login as apiLogin, register as apiRegister } from "../api/apiClient";
/**
* AuthModal — модальное окно входа и регистрации прямо на странице ленты.
@@ -15,32 +15,31 @@ export default function AuthModal({ onClose }) {
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">
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal auth-modal">
<div className="f-modal-head">
<span className="f-modal-title">
{mode === "login" ? t('auth.loginModalTitle') : t('auth.registerModalTitle')}
</span>
<button className="f-close-btn" onClick={onClose}>
<CloseIcon />
</button>
</div>
<button className="f-close-btn" type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
{mode === "login"
? <LoginFormInline
saveAuth={saveAuth}
onClose={onClose}
onSwitch={() => setMode("register")}
/>
: <RegisterFormInline
saveAuth={saveAuth}
onClose={onClose}
onSwitch={() => setMode("login")}
/>
}
{mode === "login"
? <LoginFormInline
saveAuth={saveAuth}
onClose={onClose}
onSwitch={() => setMode("register")}
/>
: <RegisterFormInline
saveAuth={saveAuth}
onClose={onClose}
onSwitch={() => setMode("login")}
/>
}
</div>
</div>
</div>
);
}
@@ -52,25 +51,16 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
const handleSubmit = async (e) => {
e.preventDefault();
if (!login || !password) { setError(t('auth.enterLoginPassword')); 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 || t('auth.invalidCredentials'));
}
const { token, refreshToken, user } = await res.json();
saveTokens(token, refreshToken);
saveAuth(user, token);
const { token, refreshToken, user } = await apiLogin(
login, password, t('auth.invalidCredentials')
);
saveAuth(user, token, refreshToken);
onClose();
} catch (err) {
setError(err.message);
@@ -80,56 +70,43 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
};
return (
<>
<div className="f-m-group">
<label className="f-m-label">{t('auth.loginLabel')}</label>
<input
className="f-m-field"
type="text"
placeholder={t('auth.yourLogin')}
value={login}
onChange={e => { setLogin(e.target.value); setError(""); }}
autoComplete="off"
/>
</div>
<form onSubmit={handleSubmit}>
<div className="f-m-group">
<label className="f-m-label">{t('auth.loginLabel')}</label>
<input
className="f-m-field"
type="text"
placeholder={t('auth.yourLogin')}
value={login}
onChange={e => { setLogin(e.target.value); setError(""); }}
autoComplete="off"
/>
</div>
<div className="f-m-group">
<label className="f-m-label">{t('auth.passwordLabel')}</label>
<input
className="f-m-field"
type="password"
placeholder={t('auth.yourPassword')}
value={password}
onChange={e => { setPassword(e.target.value); setError(""); }}
onKeyDown={e => e.key === "Enter" && handleSubmit()}
/>
</div>
<div className="f-m-group">
<label className="f-m-label">{t('auth.passwordLabel')}</label>
<input
className="f-m-field"
type="password"
placeholder={t('auth.yourPassword')}
value={password}
onChange={e => { setPassword(e.target.value); setError(""); }}
/>
</div>
{error && <p className="f-err-msg" style={{ marginBottom: 10 }}>{error}</p>}
{error && <p className="f-err-msg auth-form-error">{error}</p>}
<SubmitButton
loading={loading}
label={t('auth.login')}
onClick={handleSubmit}
/>
<SubmitButton loading={loading} label={t('auth.login')} />
<p style={{
textAlign: "center", marginTop: 14,
color: "rgba(255,255,255,.45)", fontSize: ".85rem"
}}>
{t('auth.noAccount')}{" "}
<button
onClick={onSwitch}
style={{
background: "none", border: "none", color: "#2de0c8",
cursor: "pointer", fontWeight: 700, fontSize: ".85rem",
fontFamily: "var(--font)",
}}
>
{t('auth.register')}
</button>
</p>
</>
<p style={{textAlign: "center", marginTop: 14, color: "rgba(255,255,255,.45)", fontSize: ".85rem"}}>
{t('auth.noAccount')}{" "}
<button onClick={onSwitch}
style={{background: "none", border: "none", color: "#2de0c8", cursor: "pointer", fontWeight: 700, fontSize: ".85rem", fontFamily: "var(--font)",}}
>
{t('auth.register')}
</button>
</p>
</form>
);
}
@@ -143,53 +120,39 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
const [error, setError] = useState("");
const [errors, setErrors] = useState({});
const setField = useCallback((key, val) => {
const setField = (key, val) => {
setForm(p => ({ ...p, [key]: val }));
setErrors(p => ({ ...p, [key]: "" }));
}, []);
};
const validate = () => {
const e = {};
if (!form.name.trim()) e.name = t('auth.validateNameRequired');
if (!form.login.trim()) e.login = t('auth.validateLoginRequired');
else if (form.login.length < 3) e.login = t('auth.loginMinChars');
if (form.password.length < 6) e.password = t('auth.validatePasswordMin');
if (form.password !== form.password2) e.password2 = t('auth.validatePasswordMatch');
if (!form.name.trim()) e.name = t('auth.validateNameRequired');
if (!form.login.trim()) e.login = t('auth.validateLoginRequired');
else if (form.login.length < 3) e.login = t('auth.loginMinChars');
if (form.password.length < 6) e.password = t('auth.validatePasswordMin');
if (form.password !== form.password2) e.password2 = t('auth.validatePasswordMatch');
setErrors(e);
return !Object.keys(e).length;
};
const handleSubmit = async () => {
const handleSubmit = async (e) => {
e.preventDefault();
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,
}),
});
await apiRegister(form.name, form.login, form.password, t('auth.registrationError'));
const regData = await regRes.json();
if (!regRes.ok) throw new Error(regData.message || t('auth.registrationError'));
let session;
try {
session = await apiLogin(form.login, form.password, t('auth.autoLoginFailed'));
} catch {
throw new Error(t('auth.accountCreatedLoginManually'));
}
// 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, refreshToken, user } = await loginRes.json();
saveTokens(token, refreshToken);
saveAuth(user, token);
const { token, refreshToken, user } = session;
saveAuth(user, token, refreshToken);
onClose();
} catch (err) {
setError(err.message);
@@ -199,97 +162,79 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
};
return (
<>
<div className="f-m-group">
<label className="f-m-label">{t('auth.nameLabel')}</label>
<input
className={`f-m-field${errors.name ? " err" : ""}`}
type="text"
placeholder={t('auth.namePlaceholder')}
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">{t('auth.loginLabel')}</label>
<input
className={`f-m-field${errors.login ? " err" : ""}`}
type="text"
placeholder={t('auth.loginPlaceholder')}
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">
<form onSubmit={handleSubmit}>
<div className="f-m-group">
<label className="f-m-label">{t('auth.passwordLabel')}</label>
<label className="f-m-label">{t('auth.nameLabel')}</label>
<input
className={`f-m-field${errors.password ? " err" : ""}`}
type="password"
placeholder={t('auth.passwordMinShort')}
value={form.password}
onChange={e => setField("password", e.target.value)}
className={`f-m-field${errors.name ? " err" : ""}`}
type="text"
placeholder={t('auth.namePlaceholder')}
value={form.name}
onChange={e => setField("name", e.target.value)}
/>
{errors.password && <p className="f-err-msg">{errors.password}</p>}
{errors.name && <p className="f-err-msg">{errors.name}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">{t('auth.repeatLabel')}</label>
<label className="f-m-label">{t('auth.loginLabel')}</label>
<input
className={`f-m-field${errors.password2 ? " err" : ""}`}
type="password"
placeholder={t('auth.repeatPlaceholder')}
value={form.password2}
onChange={e => setField("password2", e.target.value)}
className={`f-m-field${errors.login ? " err" : ""}`}
type="text"
placeholder={t('auth.loginPlaceholder')}
value={form.login}
onChange={e => setField("login", e.target.value)}
/>
{errors.password2 && <p className="f-err-msg">{errors.password2}</p>}
{errors.login && <p className="f-err-msg">{errors.login}</p>}
</div>
</div>
{error && <p className="f-err-msg" style={{ marginBottom: 10 }}>{error}</p>}
<div className="f-m-row">
<div className="f-m-group">
<label className="f-m-label">{t('auth.passwordLabel')}</label>
<input
className={`f-m-field${errors.password ? " err" : ""}`}
type="password"
placeholder={t('auth.passwordMinShort')}
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">{t('auth.repeatLabel')}</label>
<input
className={`f-m-field${errors.password2 ? " err" : ""}`}
type="password"
placeholder={t('auth.repeatPlaceholder')}
value={form.password2}
onChange={e => setField("password2", e.target.value)}
/>
{errors.password2 && <p className="f-err-msg">{errors.password2}</p>}
</div>
</div>
<SubmitButton
loading={loading}
label={t('auth.createAccountBtn').replace(' ✓', '')}
onClick={handleSubmit}
/>
{error && <p className="f-err-msg auth-form-error">{error}</p>}
<p style={{
textAlign: "center", marginTop: 14,
color: "rgba(255,255,255,.45)", fontSize: ".85rem"
}}>
{t('auth.hasAccount')}{" "}
<button
onClick={onSwitch}
style={{
background: "none", border: "none", color: "#2de0c8",
cursor: "pointer", fontWeight: 700, fontSize: ".85rem",
fontFamily: "var(--font)",
}}
>
{t('auth.loginLink')}
</button>
</p>
</>
<SubmitButton loading={loading} label={t('auth.createAccountBtn')} />
<p style={{textAlign: "center", marginTop: 14, color: "rgba(255,255,255,.45)", fontSize: ".85rem"}}>
{t('auth.hasAccount')}{" "}
<button onClick={onSwitch}
style={{background: "none", border: "none", color: "#2de0c8", cursor: "pointer", fontWeight: 700, fontSize: ".85rem", fontFamily: "var(--font)",}}>
{t('auth.loginLink')}
</button>
</p>
</form>
);
}
// ─── Shared submit button ──────────────────────────────────────────────────
function SubmitButton({ loading, label, onClick }) {
function SubmitButton({ loading, label }) {
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>
<button className="f-m-submit auth-submit-btn" type="submit" disabled={loading}>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner" />}
{label}
</div>
</button>
);
}
}

View File

@@ -1,386 +0,0 @@
import { useState, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import MagnifierLogo from "./MagnifierLogo";
import { useAuth } from "../hooks/useAuth";
import { useRegister } from "../hooks/useRegister";
import {FLOAT_ICONS} from "../config/authConstants";
import "../styles/global.css";
import "../styles/auth.css";
import likeSmile from '../assets/success.png';
import { useCheckLogin } from "../hooks/useRegister";
// ─── helpers ──────────────────────────────────────────────────────────────────
function getStrength(val, t) {
if (!val) return { level: 0, label: "", color: "transparent", cls: "" };
let s = 0;
if (val.length >= 6) s++;
if (val.length >= 10) s++;
if (/[A-Z]/.test(val) || /[0-9]/.test(val)) s++;
if (/[^A-Za-z0-9]/.test(val)) s++;
const labels = ["", t('auth.strengthWeak'), t('auth.strengthMedium'), t('auth.strengthGood'), t('auth.strengthExcellent')];
const colors = ["", "#ff6b6b", "#ffd166", "#2de0c8", "#2de0c8"];
const cls = ["", "sw", "sm", "ss", "ss"];
return { level: s, label: labels[s], color: colors[s], cls: cls[s] };
}
// ─── Shared eye button ────────────────────────────────────────────────────────
function EyeBtn({ show, onToggle}) {
return (
<button type="button" className="eye-btn" onClick={onToggle}>
{show ? "🔒" : "👁"}
</button>
);
}
// ─── Step dots ────────────────────────────────────────────────────────────────
function StepDots({ current, total }) {
return (
<div className="step-dots">
{Array.from({ length: total }).map((_, i) => (
<div
key={i}
className={`dot ${i === current ? "dot-active" : i < current ? "dot-done" : ""}`}
/>
))}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// LOGIN FORM
// ═══════════════════════════════════════════════════════════════════════════════
function LoginForm({ onSwitchToRegister }) {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPass, setShowPass] = useState(false);
const { login, loading, error } = useAuth();
return (
<div className="form-body">
<p className="form-subtitle">{t('auth.welcomeBack')}</p>
<form onSubmit={(e) => { e.preventDefault(); login(email, password); }} noValidate>
<div className="field-wrap">
<input
className="field"
type="email"
placeholder={t('auth.enterEmail')}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="off"
readOnly
onFocus={(e) => e.target.removeAttribute("readOnly")}
/>
</div>
<div className="field-wrap" style={{ position: "relative" }}>
<input
className="field"
type={showPass ? "text" : "password"}
placeholder={t('auth.enterPassword')}
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ paddingRight: "44px" }}
autoComplete="off"
readOnly
onFocus={(e) => e.target.removeAttribute("readOnly")}
/>
<EyeBtn show={showPass} onToggle={() => setShowPass(v => !v)} />
</div>
{error && <p className="err-msg">{error}</p>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? t('auth.loggingIn') : t('auth.login')}
</button>
</form>
<button className="ghost-btn">{t('auth.forgotPassword')}</button>
<p className="switch-row">
{t('auth.noAccount')}{" "}
<button className="link-btn" onClick={onSwitchToRegister}>
{t('auth.register')}
</button>
</p>
</div>
);
}
// ─── step 0+1: name + login + passwords ───────────────────────────────────────
function RegStep01({ data, onChange, onNext }) {
const { t } = useTranslation();
const [show1, setShow1] = useState(false);
const [show2, setShow2] = useState(false);
const [errors, setErrors] = useState({});
const strength = getStrength(data.password, t);
const matched = data.password && data.password === data.password2;
const { checking: checkingLogin, exists: loginExists } = useCheckLogin(data.login);
const validate = () => {
const e = {};
if (!data.name.trim()) e.name = t('auth.validateNameRequired');
if (!data.login.trim()) e.login = t('auth.validateLoginRequired');
else if (data.login.length < 3) e.login = t('auth.loginMinChars');
if (data.password.length < 6) e.password = t('auth.validatePasswordMin');
else if (data.password !== data.password2) e.password2 = t('auth.validatePasswordMatch');
setErrors(e);
return !Object.keys(e).length;
};
return (
<>
<div className="field-wrap">
<label className="field-label">{t('auth.username')}</label>
<input
className={`field ${errors.name ? "field-err" : ""}`}
type="text"
placeholder={t('auth.namePlaceholder')}
value={data.name}
onChange={(e) => {
onChange("name", e.target.value);
setErrors(p => ({...p, name: ""}));
}}
autoComplete="off"
/>
{errors.name && <p className="err-msg">{errors.name}</p>}
</div>
<div className="field-wrap">
<label className="field-label">{t('auth.loginLabel')}</label>
<input
className={`field ${errors.login || loginExists ? "field-err"
: ""}`}
type="text"
placeholder={t('auth.loginPlaceholder')}
value={data.login}
onChange={(e) => {
onChange("login", e.target.value);
setErrors(p => ({...p, login: ""}));
}}
autoComplete="off"
/>
{checkingLogin && <p className="hint-msg">{t('auth.checking')}</p>}
{!checkingLogin && data.login.length >= 3 && (
<p className="err-msg"
style={{color: loginExists ? "#ff6b6b" : "#2de0c8"}}>
{loginExists ? t('auth.loginTaken') : t('auth.loginFree')}
</p>
)}
{errors.login && <p className="err-msg">{errors.login}</p>}
</div>
<div className="field-wrap">
<label className="field-label">{t('auth.passwordLabel')}</label>
<div className="input-wrap">
<input
className={`field ${errors.password ? "field-err" : ""}`}
type={show1 ? "text" : "password"}
placeholder={t('auth.passwordMin')}
value={data.password}
onChange={(e) => {
onChange("password", e.target.value);
setErrors(p => ({...p, password: ""}));
}}
autoComplete="new-password"
/>
<EyeBtn show={show1} onToggle={() => setShow1(v => !v)}/>
</div>
{data.password && (
<>
<div className="strength-bar">
{[0, 1, 2, 3].map(i => (
<div key={i}
className={`seg ${i < strength.level ? strength.cls
: ""}`}/>
))}
</div>
<p className="strength-label" style={{color: strength.color}}>
{strength.label}
</p>
</>
)}
{errors.password && <p className="err-msg">{errors.password}</p>}
</div>
<div className="field-wrap">
<label className="field-label">{t('auth.repeatPassword')}</label>
<div className="input-wrap">
<input
className={`field ${data.password2 ? (matched ? "field-ok"
: "field-err") : ""}`}
type={show2 ? "text" : "password"}
placeholder={t('auth.repeatPasswordPlaceholder')}
value={data.password2}
onChange={(e) => {
onChange("password2", e.target.value);
setErrors(p => ({...p, password2: ""}));
}}
autoComplete="new-password"
/>
<EyeBtn show={show2} onToggle={() => setShow2(v => !v)}/>
</div>
{data.password2 && (
<p className="err-msg"
style={{color: matched ? "#2de0c8" : "#ff6b6b"}}>
{matched ? t('auth.passwordsMatch') : t('auth.passwordsNoMatch')}
</p>
)}
{errors.password2 && <p className="err-msg">{errors.password2}</p>}
</div>
<button className="btn-primary" onClick={() => validate() && onNext()}>
{t('auth.next')}
</button>
</>
);
}
// ─── step 2: confirm ──────────────────────────────────────────────────────────
function RegStep2({data, onSubmit, onBack, loading, error}) {
const { t } = useTranslation();
return (
<>
<div className="confirm-box">
<div className="confirm-row">
<span className="confirm-label">{t('auth.nameLabel')}</span>
<span className="confirm-val">{data.name}</span>
</div>
<div className="confirm-divider"/>
<div className="confirm-row">
<span className="confirm-label">{t('auth.loginLabel')}</span>
<span className="confirm-val accent">@{data.login}</span>
</div>
</div>
{error && <p className="err-msg" style={{
textAlign: "center",
marginBottom: "10px"
}}>{error}</p>}
<button className="btn-primary" onClick={onSubmit} disabled={loading}>
{loading ? t('auth.creating') : t('auth.createAccountBtn')}
</button>
<button className="back-btn" onClick={onBack}>{t('auth.back')}</button>
</>
);
}
// ─── success ──────────────────────────────────────────────────────────────────
function RegSuccess({name, onLogin}) {
const { t } = useTranslation();
return (
<div className="success-wrap">
<img src={likeSmile} alt="success" className="success-icon"/>
<p
className="success-title">{t('auth.welcome', { name })}</p>
<p className="success-sub">{t('auth.accountCreated')}</p>
<button className="btn-primary" style={{marginTop: "20px"}}
onClick={onLogin}>
{t('auth.loginRedirect')}
</button>
</div>
);
}
function RegisterForm({onSwitchToLogin}) {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [form, setForm] = useState(
{name: "", login: "", password: "", password2: ""});
const {register, loading, error, success} = useRegister();
const setField = useCallback((key, val) => setForm(p => ({ ...p, [key]: val })), []);
if (success) {
return (
<div className="form-body">
<RegSuccess name={form.name} onLogin={onSwitchToLogin} />
</div>
);
}
return (
<div className="form-body">
<p className="form-subtitle">{t('auth.createAccount')}</p>
<StepDots current={step} total={2} />
{step === 0 && (
<RegStep01 data={form} onChange={setField} onNext={() => setStep(1)} />
)}
{step === 1 && (
<RegStep2
data={form}
onSubmit={() => register({ name: form.name, login: form.login, password: form.password })}
onBack={() => setStep(0)}
loading={loading}
error={error}
/>
)}
{step === 0 && (
<p className="switch-row">
{t('auth.hasAccount')}{" "}
<button className="link-btn" onClick={onSwitchToLogin}>{t('auth.loginLink')}</button>
</p>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// AUTH PAGE — единственная страница, переключает login ↔ register
// ═══════════════════════════════════════════════════════════════════════════════
export default function Authentication() {
const { t } = useTranslation();
const [mode, setMode] = useState("login"); // "login" | "register"
const [animating, setAnimating] = useState(false);
const switchTo = (next) => {
if (animating) return;
setAnimating(true);
// Небольшая задержка — карточка уходит, потом приходит новая
setTimeout(() => {
setMode(next);
setAnimating(false);
}, 320);
};
return (
<div className="auth-page">
<div className="bg-glow" />
{FLOAT_ICONS.map(({ icon, delay, size, ...pos }, i) => (
<img
key={i} src={icon} alt="" className="fi"
style={{ ...pos, animationDelay: delay, width: size || "7rem", height: size || "7rem", filter: "grayscale(100%)" }}
/>
))}
<div className={`auth-card ${animating ? "card-out" : "card-in"}`}>
<button className="dots-btn">⋮</button>
<div className="logo-wrap">
<MagnifierLogo />
</div>
<h1 className="auth-title">{t('app.name')}</h1>
{mode === "login"
? <LoginForm onSwitchToRegister={() => switchTo("register")} />
: <RegisterForm onSwitchToLogin={() => switchTo("login")} />
}
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
/**
* CityFilter — мультиселект городов с поиском и свободным вводом.
*
* @param {string[]} cities — доступные города (из /posts/cities)
* @param {string[]} value — выбранные города
* @param {(next: string[]) => void} onChange — изменение выбора
*/
export default function CityFilter({ cities, value, onChange }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const inputRef = useRef(null);
// Закрываем дропдаун при клике вне компонента
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open]);
const normalizedQuery = query.trim().toLowerCase();
// Города, совпадающие с введённым текстом (выбранные помечаются галочкой)
const options = useMemo(() => {
const q = normalizedQuery;
return cities.filter(c => c && (!q || c.toLowerCase().includes(q)));
}, [cities, normalizedQuery]);
// Свободный ввод доступен, если введён текст, которого нет ни в списке, ни в выбранных
const canAddCustom = normalizedQuery.length > 0 &&
!value.some(c => c.toLowerCase() === normalizedQuery) &&
!cities.some(c => c.toLowerCase() === normalizedQuery);
const toggle = (city) => {
if (value.includes(city)) {
onChange(value.filter(c => c !== city));
} else {
onChange([...value, city]);
}
setQuery("");
inputRef.current?.focus();
};
const addCustom = () => {
const city = query.trim();
if (city && !value.includes(city)) {
onChange([...value, city]);
}
setQuery("");
inputRef.current?.focus();
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (canAddCustom) {
addCustom();
} else if (options.length) {
toggle(options[0]);
}
} else if (e.key === "Backspace" && query === "" && value.length) {
onChange(value.slice(0, -1));
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div className="f-city-multiselect" ref={rootRef}>
<div className={`f-city-ms-control${open ? " open" : ""}`}
onClick={() => setOpen(v => !v)}>
<svg className="f-city-ms-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<circle cx="12" cy="10" r="3"/>
<path d="M12 21s-7-5.6-7-11a7 7 0 0 1 14 0c0 5.4-7 11-7 11z"/>
</svg>
<div className="f-city-ms-tags">
{value.map(city => (
<span key={city} className="f-city-ms-tag">
{city}
<button
type="button"
className="f-city-ms-tag-remove"
aria-label={t('feed.filterCitiesRemove')}
onClick={(e) => {
e.stopPropagation();
toggle(city);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" width="10"
height="10">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</span>
))}
<input
ref={inputRef}
className="f-city-ms-input"
type="text"
value={query}
placeholder={value.length ? "" : t('feed.filterCitiesPlaceholder')}
onChange={e => {
setQuery(e.target.value);
setOpen(true);
}}
onClick={e => e.stopPropagation()}
onKeyDown={handleKeyDown}
/>
</div>
{value.length > 0 && (
<button
type="button"
className="f-city-ms-clear"
aria-label={t('feed.filterCitiesClear')}
onClick={(e) => {
e.stopPropagation();
onChange([]);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
width="16" height="16">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
)}
<svg className="f-city-ms-chevron" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
{open && (
<div className="f-city-ms-dropdown">
{options.length === 0 && !canAddCustom && (
<div className="f-city-ms-empty">{t('feed.filterCitiesEmpty')}</div>
)}
{canAddCustom && (
<button type="button" className="f-city-ms-option f-city-ms-add"
onClick={() => {
addCustom();
setOpen(false);
}}>
<span className="f-city-ms-check">+</span>
<span className="f-city-ms-label">
{t('feed.filterCitiesAdd', { city: query.trim() })}
</span>
</button>
)}
{options.map(city => {
const selected = value.includes(city);
return (
<button key={city} type="button"
className={`f-city-ms-option${selected ? " selected" : ""}`}
onClick={() => toggle(city)}>
<span className="f-city-ms-check">{selected ? "✓" : ""}</span>
<span className="f-city-ms-label">{city}</span>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,399 +0,0 @@
import {useCallback, useEffect, useState} from "react";
import CloseIcon from "./icons/CloseIcon";
import RulesModal from "./RulesModal";
import { EMPTY_FORM, CATEGORY_KEYS } from "../config/feedConstants";
import { useTranslation } from 'react-i18next';
import { usePostMutation } from "../hooks/usePostMutation";
import { useImageUpload, ACCEPT } from "../hooks/useImageUpload";
import { validatePostForm } from "../utils/validatePostForm";
import { formatPhone } from "../utils/formatPhone";
import { useYandexMap } from "../hooks/useYandexMap";
const MAX_SIZE_MB = 5;
/**
* CreateUpdatePost — модальная форма создания и редактирования объявления.
*
* @param {function} onClose — закрыть модалку без сохранения
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
* @param {object} [initialData] — если передан, форма работает в режиме редактирования
*/
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
// Режим: редактирование (true) или создание нового (false)
const isEdit = !!initialData;
const { t } = useTranslation();
// Поля формы; при редактировании заполняем из initialData
const [form, setForm] = useState(
isEdit
? {
type: (initialData.type || "loss").toLowerCase(),
title: initialData.title || "",
description: initialData.description || "",
category: initialData.category || "",
date: initialData.date || "",
phone: initialData.phone || "",
address: initialData.city || "",
rulesAccepted: true
}
: EMPTY_FORM
);
const [rulesModalOpen, setRulesModalOpen] = useState(false);
const [errors, setErrors] = useState({});
const {
images: uploadedImages,
imgError,
compressing,
fileInputRef,
handleFileChange,
removeImage,
getFilesForUpload,
getExistingUrlsToKeep,
} = useImageUpload(isEdit ? initialData.images : []);
// Единый хук для создания и обновления
const { save, loading: saving, error: apiError } = usePostMutation();
// Состояние для модалки-результата (успех / блокировка контента)
const [resultModal, setResultModal] = useState(null); // null | "success" | "blocked"
// Внутри компонента, рядом с другими хуками
const initialCoords = isEdit && initialData.latitude && initialData.longitude
? { latitude: initialData.latitude, longitude: initialData.longitude }
: null;
// CreateUpdatePost.jsx
const { mapRef, coords, address, addressRef } = useYandexMap(
true,
initialCoords,
isEdit ? (initialData.location || "") : ""
);
// После объявления хука useYandexMap:
useEffect(() => {
if (address) {
setForm(prev => ({ ...prev, address }));
setErrors(prev => ({ ...prev, address: "" }));
}
}, [address]);
// Единый флаг загрузки — блокирует кнопку отправки
const loading = saving || compressing;
/** Обновляет одно поле формы и сбрасывает его ошибку валидации. */
const set = useCallback((key, value) => {
setForm(prev => ({ ...prev, [key]: value }));
setErrors(prev => ({ ...prev, [key]: "" }));
}, []);
/** Собирает FormData и отправляет запрос на создание или обновление. */
const handleSubmit = async () => {
const validationErrors = validatePostForm(form);
if (Object.keys(validationErrors).length) {
setErrors(validationErrors);
return;
}
if (coords && !addressRef.current) {
setErrors(prev => ({ ...prev, map: t('post.mapLoading') }));
return;
}
const data = new FormData();
data.append("type", form.type.toUpperCase()); // "loss" → "LOSS", "found" → "FOUND" для enum на бэкенде
data.append("title", form.title.trim());
data.append("description", form.description.trim());
data.append("category", form.category);
data.append("phone", form.phone.trim());
if (form.date) data.append("date", form.date);
const files = getFilesForUpload();
files.forEach(f => data.append("images", f));
if (isEdit) {
const existing = getExistingUrlsToKeep();
if (existing.length > 0) {
data.append("existingImages", JSON.stringify(existing));
}
}
if (coords) {
data.append("latitude", coords.latitude);
data.append("longitude", coords.longitude);
data.append("address", form.address.trim()); // ← из поля формы
}
// Адрес без координат тоже отправляем:
if (!coords && form.address.trim()) {
data.append("address", form.address.trim());
}
data.append("rulesAccepted", form.rulesAccepted ? "true" : "false");
// save(id, data): id = null → POST (создание), id = string → PUT (обновление)
const result = await save(isEdit ? initialData.id : null, data);
if (!result) {
// Ошибка уже записана в apiError, либо null если неизвестная
return;
}
if (result.blocked) {
setResultModal("blocked");
return;
}
if (result.data) {
setResultModal("success");
}
};
return (
<>
{/* Клик по фону (не по самой модалке) закрывает её */}
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal">
{/* Шапка модалки */}
<div className="f-modal-head">
<span className="f-modal-title">
{isEdit ? t('post.editTitle') : t('post.newTitle')}
</span>
<button className="f-close-btn" onClick={onClose}>
<CloseIcon/>
</button>
</div>
{/* Тип: Потеря / Находка */}
<div className="f-m-group">
<label className="f-m-label">{t('post.typeLabel')}</label>
<div className="f-type-toggle">
<button
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
onClick={() => set("type", "loss")}
>
{t('post.loss')}
</button>
<button
className={`f-type-opt${form.type === "found" ? " found"
: ""}`}
onClick={() => set("type", "found")}
>
{t('post.found')}
</button>
</div>
</div>
{/* Название */}
<div className="f-m-group">
<label className="f-m-label">{t('post.titleLabel')}</label>
<input
className={`f-m-field${errors.title ? " err" : ""}`}
type="text"
placeholder={t('post.titlePlaceholder')}
value={form.title}
onChange={e => set("title", e.target.value)}
/>
{errors.title && <p className="f-err-msg">{errors.title}</p>}
</div>
{/* Описание */}
<div className="f-m-group">
<label className="f-m-label">{t('post.descriptionLabel')}</label>
<textarea
className={`f-m-field${errors.description ? " err" : ""}`}
rows={3}
placeholder={t('post.descriptionPlaceholder')}
value={form.description}
onChange={e => set("description", e.target.value)}
/>
{errors.description && <p
className="f-err-msg">{errors.description}</p>}
</div>
{/* Фотографии: сетка превью + кнопка добавления */}
<div className="f-m-group">
<label className="f-m-label">{t('post.photosLabel')}</label>
{uploadedImages.length > 0 && (
<div className="f-img-grid">
{uploadedImages.map(img => (
<div key={img.id} className="f-img-grid-item">
<img src={img.preview} alt="" className="f-img-grid-preview"/>
<button
className="f-img-remove"
onClick={() => removeImage(img.id)}
type="button"
aria-label={t('post.deletePhoto')}
>
<CloseIcon/>
</button>
</div>
))}
</div>
)}
<label className="f-img-upload-label"
aria-disabled={compressing}>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
multiple
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent)
? "environment" : undefined}
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? (
<span>{t('post.processing')}</span>
) : (
<span>{uploadedImages.length > 0 ? t('post.addMore') : t('post.addPhoto')}</span>
)}
</label>
{imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">{t('post.photoHint', { maxSize: MAX_SIZE_MB })}</p>
</div>
{/* Категория */}
<div className="f-m-row">
<div className="f-m-group">
<label className="f-m-label">{t('post.categoryLabel')}</label>
<select
className={`f-m-field${errors.category ? " err" : ""}`}
value={form.category}
onChange={e => set("category", e.target.value)}
>
<option value="">{t('categories.select')}</option>
{CATEGORY_KEYS.map(k => <option key={k} value={t(k)}>{t(k)}</option>)}
</select>
{errors.category && <p
className="f-err-msg">{errors.category}</p>}
</div>
</div>
{/* Карта */}
<div className="f-m-group">
<label className="f-m-label">{t('post.mapLabel')}</label>
<div style={{
width: "100%",
height: 260,
borderRadius: 8,
overflow: "hidden"
}}>
<div ref={mapRef} style={{width: "100%", height: "100%"}}/>
</div>
{!coords && <p className="f-hint">{t('post.mapHint')}</p>}
{errors.map && <p className="f-err-msg">{errors.map}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">{t('post.addressLabel')}</label>
<input
className={`f-m-field${errors.address ? " err" : ""}`}
type="text"
placeholder={t('post.addressPlaceholder')}
value={form.address}
onChange={e => set("address", e.target.value)}
/>
{errors.address && <p className="f-err-msg">{errors.address}</p>}
</div>
{/* Телефон с автоматической маской */}
<div className="f-m-group">
<label className="f-m-label">{t('post.phoneLabel')}</label>
<input
className={`f-m-field${errors.phone ? " err" : ""}`}
type="tel"
placeholder={t('post.phonePlaceholder')}
value={form.phone}
onChange={e => set("phone", formatPhone(e.target.value))}
/>
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
</div>
{/* Чекбокс согласия с правилами */}
<div className="f-m-group">
<label className="f-rules-checkbox">
<input
type="checkbox"
className="f-rules-input"
checked={form.rulesAccepted}
onChange={e => set("rulesAccepted", e.target.checked)}
/>
<span className="f-rules-checkmark" />
<span className="f-rules-text">
{t('post.rulesCheckboxPrefix')}{' '}
<span
className="f-rules-link"
onClick={e => {
e.preventDefault();
setRulesModalOpen(true);
}}
>
{t('post.rulesLinkText')}
</span>
</span>
</label>
{errors.rulesAccepted && <p className="f-err-msg">{errors.rulesAccepted}</p>}
</div>
{/* Глобальная ошибка API (не связана с конкретным полем) */}
{apiError && <p className="f-err-msg">{apiError}</p>}
<button className="f-m-submit" onClick={handleSubmit}
disabled={loading}>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner"/>}
{isEdit ? t('post.saveChanges') : t('post.publish')}
</div>
</button>
</div>
</div>
{/* ── Модалка с правилами площадки ── */}
{rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} />
)}
{/* ── Модалка-результат после отправки ── */}
{resultModal && (
<div className="f-modal-bg" onClick={e => {
if (e.target === e.currentTarget) {
if (resultModal === "success") {
onSuccess?.();
}
onClose();
}
}}>
<div className="f-modal f-modal-result">
<div className={`f-result-icon ${resultModal === "blocked" ? "blocked" : "success"}`}>
{resultModal === "blocked" ? "✕" : "✓"}
</div>
<h3 className="f-result-title">
{resultModal === "blocked"
? t('post.blockedTitle')
: t('post.successTitle')}
</h3>
<p className="f-result-text">
{resultModal === "blocked"
? t('post.blockedText')
: t('post.successText')}
</p>
<button
className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`}
onClick={() => {
if (resultModal === "success") {
onSuccess?.();
}
onClose();
}}
>
{t('post.resultClose')}
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -4,11 +4,14 @@ import { useTranslation } from 'react-i18next';
export default function DateCalendarPopup({ date, onClose }) {
const { t } = useTranslation();
const months = t('calendar.months', { returnObjects: true });
const days = t('calendar.days', { returnObjects: true });
const dayNames = t('calendar.days', { returnObjects: true });
const ref = useRef(null);
const d = new Date(date);
const [year, setYear] = useState(d.getFullYear());
const [month, setMonth] = useState(d.getMonth());
const selectedDate = new Date(date);
const isValidDate = !Number.isNaN(selectedDate.getTime());
const [year, setYear] = useState(isValidDate ? selectedDate.getFullYear() : new Date().getFullYear());
const [month, setMonth] = useState(isValidDate ? selectedDate.getMonth() : new Date().getMonth());
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
@@ -16,6 +19,24 @@ export default function DateCalendarPopup({ date, onClose }) {
return () => document.removeEventListener("mousedown", handler);
}, [onClose]);
const goToPrevMonth = () => {
if (month === 0) {
setMonth(11);
setYear(y => y - 1);
} else {
setMonth(m => m - 1);
}
};
const goToNextMonth = () => {
if (month === 11) {
setMonth(0);
setYear(y => y + 1);
} else {
setMonth(m => m + 1);
}
};
const firstDay = new Date(year, month, 1).getDay();
const offset = firstDay === 0 ? 6 : firstDay - 1;
const daysInMonth = new Date(year, month + 1, 0).getDate();
@@ -25,27 +46,33 @@ export default function DateCalendarPopup({ date, onClose }) {
for (let i = 1; i <= daysInMonth; i++) cells.push(i);
const isSameDay = (day) =>
d.getFullYear() === year && d.getMonth() === month && d.getDate() === day;
isValidDate &&
selectedDate.getFullYear() === year &&
selectedDate.getMonth() === month &&
selectedDate.getDate() === day;
return (
<div className="f-date-calendar" ref={ref}>
<div className="f-cal-header">
<button onClick={() => setMonth(m => m === 0 ? (setYear(y => y - 1), 11) : m - 1)}>‹</button>
<span>{months[month]} {year}</span>
<button onClick={() => setMonth(m => m === 11 ? (setYear(y => y + 1), 0) : m + 1)}>›</button>
<div className="f-date-calendar" ref={ref}>
<div className="f-cal-header">
<button type="button" onClick={goToPrevMonth}>‹</button>
<span>{months[month]} {year}</span>
<button type="button" onClick={goToNextMonth}>›</button>
</div>
<div className="f-cal-grid">
{dayNames.map(dayName => (
<div key={dayName} className="f-cal-day-name">{dayName}</div>
))}
{cells.map((day, i) =>
day ? (
<div
key={i}
className={`f-cal-day${isSameDay(day) ? " f-cal-selected" : ""}`}
>
{day}
</div>
) : <div key={i} />
)}
</div>
</div>
<div className="f-cal-grid">
{days.map(d => <div key={d} className="f-cal-day-name">{d}</div>)}
{cells.map((day, i) =>
day ? (
<div key={i}
className={`f-cal-day${isSameDay(day) ? " f-cal-selected" : ""}`}
>
{day}
</div>
) : <div key={i} />
)}
</div>
</div>
);
}
}

View File

@@ -1,19 +1,20 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useTranslation } from 'react-i18next';
import CloseIcon from "./icons/CloseIcon";
import Toast from "./Toast";
import { getImageUrl } from "../config/authConstants";
import { useChats } from "../hooks/useChats";
import { useContactSeller } from "../hooks/useContactSeller";
import { useAuthContext } from "../context/AuthContext";
export default function ImageViewer({ images, initialIndex = 0, onClose, postData }) {
const { t } = useTranslation();
const [index, setIndex] = useState(initialIndex);
const touchStart = useRef(null);
const swiped = useRef(false);
const navigate = useNavigate();
const { createOrGetChat } = useChats();
const { isAuthenticated } = useAuthContext();
const { contactSeller, error: contactError, clearError } = useContactSeller(postData);
const urls = images.map(u => getImageUrl(u));
const urls = useMemo(() => images.map(u => getImageUrl(u)), [images]);
const next = useCallback(() => {
setIndex(i => (i + 1) % urls.length);
@@ -59,63 +60,63 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
touchStart.current = null;
}, [prev, next]);
const handleContact = useCallback(async () => {
if (!postData) return;
try {
const chat = await createOrGetChat(postData.userId, postData.id, {
title: postData.title,
images: postData.images,
emoji: postData.emoji,
});
onClose();
navigate(`/chats/${chat.id}`);
} catch (err) {
console.error(err);
}
}, [postData, createOrGetChat, navigate, onClose]);
const handleContact = useCallback(() => {
// Закрываем вьюер перед переходом в чат
contactSeller(onClose);
}, [contactSeller, onClose]);
const showContact = isAuthenticated && postData && !postData.isOwner && postData.status !== 'CLOSED';
if (!urls.length) return null;
return (
<div
className="f-viewer-bg"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={e => e.target === e.currentTarget && !swiped.current && onClose()}
>
<button className="f-viewer-close" onClick={onClose}>
<CloseIcon />
</button>
<div
className="f-viewer-bg"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={e => e.target === e.currentTarget && !swiped.current && onClose()}
>
<button type="button" className="f-viewer-close" onClick={onClose} aria-label={t('common.close')}>
<CloseIcon />
</button>
<div className="f-viewer">
<div className="f-viewer-img-wrap" onClick={handleImgClick}>
<img
src={urls[index]}
alt=""
className="f-viewer-img"
/>
<div className="f-viewer">
<div className="f-viewer-img-wrap" onClick={handleImgClick}>
<img
src={urls[index]}
alt=""
className="f-viewer-img"
/>
</div>
{showContact && (
<button type="button" className="f-viewer-contact" onClick={handleContact}>
{t('postCard.write')}
</button>
)}
{urls.length > 1 && (
<div className="f-viewer-dots">
{urls.map((_, i) => (
<button
type="button"
key={i}
className={`f-viewer-dot${i === index ? " active" : ""}`}
onClick={() => setIndex(i)}
aria-label={t('viewer.goToImage', { number: i + 1 })}
/>
))}
</div>
)}
</div>
{showContact && (
<button className="f-viewer-contact" onClick={handleContact}>
Написать
</button>
)}
{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>
{contactError && (
<Toast
message={contactError}
type="error"
onDismiss={clearError}
/>
)}
</div>
</div>
);
}
}

View File

@@ -1,84 +0,0 @@
import keysIcon from '../assets/passwords_keys_6032.png';
export default function MagnifierLogo() {
return (
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg" className="logoSvg">
<defs>
<radialGradient id="glassGrad" cx="38%" cy="35%" r="60%">
<stop offset="0%" stopColor="#7ffff0"/>
<stop offset="45%" stopColor="#2de0c8"/>
<stop offset="100%" stopColor="#0a7a68"/>
</radialGradient>
<linearGradient id="rimGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#e8eef5"/>
<stop offset="40%" stopColor="#b0bec5"/>
<stop offset="100%" stopColor="#607d8b"/>
</linearGradient>
<linearGradient id="handleGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#4a4a4a"/>
<stop offset="25%" stopColor="#2a2a2a"/>
<stop offset="50%" stopColor="#111111"/>
<stop offset="75%" stopColor="#1e1e1e"/>
<stop offset="100%" stopColor="#000000"/>
</linearGradient>
<radialGradient id="innerGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.25"/>
<stop offset="100%" stopColor="#ffffff" stopOpacity="0"/>
</radialGradient>
<linearGradient id="shimmer2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.7"/>
<stop offset="100%" stopColor="#ffffff" stopOpacity="0"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="3" floodColor="#000"
floodOpacity="0.5"/>
</filter>
<clipPath id="lensClip">
<circle cx="35" cy="35" r="23"/>
</clipPath>
</defs>
{/* Ручка */}
<g filter="url(#shadow)">
<rect x="60" y="56" width="11" height="28" rx="5.5"
fill="url(#handleGrad)" transform="rotate(-45 62.5 71)"/>
<rect x="61" y="56" width="3" height="22" rx="1.5"
fill="white" opacity="0.12" transform="rotate(-45 62.5 71)" />
<g transform="rotate(-45 62.5 71)" opacity="0.08">
<line x1="60" y1="62" x2="71" y2="62" stroke="white" strokeWidth="1"/>
<line x1="60" y1="66" x2="71" y2="66" stroke="white" strokeWidth="1"/>
<line x1="60" y1="70" x2="71" y2="70" stroke="white" strokeWidth="1"/>
<line x1="60" y1="74" x2="71" y2="74" stroke="white" strokeWidth="1"/>
</g>
</g>
{/* Металлический обод */}
<circle cx="35" cy="35" r="28" fill="url(#rimGrad)" filter="url(#shadow)" />
<circle cx="35" cy="35" r="26" fill="none" stroke="#ecf0f1" strokeWidth="1" opacity="0.6" />
<circle cx="35" cy="35" r="28" fill="none" stroke="#455a64" strokeWidth="1.5" opacity="0.5" />
{/* Стеклянная линза */}
<circle cx="35" cy="35" r="23" fill="url(#glassGrad)" />
<circle cx="35" cy="35" r="23" fill="url(#innerGlow)" />
{/* Иконка внутри линзы */}
<image
href={keysIcon}
x="20"
y="20"
width="29"
height="29"
clipPath="url(#lensClip)"
opacity="0.6"
/>
{/* Блики на стекле */}
<ellipse cx="27" cy="25" rx="9" ry="6"
fill="url(#shimmer2)" opacity="0.55" transform="rotate(-20 27 25)" />
<ellipse cx="23" cy="29" rx="3" ry="2"
fill="white" opacity="0.3" transform="rotate(-20 23 29)" />
<path d="M 14 22 Q 20 10 35 8" stroke="white" strokeWidth="1.5"
strokeLinecap="round" fill="none" opacity="0.45" />
</svg>
);
}

View File

@@ -1,10 +1,14 @@
import { useState } from "react";
import LogoIcon from "./icons/LogoIcon";
import {NAV_BOTTOM, NAV_ITEMS} from "../config/feedConstants";
import {NavIcons} from "./icons/NavIcons";
import GlobeIcon from "./icons/GlobeIcon";
import SunIcon from "./icons/SunIcon";
import MoonIcon from "./icons/MoonIcon";
import { NAV_BOTTOM, NAV_ITEMS } from "../config/feedConstants";
import { NavIcons } from "./icons/NavIcons";
import { useNavigate } from "react-router-dom";
import { useTheme } from "../context/ThemeContext";
import { useUnread } from "../context/UnreadContext";
import { useLanguageToggle } from "../hooks/useLanguageToggle";
import { useTranslation } from 'react-i18next';
import SupportModal from "./SupportModal";
@@ -21,15 +25,10 @@ export default function Sidebar({
const navigate = useNavigate();
const { theme, toggle: toggleTheme } = useTheme();
const { totalUnread } = useUnread();
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { language, toggleLanguage } = useLanguageToggle();
const [supportOpen, setSupportOpen] = useState(false);
const toggleLang = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(next);
localStorage.setItem('i18nextLng', next);
};
return (
<>
<div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} />
@@ -57,16 +56,17 @@ export default function Sidebar({
onClose();
};
return (
<div
<button
type="button"
key={itemKey}
className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
onClick={handleClick}
>
<Ic /> {t(labelKey)}
{itemKey === "chat" && totalUnread > 0 && (
<span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span>
<span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span>
)}
</div>
</button>
);
})}
</nav>
@@ -88,42 +88,31 @@ export default function Sidebar({
onClose();
};
return (
<div
<button
type="button"
key={itemKey}
className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
onClick={handleBottomClick}
>
<Ic /> {t(labelKey)}
</div>
</button>
);
})}
<div className="f-sb-item" onClick={() => setSupportOpen(true)}>
<button type="button" className="f-sb-item" onClick={() => setSupportOpen(true)}>
<NavIcons.help /> {t('nav.support')}
</div>
</button>
<div className="f-sb-divider" />
<div className="f-sb-item f-sb-theme-toggle" onClick={toggleTheme}>
{theme === "dark" ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
<button type="button" className="f-sb-item f-sb-theme-toggle" onClick={toggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
{theme === "dark" ? t('sidebar.lightTheme') : t('sidebar.darkTheme')}
</div>
<div className="f-sb-item f-sb-lang-toggle" onClick={toggleLang}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{i18n.language === 'ru' ? 'English' : 'Русский'}
</div>
</button>
<button type="button" className="f-sb-item f-sb-lang-toggle" onClick={toggleLanguage}>
<GlobeIcon />
{language === 'ru' ? 'English' : 'Русский'}
</button>
</div>
</div>
{supportOpen && <SupportModal onClose={() => setSupportOpen(false)} />}
</>
);
}
}

View File

@@ -1,26 +1,27 @@
import { useTranslation } from 'react-i18next';
import CloseIcon from "./icons/CloseIcon";
import { SUPPORT_EMAIL } from "../config/authConstants";
export default function SupportModal({ onClose }) {
const { t } = useTranslation();
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal" style={{ width: 420 }}>
<div className="f-modal-head">
<span className="f-modal-title">{t('support.title')}</span>
<button className="f-close-btn" onClick={onClose}>
<CloseIcon />
</button>
</div>
<div className="f-modal-body f-support-body">
<p className="f-support-text">{t('support.text')}</p>
<p className="f-support-email">
<a href="mailto:1989krylov@gmail.com">1989krylov@gmail.com</a>
</p>
<p className="f-support-footer">{t('support.footer')}</p>
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('support.title')}</span>
<button className="f-close-btn" type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
<div className="f-modal-body f-support-body">
<p className="f-support-text">{t('support.text')}</p>
<p className="f-support-email">
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
</p>
<p className="f-support-footer">{t('support.footer')}</p>
</div>
</div>
</div>
</div>
);
}
}

28
src/components/Toast.jsx Normal file
View File

@@ -0,0 +1,28 @@
// src/components/Toast.jsx
import { useEffect } from "react";
/**
* Toast — всплывающее сообщение об ошибке/успехе, автоматически скрывается.
*
* @param {string} message — текст сообщения
* @param {"error"|"success"} [type="error"]
* @param {function} onDismiss — вызывается по истечении таймера или по клику на крестик
* @param {number} [duration=4000] — время жизни в мс
*/
export default function Toast({ message, type = "error", onDismiss, duration = 4000 }) {
useEffect(() => {
const timer = setTimeout(onDismiss, duration);
return () => clearTimeout(timer);
}, [onDismiss, duration]);
if (!message) return null;
return (
<div className={`f-toast f-toast-${type}`} role="alert">
<span>{message}</span>
<button type="button" className="f-toast-close" onClick={onDismiss} aria-label="Close">
×
</button>
</div>
);
}

View File

@@ -0,0 +1,20 @@
export default function ChatHeader({ title, onBack, avatar, statusLabel, statusConnected }) {
return (
<div className="chat-header">
<button className="chat-back-btn" onClick={onBack}>
←
</button>
{avatar !== undefined && (
<div className="chat-header-avatar">{avatar}</div>
)}
<div className="chat-header-title">{title}</div>
{statusLabel !== undefined ? (
<div className={`chat-status${statusConnected ? " connected" : ""}`}>
{statusLabel}
</div>
) : (
<div />
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { useRef } from "react";
import Picker from "@emoji-mart/react";
import emojiData from "@emoji-mart/data";
import { useClickOutside } from "../../hooks/useClickOutside";
export default function ChatInputBar({
input,
onInputChange,
onSend,
onEmojiSelect,
showEmoji,
onToggleEmoji,
onCloseEmoji,
connected,
placeholder,
}) {
const emojiPickerRef = useRef(null);
useClickOutside(emojiPickerRef, onCloseEmoji, showEmoji);
return (
<div className="chat-input-area">
{showEmoji && (
<div className="chat-emoji-picker" ref={emojiPickerRef}>
<Picker
data={emojiData}
onEmojiSelect={onEmojiSelect}
theme="dark"
previewPosition="none"
skinTonePosition="none"
searchPosition="sticky"
maxSearchResults={30}
perLine={8}
/>
</div>
)}
<form onSubmit={onSend} className="chat-input-row">
<input
value={input}
onChange={(e) => onInputChange(e.target.value)}
placeholder={placeholder}
className="chat-input"
/>
<button
type="button"
className={`chat-emoji-btn${showEmoji ? " active" : ""}`}
onClick={onToggleEmoji}
>
😊
</button>
<button type="submit" className="chat-send-btn" disabled={!connected}>
➤
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { getChatDisplayInfo } from "../../utils/chatDisplay";
import TrashIcon from "../icons/TrashIcon";
export default function ChatListItem({ chat, post, unreadMap, t, onOpen, onDelete }) {
const { title, imgSrc, emoji, unreadCount } = getChatDisplayInfo(chat, post, unreadMap, t);
return (
<div
className={`chat-list-item${unreadCount > 0 ? " unread" : ""}`}
onClick={() => onOpen(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 || t('chat.noMessages')}
</div>
</div>
{unreadCount > 0 && (
<div className="chat-list-unread-badge">{unreadCount > 99 ? "99+" : unreadCount}</div>
)}
<button
className="chat-list-delete-btn"
title={t('chat.deleteChat')}
onClick={(e) => {
e.stopPropagation();
onDelete(chat.id);
}}
>
<TrashIcon />
</button>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function MessageBubble({ message, isMine }) {
return (
<div className={`chat-bubble${isMine ? " mine" : ""}`}>
{message.content}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import PostOnFeed from "../../pages/PostOnFeed";
export default function FeedContent({
loading,
cityLoading,
citySearchActive,
error,
filteredAds,
activeNav,
liked,
onLike,
onEdit,
onDelete,
onMarkFound,
onImageClick,
onAuthRequired,
t,
}) {
if (loading || (citySearchActive && cityLoading)) {
return (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
{t('feed.loading')}
</div>
</div>
);
}
if (error) {
return (
<div className="f-center">
<div className="f-center-icon">⚠️</div>
<div className="f-center-title">{t('feed.loadError')}</div>
<div className="f-center-sub">{error}</div>
</div>
);
}
if (!filteredAds.length) {
const emptyMsg = activeNav === "myads"
? { title: t('feed.emptyMyAdsTitle'), sub: t('feed.emptyMyAdsSub') }
: citySearchActive
? { title: t('feed.emptyCityTitle'), sub: t('feed.emptyCitySub') }
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
return (
<div className="f-center">
<div className="f-center-icon">🔍</div>
<div className="f-center-title">{emptyMsg.title}</div>
<div className="f-center-sub" dangerouslySetInnerHTML={{ __html: emptyMsg.sub }} />
</div>
);
}
return filteredAds.map(ad => (
<PostOnFeed
key={ad.id}
ad={ad}
liked={liked.has(ad.id)}
onLike={onLike}
onEdit={activeNav === "myads" ? onEdit : undefined}
onDelete={activeNav === "myads" ? onDelete : undefined}
onMarkFound={activeNav === "myads" ? onMarkFound : undefined}
isOwner={activeNav === "myads"}
onImageClick={onImageClick}
onAuthRequired={onAuthRequired}
/>
));
}

View File

@@ -0,0 +1,26 @@
export default function FeedSearchBar({ value, onChange, t }) {
return (
<div className="f-search-bar">
<svg className="f-search-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
className="f-search-input"
type="text"
placeholder={t('feed.searchPlaceholder')}
value={value}
onChange={e => onChange(e.target.value)}
/>
{value && (
<button className="f-search-clear" onClick={() => onChange("")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import AppTopBar from "../layout/AppTopBar";
export default function FeedTopBar({
onMenuClick,
onCreatePost,
isAuthenticated,
profileData,
onProfileClick,
onLoginClick,
t,
}) {
return (
<AppTopBar
onMenuClick={onMenuClick}
isAuthenticated={isAuthenticated}
profileData={profileData}
onProfileClick={onProfileClick}
onLoginClick={onLoginClick}
loginLabel={t('feed.login')}
centerContent={
<button className="f-post-btn" onClick={onCreatePost}>
<span style={{ fontSize: "1.15rem", fontWeight: 900, lineHeight: 1 }}>+</span>
{t('feed.createPost')}
</button>
}
/>
);
}

View File

@@ -0,0 +1,21 @@
export default function TypeFilterBar({ value, onChange, t }) {
const options = [
{ key: "loss", label: t('feed.filterLoss') },
{ key: null, label: t('feed.filterAll') },
{ key: "found", label: t('feed.filterFound') },
];
return (
<div className="f-filter-bar">
{options.map(opt => (
<button
key={opt.key ?? "all"}
className={`f-filter-btn${value === opt.key ? " active" : ""}`}
onClick={() => onChange(opt.key)}
>
{opt.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,8 @@
export default function GlobeIcon(props) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
}

View File

@@ -0,0 +1,7 @@
export default function MoonIcon(props) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
}

View File

@@ -30,12 +30,6 @@ export const NavIcons = {
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
</svg>
),
settings: () => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
star: () => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<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" />

View File

@@ -0,0 +1,8 @@
export default function SunIcon(props) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
);
}

View File

@@ -1,51 +1,36 @@
import { useState } from "react";
// src/components/landing/Hero.jsx
import Navbar from "./Navbar";
import RadarBeacon from "./RadarBeacon";
import { useTranslation } from 'react-i18next';
export default function Hero({ onCtaClick, onLoginClick }) {
const { t } = useTranslation();
const [hover, setHover] = useState(false);
return (
<section className="nk-hero">
<div className="nk-grid" />
<div className="nk-glow" />
<section className="nk-hero">
<div className="nk-grid" />
<div className="nk-glow" />
<RadarBeacon />
<Navbar onLoginClick={onLoginClick || onCtaClick} />
<RadarBeacon />
<Navbar onLoginClick={onLoginClick || onCtaClick} />
<div className="nk-hero-text">
<span className="nk-eyebrow">{t('landing.eyebrow')}</span>
<h1 className="nk-h1">
{t('landing.heroTitle')}
<br />
<span className="nk-accent">{t('landing.heroAccent')}</span>
</h1>
<p className="nk-lead">
{t('landing.heroLead')}
</p>
<div className="nk-cta-row">
<button
className="nk-cta-primary"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onClick={onCtaClick}
>
{t('landing.startSearch')}
<span
style={{
marginLeft: 8,
display: "inline-block",
transform: hover ? "translateX(3px)" : "none",
transition: "transform .2s",
}}
>
→
</span>
</button>
<div className="nk-hero-text">
<span className="nk-eyebrow">{t('landing.eyebrow')}</span>
<h1 className="nk-h1">
{t('landing.heroTitle')}
<br />
<span className="nk-accent">{t('landing.heroAccent')}</span>
</h1>
<p className="nk-lead">
{t('landing.heroLead')}
</p>
<div className="nk-cta-row">
<button className="nk-cta-primary" onClick={onCtaClick}>
{t('landing.startSearch')}
<span className="nk-arrow">→</span>
</button>
</div>
</div>
</div>
</section>
</section>
);
}
}

View File

@@ -1,29 +1,25 @@
import { useTranslation } from 'react-i18next';
import GlobeIcon from "../icons/GlobeIcon";
import { useLanguageToggle } from "../../hooks/useLanguageToggle";
export default function Navbar({ onLoginClick }) {
const { t, i18n } = useTranslation();
const toggleLang = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(next);
localStorage.setItem('i18nextLng', next);
};
const { t } = useTranslation();
const { language, toggleLanguage } = useLanguageToggle();
return (
<nav className="nk-nav">
<span className="nk-logo">{t('app.name')}</span>
<div className="nk-nav-right">
<button className="nk-lang-btn" onClick={toggleLang}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{i18n.language === 'ru' ? 'EN' : 'RU'}
</button>
<button className="nk-nav-btn" onClick={onLoginClick}>
{t('landing.login')}
</button>
</div>
</nav>
<nav className="nk-nav">
<span className="nk-logo">{t('app.name')}</span>
<div className="nk-nav-right">
<button className="nk-lang-btn" onClick={toggleLanguage}>
<GlobeIcon />
{language === 'ru' ? 'EN' : 'RU'}
</button>
<button className="nk-nav-btn" onClick={onLoginClick}>
{t('landing.login')}
</button>
</div>
</nav>
);
}
}

View File

@@ -0,0 +1,37 @@
import MenuIcon from "../icons/MenuIcon";
import ProfileAvatar from "../profile/ProfileAvatar";
/**
* Общая верхняя панель приложения: бургер-меню слева,
* произвольный контент по центру, профиль/логин справа.
*/
export default function AppTopBar({
onMenuClick,
centerContent,
isAuthenticated,
profileData,
onProfileClick,
onLoginClick,
loginLabel,
}) {
return (
<div className="f-topbar">
<button className="f-menu-btn" onClick={onMenuClick}>
<MenuIcon />
</button>
<div className="f-topbar-center">{centerContent}</div>
{isAuthenticated ? (
<div className="f-topbar-profile" onClick={onProfileClick}>
<ProfileAvatar
avatar={profileData.avatar}
name={`${profileData.firstName || ""} ${profileData.lastName || ""}`}
/>
</div>
) : (
<button className="f-login-btn" onClick={onLoginClick}>
{loginLabel}
</button>
)}
</div>
);
}

View File

@@ -1,11 +0,0 @@
import ProfileAvatar from "./ProfileAvatar";
import ProfileInfo from "./ProfileInfo";
export default function Profile({ data = {}, onEdit }) {
return (
<div className="f-profile" onClick={onEdit}>
<ProfileAvatar avatar={data.avatar} name={`${data.firstName || ""} ${data.lastName || ""}`} />
<ProfileInfo firstName={data.firstName} email={data.email} />
</div>
);
}

View File

@@ -1,18 +1,18 @@
import React from "react";
import { useTranslation } from 'react-i18next';
import CloseIcon from "../icons/CloseIcon";
import { MAX_SIZE_MB as MAX_PHOTO_SIZE_MB } from "../../hooks/useImageUpload";
// ─── Input field component ───────────────────────────────────────────────────
function InputField({ label, error, ...props }) {
return (
<div className="f-m-group">
<label className="f-m-label">{label}</label>
<input
className={`f-m-field${error ? " err" : ""}`}
{...props}
/>
{error && <p className="f-err-msg">{error}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">{label}</label>
<input
className={`f-m-field${error ? " err" : ""}`}
{...props}
/>
{error && <p className="f-err-msg">{error}</p>}
</div>
);
}
@@ -26,39 +26,38 @@ function ImageUpload({
onRemove,
}) {
const { t } = useTranslation();
const MAX_SIZE_MB = 5;
return (
<div className="f-m-group">
<label className="f-m-label">{t('profile.photoLabel')}</label>
{displayImage ? (
<div className="f-img-preview-wrap">
<img src={displayImage} alt="profile" className="f-img-preview" />
<button
className="f-img-remove"
onClick={onRemove}
type="button"
aria-label={t('profile.deletePhoto')}
>
<CloseIcon />
</button>
</div>
) : (
<label className="f-img-upload-label" aria-disabled={compressing}>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={onFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? <span>{t('profile.processing')}</span> : <span>{t('profile.addPhoto')}</span>}
</label>
)}
{imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">{t('profile.photoHint', { maxSize: MAX_SIZE_MB })}</p>
</div>
<div className="f-m-group">
<label className="f-m-label">{t('profile.photoLabel')}</label>
{displayImage ? (
<div className="f-img-preview-wrap">
<img src={displayImage} alt="profile" className="f-img-preview" />
<button
className="f-img-remove"
onClick={onRemove}
type="button"
aria-label={t('profile.deletePhoto')}
>
<CloseIcon />
</button>
</div>
) : (
<label className="f-img-upload-label">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={onFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? <span>{t('profile.processing')}</span> : <span>{t('profile.addPhoto')}</span>}
</label>
)}
{imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">{t('profile.photoHint', { maxSize: MAX_PHOTO_SIZE_MB })}</p>
</div>
);
}
@@ -66,13 +65,13 @@ function ImageUpload({
function LoginField({ form, errors, onChange }) {
const { t } = useTranslation();
return (
<InputField
label={t('profile.loginLabel')}
placeholder="username"
value={form.login}
error={errors.login}
onChange={(e) => onChange("login", e.target.value)}
/>
<InputField
label={t('profile.loginLabel')}
placeholder="username"
value={form.login}
error={errors.login}
onChange={(e) => onChange("login", e.target.value)}
/>
);
}
@@ -80,22 +79,22 @@ function LoginField({ form, errors, onChange }) {
function NameFieldsRow({ form, errors, onChange }) {
const { t } = useTranslation();
return (
<div className="f-m-row">
<InputField
label={t('profile.nameLabel')}
placeholder={t('profile.namePlaceholder')}
value={form.firstName}
error={errors.firstName}
onChange={(e) => onChange("firstName", e.target.value)}
/>
<InputField
label={t('profile.surnameLabel')}
placeholder={t('profile.surnamePlaceholder')}
value={form.lastName}
error={errors.lastName}
onChange={(e) => onChange("lastName", e.target.value)}
/>
</div>
<div className="f-m-row">
<InputField
label={t('profile.nameLabel')}
placeholder={t('profile.namePlaceholder')}
value={form.firstName}
error={errors.firstName}
onChange={(e) => onChange("firstName", e.target.value)}
/>
<InputField
label={t('profile.surnameLabel')}
placeholder={t('profile.surnamePlaceholder')}
value={form.lastName}
error={errors.lastName}
onChange={(e) => onChange("lastName", e.target.value)}
/>
</div>
);
}
@@ -103,24 +102,24 @@ function NameFieldsRow({ form, errors, onChange }) {
function ContactFields({ form, errors, onChange, onPhoneChange }) {
const { t } = useTranslation();
return (
<>
<InputField
label={t('profile.phoneLabel')}
type="tel"
placeholder="+7 (___) ___-__-__"
value={form.phone}
error={errors.phone}
onChange={(e) => onPhoneChange(e.target.value)}
/>
<InputField
label={t('profile.emailLabel')}
type="email"
placeholder="example@email.com"
value={form.email}
error={errors.email}
onChange={(e) => onChange("email", e.target.value)}
/>
</>
<>
<InputField
label={t('profile.phoneLabel')}
type="tel"
placeholder="+7 (___) ___-__-__"
value={form.phone}
error={errors.phone}
onChange={(e) => onPhoneChange(e.target.value)}
/>
<InputField
label={t('profile.emailLabel')}
type="email"
placeholder="example@email.com"
value={form.email}
error={errors.email}
onChange={(e) => onChange("email", e.target.value)}
/>
</>
);
}
@@ -128,16 +127,17 @@ function ContactFields({ form, errors, onChange, onPhoneChange }) {
function SubmitButton({ loading, onSubmit }) {
const { t } = useTranslation();
return (
<button
className="f-m-submit"
onClick={onSubmit}
disabled={loading}
>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner" />}
{t('profile.save')}
</div>
</button>
<button
className="f-m-submit"
type="submit"
onClick={onSubmit}
disabled={loading}
>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner" />}
{t('profile.save')}
</div>
</button>
);
}
@@ -145,13 +145,13 @@ function SubmitButton({ loading, onSubmit }) {
function ModalHeader({ onClose }) {
const { t } = useTranslation();
return (
<div className="f-modal-head">
<span className="f-modal-title">{t('profile.editTitle')}</span>
<button className="f-close-btn" onClick={onClose}>
<CloseIcon />
</button>
</div>
<div className="f-modal-head">
<span className="f-modal-title">{t('profile.editTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
export { InputField, ImageUpload, LoginField, NameFieldsRow, ContactFields, SubmitButton, ModalHeader };
export { InputField, ImageUpload, LoginField, NameFieldsRow, ContactFields, SubmitButton, ModalHeader };

View File

@@ -1,14 +0,0 @@
import { useTranslation } from 'react-i18next';
// ─── Profile info component ─────────────────────────────────────────────────
export default function ProfileInfo({ firstName, email }) {
const { t } = useTranslation();
const displayName = firstName || t('profile.default');
return (
<div className="f-profile-info">
<div className="f-profile-name">{displayName}</div>
{email && <div className="f-profile-email">{email}</div>}
</div>
);
}

View File

@@ -36,26 +36,21 @@ export default function ProfileModal({ onClose, initialData = {} }) {
const currentImage = images[0];
const displayImage = currentImage?.preview ?? null;
const imageFile = currentImage?.file ?? null;
// ─── Field change handler ───────────────────────────────────────────────
const handleFieldChange = useCallback((key, value) => {
setForm(prev => ({ ...prev, [key]: value }));
setErrors(prev => ({ ...prev, [key]: "" }));
}, []);
// ─── Remove image handler (wraps removeImage with the only image id) ─────
const handleRemoveImage = useCallback(() => {
if (currentImage?.id) removeImage(currentImage.id);
}, [currentImage, removeImage]);
// ─── Phone field change with formatting ──────────────────────────────────
const handlePhoneChange = useCallback((value) => {
const formatted = formatPhone(value);
handleFieldChange("phone", formatted);
}, [handleFieldChange]);
// ─── Submit handler ─────────────────────────────────────────────────────
const handleSubmit = async () => {
const validationErrors = validateProfileForm(form);
if (Object.keys(validationErrors).length) {
@@ -77,57 +72,62 @@ export default function ProfileModal({ onClose, initialData = {} }) {
const updatedUser = await updateProfile(user?.id, profileData);
if (updatedUser) {
// Обновляем данные пользователя в контексте и localStorage
updateUser(updatedUser);
onClose();
}
};
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal">
<ModalHeader onClose={onClose} />
<ImageUpload
displayImage={displayImage}
imgError={imgError}
compressing={compressing}
fileInputRef={fileInputRef}
onFileChange={handleFileChange}
onRemove={handleRemoveImage}
/>
<LoginField
form={form}
errors={errors}
onChange={handleFieldChange}
/>
<NameFieldsRow
form={form}
errors={errors}
onChange={handleFieldChange}
/>
<ContactFields
form={form}
errors={errors}
onChange={handleFieldChange}
onPhoneChange={handlePhoneChange}
/>
{saveError && <p className="f-err-msg" style={{ marginBottom: 10 }}>{saveError}</p>}
<SubmitButton loading={compressing || saving} onSubmit={handleSubmit} />
<button
type="button"
className="f-modal-logout"
onClick={() => { logout(); onClose(); }}
<div
className="f-modal-bg"
onClick={e => e.target === e.currentTarget && !saving && onClose()}
>
<form
className="f-modal"
onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}
>
{t('profile.logout')}
</button>
<ModalHeader onClose={onClose} />
<ImageUpload
displayImage={displayImage}
imgError={imgError}
compressing={compressing}
fileInputRef={fileInputRef}
onFileChange={handleFileChange}
onRemove={handleRemoveImage}
/>
<LoginField
form={form}
errors={errors}
onChange={handleFieldChange}
/>
<NameFieldsRow
form={form}
errors={errors}
onChange={handleFieldChange}
/>
<ContactFields
form={form}
errors={errors}
onChange={handleFieldChange}
onPhoneChange={handlePhoneChange}
/>
{saveError && <p className="f-err-msg" style={{ marginBottom: 10 }}>{saveError}</p>}
<SubmitButton loading={compressing || saving} onSubmit={handleSubmit} />
<button
type="button"
className="f-modal-logout"
onClick={() => { logout(); onClose(); }}
>
{t('profile.logout')}
</button>
</form>
</div>
</div>
);
}
}

View File

@@ -1,5 +1,2 @@
export { default as Profile } from "./Profile";
export { default as ProfileModal } from "./ProfileModal";
export { default as ProfileAvatar } from "./ProfileAvatar";
export { default as ProfileInfo } from "./ProfileInfo";
export { validateProfileForm, initializeFormState } from "./profileValidation";

View File

@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import StarRating from "./StarRating";
export default function RatingSummary({ avgRating, totalCount, distribution }) {
const { t } = useTranslation();
return (
<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">{t('reviews.noReviews')}</span>
)}
</div>
<span className="rv-summary-total">
{t('reviews.title')} ({totalCount})
</span>
{totalCount > 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>
);
}

View File

@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import StarRating from "./StarRating";
export default function ReviewCard({ review }) {
const { t } = useTranslation();
const date = review.createdAt
? new Date(review.createdAt).toLocaleDateString("ru-RU", {
day: "numeric", month: "long", year: "numeric",
})
: "";
return (
<div className={`rv-card${review.pending ? " rv-card-pending" : ""}`}>
<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 || t('reviews.anonymous')}</span>
{date && <span className="rv-card-date">{date}</span>}
{review.pending && (
<span className="rv-card-pending-badge">{t('reviews.notSaved')}</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>
);
}

View File

@@ -0,0 +1,47 @@
import { useState } from "react";
import { useTranslation } from 'react-i18next';
import StarRating from "./StarRating";
export default function ReviewForm({ onSubmit, submitting }) {
const { t } = useTranslation();
const [rating, setRating] = useState(0);
const [text, setText] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (rating === 0) {
setError(t('reviews.ratePrompt'));
return;
}
setError("");
onSubmit({ rating, text: text.trim() });
setRating(0);
setText("");
};
return (
<form className="rv-form" onSubmit={handleSubmit}>
<h3 className="rv-form-title">{t('reviews.leaveReview')}</h3>
<div className="rv-form-rating">
<span className="rv-form-label">{t('reviews.rating')}</span>
<StarRating value={rating} onChange={setRating} size={32} />
</div>
{error && <div className="rv-form-error">{error}</div>}
<textarea
className="rv-form-textarea"
placeholder={t('reviews.reviewPlaceholder')}
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 ? t('reviews.sending') : t('reviews.send')}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,32 @@
import { useState } from "react";
export default 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>
);
}

View File

@@ -1,14 +1,3 @@
import bagIcon from '../assets/bag.png';
import catIcon from '../assets/cat.png';
import dogIcon from '../assets/dog.png';
import phoneIcon from '../assets/phone.png';
import walletIcon from '../assets/wallet.png';
import parcelIcon from '../assets/parcel.png';
import cardIcon from '../assets/card.png';
import headphonesIcon from '../assets/headphones.png';
import watchIcon from '../assets/watch.png';
import glassesIcon from '../assets/glasses.png';
export const API_URL = process.env.REACT_APP_API_URL || "http://localhost:8080";
export function getImageUrl(filename) {
@@ -18,16 +7,4 @@ export function getImageUrl(filename) {
return `${API_URL}/api/v1/files/${filename}`;
}
export const FLOAT_ICONS = [
{ icon: bagIcon, top: "5%", left: "5%", delay: "0s" },
{ icon: catIcon, top: "5%", right: "5%", delay: "1s" },
{ icon: dogIcon, top: "22%", left: "25%", delay: "0.4s" },
{ icon: phoneIcon, top: "25%", right: "17%", delay: "1.4s" },
{ icon: walletIcon, top: "42%", left: "15%", delay: "2s" },
{ icon: parcelIcon, top: "45%", right: "25%", delay: "0.7s" },
{ icon: cardIcon, top: "65%", left: "6%", delay: "1.7s" },
{ icon: headphonesIcon, top: "65%", right: "2%", delay: "2.4s" },
{ icon: watchIcon, bottom: "10%", left: "24%", delay: "3s" },
{ icon: glassesIcon, bottom: "5%", right: "20%", delay: "3.4s" },
];
export const SUPPORT_EMAIL = import.meta.env.VITE_SUPPORT_EMAIL || "support@nakhodka.app";

View File

@@ -24,5 +24,8 @@ export const EMPTY_FORM = {
phone: "",
date: "",
address: "",
rulesAccepted: false
rulesAccepted: false,
reward: false
};
export const MAX_PHOTO_SIZE_MB = 5;

View File

@@ -1,32 +1,7 @@
export const markers = [
{ id: "paw", x: 14, y: 18, delay: "0.4s", icon: "paw" },
{ id: "key", x: 72, y: 22, delay: "1.1s", icon: "key" },
{ id: "phone", x: 60, y: 62, delay: "1.8s", icon: "phone" },
{ id: "bag", x: 26, y: 70, delay: "2.4s", icon: "bag" },
{id: "paw", x: 14, y: 18, delay: "0.4s", icon: "paw"},
{id: "key", x: 72, y: 22, delay: "1.1s", icon: "key"},
{id: "phone", x: 60, y: 62, delay: "1.8s", icon: "phone"},
{id: "bag", x: 26, y: 70, delay: "2.4s", icon: "bag"},
];
export const steps = [
{
n: "01",
title: "Разместите объявление",
text: "Опишите пропажу или находку: фото, место, приметы. Это занимает меньше минуты.",
},
{
n: "02",
title: "Система ищет совпадения",
text: "Находка сама сравнивает похожие объявления по фото, месту и описанию — по всему городу.",
},
{
n: "03",
title: "Свяжитесь и заберите",
text: "Как только есть совпадение — пишите в чат прямо в приложении и договоритесь о встрече.",
},
];
export const stats = [
{ value: "12 480", label: "находок опубликовано" },
{ value: "9 200+", label: "воссоединений с хозяевами" },
{ value: "24/7", label: "поиск работает без перерыва" },
];

View File

@@ -1,4 +1,5 @@
import { createContext, useContext, useState, useCallback } from "react";
import { saveTokens, clearTokens, getAccessToken } from "../api/apiClient";
const AuthContext = createContext(null);
@@ -6,6 +7,10 @@ const AuthContext = createContext(null);
* AuthProvider — глобальный контекст аутентификации.
* Хранит текущего пользователя и JWT-токен в localStorage.
* Предоставляет методы login / logout / isAuthenticated.
*
* Токены (access + refresh) физически хранятся и читаются через apiClient —
* это единственный источник правды по ключам localStorage, чтобы контекст
* и apiFetch/refreshAccessToken не могли рассинхронизироваться.
*/
export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
@@ -17,20 +22,19 @@ export function AuthProvider({ children }) {
}
});
const [token, setToken] = useState(() => localStorage.getItem("token"));
const [token, setToken] = useState(() => getAccessToken());
/** Сохраняет данные пользователя и токен после успешного входа. */
const login = useCallback((userData, tokenStr) => {
/** Сохраняет данные пользователя и токены после успешного входа. */
const login = useCallback((userData, tokenStr, refreshTokenStr) => {
saveTokens(tokenStr, refreshTokenStr);
localStorage.setItem("user", JSON.stringify(userData));
localStorage.setItem("token", tokenStr);
setUser(userData);
setToken(tokenStr);
}, []);
/** Очищает все данные аутентификации. */
/** Очищает все данные аутентификации (access, refresh, user). */
const logout = useCallback(() => {
localStorage.removeItem("user");
localStorage.removeItem("token");
clearTokens();
setUser(null);
setToken(null);
}, []);
@@ -42,18 +46,18 @@ export function AuthProvider({ children }) {
}, []);
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
login,
logout,
updateUser,
}}
>
{children}
</AuthContext.Provider>
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
login,
logout,
updateUser,
}}
>
{children}
</AuthContext.Provider>
);
}
@@ -64,4 +68,4 @@ export function useAuthContext() {
throw new Error("useAuthContext must be used inside AuthProvider");
}
return ctx;
}
}

View File

@@ -6,7 +6,15 @@ export function UnreadProvider({ children }) {
const [unreadMap, setUnreadMap] = useState({});
const setChatUnread = useCallback((chatId, count) => {
setUnreadMap(prev => ({ ...prev, [chatId]: count }));
setUnreadMap(prev => {
if (count > 0) {
return { ...prev, [chatId]: count };
}
if (prev[chatId] === undefined) return prev;
const next = { ...prev };
delete next[chatId];
return next;
});
}, []);
const clearChatUnread = useCallback((chatId) => {
@@ -18,6 +26,11 @@ export function UnreadProvider({ children }) {
});
}, []);
/**
* Инициализирует/синхронизирует unreadMap из полного списка чатов
* (например, при первой загрузке через REST). Ожидает ПОЛНЫЙ список —
* чаты, отсутствующие в переданном массиве, не трогает.
*/
const updateFromChats = useCallback((chats) => {
setUnreadMap(prev => {
const next = { ...prev };
@@ -25,6 +38,8 @@ export function UnreadProvider({ children }) {
const count = chat.unreadCount ?? chat.unreadMessages ?? 0;
if (count > 0) {
next[chat.id] = count;
} else {
delete next[chat.id];
}
}
return next;
@@ -32,14 +47,14 @@ export function UnreadProvider({ children }) {
}, []);
const totalUnread = useMemo(() =>
Object.values(unreadMap).reduce((sum, c) => sum + c, 0),
[unreadMap]
Object.values(unreadMap).reduce((sum, c) => sum + c, 0),
[unreadMap]
);
return (
<UnreadContext.Provider value={{ unreadMap, totalUnread, setChatUnread, clearChatUnread, updateFromChats }}>
{children}
</UnreadContext.Provider>
<UnreadContext.Provider value={{ unreadMap, totalUnread, setChatUnread, clearChatUnread, updateFromChats }}>
{children}
</UnreadContext.Provider>
);
}
@@ -47,4 +62,4 @@ export function useUnread() {
const ctx = useContext(UnreadContext);
if (!ctx) throw new Error('useUnread must be used within UnreadProvider');
return ctx;
}
}

View File

@@ -1,50 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL } from "../config/authConstants";
import { useAuthContext } from "../context/AuthContext";
import { saveTokens } from "../api/apiClient";
export function useAuth() {
const navigate = useNavigate();
const { login: saveAuth } = useAuthContext();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const login = async (login, password) => {
if (!login || !password) { setError("Enter login and password"); 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 || "Invalid login or password");
}
const data = await res.json();
console.log("LOGIN RESPONSE", data);
const { token, refreshToken, user } = data;
console.log("ACCESS", token);
console.log("REFRESH", refreshToken);
saveTokens(token, refreshToken);
saveAuth(user, token);
navigate("/feed");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return { login, loading, error };
}

View File

@@ -22,7 +22,6 @@ export function useChatSocket(chatId, onMessage) {
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
reconnectDelay: 3000,
beforeConnect: async () => {
// На каждую попытку коннекта — свежий токен из localStorage
client.connectHeaders = {
Authorization: `Bearer ${getAccessToken()}`,
};
@@ -30,13 +29,16 @@ export function useChatSocket(chatId, onMessage) {
onConnect: () => {
setConnected(true);
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
const body = JSON.parse(msg.body);
onMessageRef.current(body);
try {
const body = JSON.parse(msg.body);
onMessageRef.current(body);
} catch (e) {
console.error("Failed to parse chat message:", e);
}
});
},
onDisconnect: () => setConnected(false),
onStompError: async (frame) => {
// Если сервер отклонил CONNECT из-за протухшего токена — пробуем обновить и переподключиться
if (frame.headers?.message?.includes("Unauthorized")) {
try {
await refreshAccessToken();
@@ -53,12 +55,14 @@ export function useChatSocket(chatId, onMessage) {
return () => client.deactivate();
}, [chatId]);
/** @returns {boolean} true если сообщение реально ушло в сокет, false если соединения нет */
const sendMessage = useCallback((content) => {
if (!clientRef.current?.connected) return;
if (!clientRef.current?.connected) return false;
clientRef.current.publish({
destination: "/app/chat.send",
body: JSON.stringify({ chatId, content }),
});
return true;
}, [chatId]);
return { connected, sendMessage };

View File

@@ -1,47 +1,57 @@
import { useState, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
import { cachePostsFromChats, setPostForChat } from "../stores/chatPostStore";
import { useUnread } from "../context/UnreadContext";
export function useChats() {
const { t } = useTranslation();
const [chats, setChats] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { updateFromChats, clearChatUnread } = useUnread();
const fetchChats = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await apiFetch(`${API_URL}/api/v1/chats`);
if (!res.ok) throw new Error(t('chats.fetchError'));
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);
updateFromChats(list);
} catch (e) {
setError(e.message || t('chats.fetchError'));
} finally {
setLoading(false);
}
}, []);
}, [updateFromChats, t]);
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 apiFetch(url, { method: "POST" });
if (!res.ok) throw new Error("Не удалось создать чат");
if (!res.ok) throw new Error(t('chats.createError'));
const chat = await res.json();
if (postInfo && chat.id) {
setPostForChat(chat.id, postInfo);
}
return chat;
}, []);
}, [t]);
const fetchMessages = useCallback(async (chatId) => {
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}/messages`);
const data = await res.json();
// Normalize: array or wrapper object
return Array.isArray(data) ? data : (data.items ?? data.content ?? []);
}, []);
const markAsRead = useCallback(async (chatId) => {
clearChatUnread(chatId);
try {
await apiFetch(`${API_URL}/api/v1/chats/${chatId}/read`, {
method: "POST",
@@ -49,7 +59,7 @@ export function useChats() {
} catch (e) {
console.error("Failed to mark as read:", e);
}
}, []);
}, [clearChatUnread]);
return { chats, loading, fetchChats, createOrGetChat, fetchMessages, markAsRead };
return { chats, loading, error, fetchChats, createOrGetChat, fetchMessages, markAsRead };
}

35
src/hooks/useCities.js Normal file
View File

@@ -0,0 +1,35 @@
import { useState, useEffect, useCallback } from "react";
import { API_URL } from "../config/authConstants";
/**
* useCities — загружает список уникальных городов с бэкенда.
*
* @returns {Object} { cities, loading, error }
*/
export function useCities() {
const [cities, setCities] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const loadCities = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/posts/cities`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const list = Array.isArray(data) ? data : [];
setCities([...new Set(list.filter(c => c && c.trim()))]);
} catch (err) {
setError(err.message || "Failed to load cities");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadCities();
}, [loadCities]);
return { cities, loading, error };
}

41
src/hooks/useCityFeed.jsx Normal file
View File

@@ -0,0 +1,41 @@
import { useState, useEffect, useCallback } from "react";
import { useSearchPosts } from "./useSearchPosts";
/**
* Поиск объявлений по нескольким выбранным городам, с объединением
* результатов без дублей. Автоматически перезапускается при смене
* selectedCities.
*/
export function useCityFeed(selectedCities) {
const { loading: cityLoading, searchPosts } = useSearchPosts();
const [cityAds, setCityAds] = useState([]);
const searchCities = useCallback((citiesToSearch) => {
if (!citiesToSearch.length) return Promise.resolve([]);
return Promise.all(citiesToSearch.map(c => searchPosts(c))).then(results => {
const seen = new Set();
const merged = [];
results.flat().forEach(ad => {
if (!seen.has(ad.id)) {
seen.add(ad.id);
merged.push(ad);
}
});
return merged;
});
}, [searchPosts]);
useEffect(() => {
if (selectedCities.length === 0) {
setCityAds([]);
return;
}
let cancelled = false;
searchCities(selectedCities).then(result => {
if (!cancelled) setCityAds(result);
});
return () => { cancelled = true; };
}, [selectedCities, searchCities]);
return { cityAds, cityLoading, searchCities };
}

View File

@@ -0,0 +1,20 @@
import { useEffect } from "react";
/**
* Вызывает callback при клике вне элемента, на который повешен ref.
* @param {React.RefObject} ref
* @param {Function} onOutsideClick
* @param {boolean} enabled — можно временно отключать слушатель
*/
export function useClickOutside(ref, onOutsideClick, enabled = true) {
useEffect(() => {
if (!enabled) return;
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
onOutsideClick();
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [ref, onOutsideClick, enabled]);
}

View File

@@ -0,0 +1,46 @@
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from 'react-i18next';
import { useChats } from "./useChats";
/**
* useContactSeller — общая логика открытия/создания чата с автором объявления.
* Используется в PostOnFeed (карточка в ленте) и ImageViewer (просмотр фото).
*
* @param {object} post — объявление: { id, userId, title, images, emoji }
* @returns {{
* contactSeller: (onBeforeNavigate?: function) => Promise<void>,
* error: string,
* clearError: function
* }}
*/
export function useContactSeller(post) {
const { t } = useTranslation();
const [error, setError] = useState("");
const navigate = useNavigate();
const { createOrGetChat } = useChats();
/**
* @param {function} [onBeforeNavigate] — вызывается перед переходом в чат
* (например, чтобы закрыть модалку просмотра фото до навигации)
*/
const contactSeller = useCallback(async (onBeforeNavigate) => {
if (!post) return;
try {
const chat = await createOrGetChat(post.userId, post.id, {
title: post.title,
images: post.images,
emoji: post.emoji,
});
onBeforeNavigate?.();
navigate(`/chats/${chat.id}`);
} catch (err) {
console.error(err);
setError(t('postCard.contactError'));
}
}, [post, createOrGetChat, navigate, t]);
const clearError = useCallback(() => setError(""), []);
return { contactSeller, error, clearError };
}

View File

@@ -1,38 +0,0 @@
import { useState } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
export function useCreatePost() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const createAd = async (formData) => { // ← принимаем FormData
setLoading(true);
setError(null);
try {
const res = await apiFetch(`${API_URL}/api/v1/posts`, {
method: "POST",
// Content-Type НЕ указываем — браузер сам добавит multipart/form-data с boundary
body: formData, // ← передаём FormData
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Error creating ad");
}
setSuccess(true);
return data;
} catch (e) {
setError(e.message);
return null;
} finally {
setLoading(false);
}
};
return { createAd, loading, error, success };
}

View File

@@ -1,10 +1,14 @@
import { useState, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
import { useUnread } from "../context/UnreadContext";
export function useDeleteChat() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { clearChatUnread } = useUnread();
const deleteChat = useCallback(async (chatId) => {
setLoading(true);
@@ -13,17 +17,22 @@ export function useDeleteChat() {
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}`, {
method: "DELETE",
});
if (!res.ok) {
throw new Error(`Ошибка ${res.status}`);
let body;
try { body = await res.json(); } catch { body = null; }
throw new Error(body?.message || t('chat.deleteErrorWithStatus', { status: res.status }));
}
clearChatUnread(chatId);
return true;
} catch (err) {
setError(err.message || "Failed to delete chat");
setError(err.message || t('chat.deleteError'));
return false;
} finally {
setLoading(false);
}
}, []);
}, [t, clearChatUnread]);
return { deleteChat, loading, error };
}

View File

@@ -1,13 +1,19 @@
import { useState, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
/**
* useDeletePost — хук для удаления объявления.
*
* deletePost возвращает { success, error } вместо голого boolean — так
* вызывающий код получает актуальный текст ошибки сразу, без риска прочитать
* устаревшее значение error state до его обновления React'ом.
*
* @returns {Object} { deletePost, loading, error }
*/
export function useDeletePost() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -21,17 +27,20 @@ export function useDeletePost() {
});
if (!res.ok) {
throw new Error(`Ошибка ${res.status}`);
let body;
try { body = await res.json(); } catch { body = null; }
throw new Error(body?.message || t('post.deleteErrorWithStatus', { status: res.status }));
}
return true;
return { success: true, error: null };
} catch (err) {
setError(err.message || "Не удалось удалить объявление");
return false;
const message = err.message || t('post.deleteError');
setError(message);
return { success: false, error: message };
} finally {
setLoading(false);
}
}, []);
}, [t]);
return { deletePost, loading, error };
}

View File

@@ -1,4 +1,6 @@
import { useCallback } from 'react';
// src/hooks/useFavorites.js
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { API_URL } from '../config/authConstants';
import { apiFetch } from '../api/apiClient';
@@ -7,51 +9,59 @@ import { apiFetch } from '../api/apiClient';
* Использует backend API для добавления/удаления лайков.
*/
export function useFavorites() {
const { t } = useTranslation();
const [error, setError] = useState("");
/**
* Переключает лайк на посте через API.
* @param {number} postId - ID поста
* @returns {Promise<boolean>} - true если операция успешна
* @returns {Promise<{success: boolean, error: string|null}>}
*/
const toggleFavorite = useCallback(async (postId) => {
setError("");
try {
const response = await apiFetch(`${API_URL}/api/v1/favorites/${postId}/toggle`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Failed to toggle favorite: ${response.status}`);
throw new Error(t('favorites.toggleError'));
}
return true;
} catch (error) {
console.error('Error toggling favorite:', error);
return false;
return { success: true, error: null };
} catch (err) {
console.error('Error toggling favorite:', err);
const message = err.message || t('favorites.toggleError');
setError(message);
return { success: false, error: message };
}
}, []);
}, [t]);
/**
* Загружает список лайкнутых постов текущего пользователя.
* @returns {Promise<Array>} - массив ID лайкнутых постов
*/
const loadFavorites = useCallback(async () => {
setError("");
try {
const response = await apiFetch(`${API_URL}/api/v1/favorites`);
if (!response.ok) {
throw new Error(`Failed to load favorites: ${response.status}`);
throw new Error(t('favorites.loadError'));
}
const data = await response.json();
// API возвращает массив постов, извлекаем ID
return Array.isArray(data) ? data.map(post => post.id) : [];
} catch (error) {
console.error('Error loading favorites:', error);
} catch (err) {
console.error('Error loading favorites:', err);
setError(err.message || t('favorites.loadError'));
return [];
}
}, []);
}, [t]);
return {
toggleFavorite,
loadFavorites,
error,
};
}

View File

@@ -0,0 +1,81 @@
// src/hooks/useFeedActions.js
import { useState, useCallback } from "react";
import { useDeletePost } from "./useDeletePost";
import { useFavorites } from "./useFavorites";
import { usePostStatus } from "./usePostStatus";
/**
* Объединяет действия над объявлениями (лайк, удаление, отметка "найдено")
* и общий паттерн "выполнить действие -> перезагрузить ленту и мои объявления".
*
* Авторизация (Authorization header + авто-обновление токена) обрабатывается
* внутри apiFetch — сам token сюда передавать не нужно, только isAuthenticated
* как ранняя проверка перед запросом.
*
* Каждое действие (deletePost/toggleFavorite/markAsFound) возвращает
* { success, error } напрямую из вызова — это исключает гонку между
* обновлением error state дочернего хука и его чтением здесь же в том же
* тике (React batching иначе мог бы подставить устаревшее значение).
*
* @param {Function} refresh — перезагрузить текущий список (лента/город)
* @param {Function} loadMyPosts — перезагрузить "мои объявления"
* @param {Function} onAuthRequired — вызывается когда нужна авторизация
* @param {boolean} isAuthenticated
*/
export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthenticated, t }) {
const { deletePost } = useDeletePost();
const { toggleFavorite } = useFavorites();
const { markAsFound } = usePostStatus();
const [error, setError] = useState("");
const afterMutation = useCallback(() => {
refresh();
loadMyPosts();
}, [refresh, loadMyPosts]);
const toggleLike = useCallback(async (id) => {
if (!isAuthenticated) {
onAuthRequired();
return;
}
const result = await toggleFavorite(id);
if (result.success) {
afterMutation();
} else {
setError(result.error);
}
}, [isAuthenticated, onAuthRequired, toggleFavorite, afterMutation]);
const handleDelete = useCallback(async (postId) => {
if (!window.confirm(t('feed.confirmDelete'))) return;
const result = await deletePost(postId);
if (result.success) {
afterMutation();
} else {
setError(result.error);
}
}, [t, deletePost, afterMutation]);
const handleMarkFound = useCallback(async (postId) => {
if (!window.confirm(t('feed.confirmMarkFound'))) return;
const result = await markAsFound(postId);
if (result.success) {
afterMutation();
} else {
setError(result.error);
}
}, [t, markAsFound, afterMutation]);
const clearError = useCallback(() => setError(""), []);
return {
toggleLike,
handleDelete,
handleMarkFound,
handleNewAd: afterMutation,
handleEditSave: afterMutation,
error,
clearError,
};
}

View File

@@ -0,0 +1,50 @@
import { useState, useCallback } from "react";
import { API_URL } from "../config/authConstants";
const PAGE_SIZE = 20;
/**
* Управляет основной лентой объявлений: загрузка, пагинация, поиск.
*/
export function useFeedPosts() {
const [ads, setAds] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
if (pageNum === 0) {
setLoading(true);
} else {
setLoadingMore(true);
}
setError("");
try {
const params = new URLSearchParams({ page: pageNum, size: PAGE_SIZE });
if (search) params.set("search", search);
const res = await fetch(`${API_URL}/api/v1/posts?${params}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const content = data.content ?? [];
const normalizedAds = content.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss",
}));
setAds(prev => (append ? [...prev, ...normalizedAds] : normalizedAds));
setHasMore(!data.last);
setPage(pageNum);
} catch (err) {
setError(err.message || "Failed to load ads");
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
return { ads, loading, error, page, hasMore, loadingMore, loadFeed };
}

View File

@@ -1,8 +1,9 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import { compressImage } from "../utils/compressImage";
import { getImageUrl } from "../config/authConstants";
const MAX_SIZE_MB = 5;
export const MAX_SIZE_MB = 5;
export const ACCEPT = "image/jpeg,image/png,image/webp";
/**
@@ -18,6 +19,7 @@ function generateImageId() {
}
export function useImageUpload(existingUrls = []) {
const { t } = useTranslation();
const [images, setImages] = useState([]);
const [imgError, setImgError] = useState("");
const [compressing, setCompressing] = useState(false);
@@ -45,11 +47,11 @@ export function useImageUpload(existingUrls = []) {
for (const file of files) {
if (!file.type.startsWith("image/")) {
setImgError("Images only (JPEG, PNG, WebP)");
setImgError(t('imageUpload.invalidType'));
return;
}
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
setImgError(`File too large — max ${MAX_SIZE_MB} MB`);
setImgError(t('imageUpload.tooLarge', { maxSize: MAX_SIZE_MB }));
return;
}
}
@@ -66,12 +68,12 @@ export function useImageUpload(existingUrls = []) {
}));
setImages(prev => [...prev, ...newImages]);
} catch {
setImgError("Failed to process image");
setImgError(t('imageUpload.processingFailed'));
} finally {
setCompressing(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}, []);
}, [t]);
const removeImage = useCallback((id) => {
setImages(prev => {
@@ -91,15 +93,22 @@ export function useImageUpload(existingUrls = []) {
return images.filter(i => i.isExisting).map(i => i.serverUrl);
}, [images]);
// Ref нужен, чтобы cleanup при размонтировании видел АКТУАЛЬНЫЙ список
// изображений, а не тот, что был на момент первого рендера (иначе
// object URL'ы для реально загруженных файлов никогда не отзываются).
const imagesRef = useRef(images);
useEffect(() => {
imagesRef.current = images;
}, [images]);
useEffect(() => {
return () => {
images.forEach(img => {
imagesRef.current.forEach(img => {
if (!img.isExisting && img.preview) {
URL.revokeObjectURL(img.preview);
}
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {

View File

@@ -0,0 +1,27 @@
import { useRef, useCallback } from "react";
/**
* Универсальный хук для бесконечного скролла через IntersectionObserver.
* Возвращает ref, который нужно повесить на сентинел-элемент в конце списка.
*
* @param {Function} onIntersect — вызывается, когда сентинел попадает в вьюпорт
* @param {boolean} enabled — можно ли сейчас триггерить подгрузку
*/
export function useInfiniteScroll(onIntersect, enabled) {
const observerRef = useRef(null);
const sentinelRef = useCallback(node => {
if (!enabled) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && enabled) {
onIntersect();
}
}, { rootMargin: "200px" });
if (node) observerRef.current.observe(node);
}, [enabled, onIntersect]);
return sentinelRef;
}

View File

@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
/**
* useLanguageToggle — переключение между ru/en.
*
* i18next сам сохраняет выбранный язык в localStorage
* через LanguageDetector (ключ 'i18nextLng'),
* если detection.caches включает localStorage.
*/
export function useLanguageToggle() {
const { i18n } = useTranslation();
const toggleLanguage = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(next);
};
return {
language: i18n.language,
toggleLanguage,
};
}

21
src/hooks/useModals.jsx Normal file
View File

@@ -0,0 +1,21 @@
import { useState, useCallback } from "react";
/**
* Управляет всеми модалками MainFeed через единое состояние,
* чтобы гарантировать что открыта максимум одна модалка сразу.
*
* Типы: "create" | "edit" | "profile" | "auth" | "rules" | "viewer"
*/
export function useModals() {
const [modal, setModal] = useState(null); // { type, payload }
const open = useCallback((type, payload = null) => {
setModal({ type, payload });
}, []);
const close = useCallback(() => setModal(null), []);
const isOpen = useCallback((type) => modal?.type === type, [modal]);
return { modal, open, close, isOpen };
}

38
src/hooks/useMyPosts.jsx Normal file
View File

@@ -0,0 +1,38 @@
import { useState, useCallback } from "react";
import { API_URL } from "../config/authConstants";
/**
* Загружает объявления текущего пользователя по userId.
*/
export function useMyPosts(userId) {
const [myAds, setMyAds] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const loadMyPosts = useCallback(async () => {
if (!userId) return;
setLoading(true);
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/posts/${userId}`);
if (res.status === 404) {
setMyAds([]);
return;
}
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const posts = Array.isArray(data) ? data : (data.items ?? []);
const normalizedPosts = posts.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss",
}));
setMyAds(normalizedPosts);
} catch (err) {
setError(err.message || "Failed to load your ads");
} finally {
setLoading(false);
}
}, [userId]);
return { myAds, loading, error, loadMyPosts };
}

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from 'react-i18next';
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
@@ -19,6 +20,7 @@ import { apiFetch } from "../api/apiClient";
* null — серверная ошибка (свойство error содержит текст)
*/
export function usePostMutation() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -46,12 +48,12 @@ export function usePostMutation() {
return { blocked: true, reason: body.reason || "" };
}
throw new Error(body?.message || `Ошибка сервера: ${res.status}`);
throw new Error(body?.message || t('post.serverError', { status: res.status }));
}
return { data: await res.json() };
} catch (e) {
setError(e.message || "Неизвестная ошибка");
setError(e.message || t('post.unknownError'));
return null;
} finally {
setLoading(false);

View File

@@ -1,66 +1,61 @@
import { useCallback } from 'react';
// src/hooks/usePostStatus.js
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { API_URL } from '../config/authConstants';
import { apiFetch } from '../api/apiClient';
/**
* Хук для изменения статуса поста (ACTIVE/CLOSED).
* Используется для отметки "Вещь найдена".
* Используется для отметки "Вещь найдена" и (в будущем) для reopen.
*/
export function usePostStatus() {
const { t } = useTranslation();
const [error, setError] = useState("");
/**
* Изменяет статус поста на CLOSED (вещь найдена).
* @param {number} postId - ID поста
* @returns {Promise<boolean>} - true если операция успешна
* @returns {Promise<{success: boolean, error: string|null}>}
*/
const markAsFound = useCallback(async (postId) => {
const updateStatus = useCallback(async (postId, status) => {
setError("");
try {
const response = await apiFetch(`${API_URL}/api/v1/posts/${postId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'CLOSED' }),
body: JSON.stringify({ status }),
});
if (!response.ok) {
throw new Error(`Failed to update status: ${response.status}`);
throw new Error(t('post.statusUpdateError'));
}
return true;
} catch (error) {
console.error('Error marking post as found:', error);
return false;
return { success: true, error: null };
} catch (err) {
console.error('Error updating post status:', err);
const message = err.message || t('post.statusUpdateError');
setError(message);
return { success: false, error: message };
}
}, []);
}, [t]);
/**
* Изменяет статус поста на CLOSED (вещь найдена).
* @param {number} postId - ID поста
* @returns {Promise<{success: boolean, error: string|null}>}
*/
const markAsFound = useCallback((postId) => updateStatus(postId, 'CLOSED'), [updateStatus]);
/**
* Возвращает статус поста в ACTIVE (если нужно reopen).
* @param {number} postId - ID поста
* @returns {Promise<boolean>} - true если операция успешна
* @returns {Promise<{success: boolean, error: string|null}>}
*/
const reopenPost = useCallback(async (postId) => {
try {
const response = await apiFetch(`${API_URL}/api/v1/posts/${postId}/status`, {
method: 'PATCH',
headers: {
'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;
}
}, []);
const reopenPost = useCallback((postId) => updateStatus(postId, 'ACTIVE'), [updateStatus]);
return {
markAsFound,
reopenPost,
error,
};
}

View File

@@ -1,69 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL } from "../config/authConstants";
import { saveTokens } from "../api/apiClient";
export function useCheckLogin(login) {
const [checking, setChecking] = useState(false);
const [exists, setExists] = useState(false);
useEffect(() => {
if (!login || login.length < 3) {
setExists(false);
return;
}
const timer = setTimeout(async () => {
try {
setChecking(true);
const res = await fetch(`${API_URL}/api/v1/auth/check-login?login=${encodeURIComponent(login)}`);
const json = await res.json();
setExists(json.exists);
} catch (e) {
console.error(e);
} finally {
setChecking(false);
}
}, 400);
return () => clearTimeout(timer);
}, [login]);
return { checking, exists };
}
export function useRegister() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const register = async ({ name, login, password }) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_URL}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, login, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Ошибка регистрации");
}
saveTokens(data.token, data.refreshToken);
setSuccess(true);
setTimeout(() => navigate("/feed"), 1500);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
return { register, loading, error, success };
}

91
src/hooks/useReviews.jsx Normal file
View File

@@ -0,0 +1,91 @@
import { useState, useCallback, useMemo } from "react";
import { API_URL } from "../config/authConstants";
/**
* Загрузка/отправка отзывов + производные данные (средний рейтинг,
* распределение по звёздам).
*/
export function useReviews({ token, isAuthenticated, onAuthRequired, authorFallbackName }) {
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const loadReviews = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/reviews`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const list = Array.isArray(data) ? data : data.items ?? data.reviews ?? [];
setReviews(list);
} catch (err) {
setError(err.message || "Failed to load reviews");
setReviews([]);
} finally {
setLoading(false);
}
}, []);
const submitReview = useCallback(async ({ rating, text }) => {
if (!isAuthenticated) {
onAuthRequired();
return;
}
setSubmitting(true);
setSubmitError("");
try {
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 }),
});
if (!res.ok) throw new Error(`Error ${res.status}`);
await loadReviews();
} catch (err) {
setSubmitError(err.message || "Failed to submit review");
// Отзыв добавляется локально с пометкой "не сохранено",
// чтобы пользователь видел, что отправка НЕ удалась,
// а не что всё прошло успешно.
setReviews((prev) => [
{
id: `pending-${Date.now()}`,
authorName: authorFallbackName,
rating,
text,
createdAt: new Date().toISOString(),
pending: true,
},
...prev,
]);
} finally {
setSubmitting(false);
}
}, [isAuthenticated, onAuthRequired, token, loadReviews, authorFallbackName]);
const avgRating = reviews.length
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
: null;
const distribution = useMemo(() => {
const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
reviews.forEach((r) => {
if (counts[r.rating] !== undefined) counts[r.rating] += 1;
});
return [5, 4, 3, 2, 1].map((star) => ({
star,
count: counts[star],
pct: reviews.length ? Math.round((counts[star] / reviews.length) * 100) : 0,
}));
}, [reviews]);
return {
reviews, loading, error, submitting, submitError,
loadReviews, submitReview, avgRating, distribution,
};
}

View File

@@ -0,0 +1,43 @@
import { useState, useCallback } from "react";
import { API_URL } from "../config/authConstants";
/**
* useSearchPosts — поиск объявлений по городу.
* Ходит на GET /api/v1/posts/search?city=..., возвращает список без пагинации.
*
* @returns {Object} { posts, loading, error, searchPosts }
*/
export function useSearchPosts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const searchPosts = useCallback(async (city) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (city) params.set("city", city);
const res = await fetch(`${API_URL}/api/v1/posts/search?${params}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const list = Array.isArray(data) ? data : [];
const normalized = list.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setPosts(normalized);
return normalized;
} catch (err) {
setError(err.message || "Failed to search posts");
setPosts([]);
return [];
} finally {
setLoading(false);
}
}, []);
return { posts, loading, error, searchPosts };
}

View File

@@ -1,29 +0,0 @@
import { useState } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
export function useUpdatePost() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const updateAd = async (id, formData) => {
setLoading(true);
setError("");
try {
const res = await apiFetch(`${API_URL}/api/v1/posts/${id}`, {
method: "PUT",
body: formData, // FormData
});
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
return await res.json();
} catch (err) {
setError(err.message || "Failed to save");
return null;
} finally {
setLoading(false);
}
};
return { updateAd, loading, error };
}

View File

@@ -1,22 +1,30 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
const YANDEX_GEOCODER_API_KEY = import.meta.env.VITE_YANDEX_GEOCODER_KEY;
export function useYandexMap(enabled, initialCoords = null, initialAddress = "") {
const mapRef = useRef(null);
const ymapRef = useRef(null);
const placemarkRef = useRef(null);
const initializedRef = useRef(false); // защита от двойного вызова StrictMode
const requestIdRef = useRef(0); // защита от гонки при быстрых кликах/драгах
const [coords, setCoords] = useState(initialCoords);
const [address, setAddress] = useState(initialAddress ?? "");
const addressRef = useRef(initialAddress ?? ""); // ← добавьте
const addressRef = useRef(initialAddress ?? "");
const resolveAddress = useCallback((lat, lng) => {
const requestId = ++requestIdRef.current;
const resolveAddress = (lat, lng) => {
fetch(
`https://geocode-maps.yandex.ru/1.x/?apikey=c2d3c717-a109-4494-8863-7edbc32044e3&geocode=${lng},${lat}&format=json&lang=ru_RU`
`https://geocode-maps.yandex.ru/1.x/?apikey=${YANDEX_GEOCODER_API_KEY}&geocode=${lng},${lat}&format=json&lang=ru_RU`
)
.then(r => r.json())
.then(data => {
// Игнорируем ответ, если пришёл более новый запрос — иначе
// устаревший ответ может перезаписать адрес актуальных координат.
if (requestId !== requestIdRef.current) return;
const found =
data?.response?.GeoObjectCollection?.featureMember?.[0]
?.GeoObject?.metaDataProperty?.GeocoderMetaData?.text;
@@ -25,11 +33,13 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
addressRef.current = resolved;
})
.catch(() => {
if (requestId !== requestIdRef.current) return;
const fallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
setAddress(fallback);
addressRef.current = fallback;
});
};
}, []);
useEffect(() => {
if (!enabled || !mapRef.current) return;
@@ -37,7 +47,6 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
initializedRef.current = true;
const init = () => {
// Если карта уже создана — не создаём снова
if (ymapRef.current) return;
const center = initialCoords
@@ -90,13 +99,12 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
}
return () => {
// Уничтожаем карту только при размонтировании компонента
ymapRef.current?.destroy();
ymapRef.current = null;
placemarkRef.current = null;
initializedRef.current = false;
};
}, [enabled]);
}, [enabled, initialCoords, resolveAddress]);
return { mapRef, coords, address, addressRef };
}

355
src/locales/en.json Normal file
View File

@@ -0,0 +1,355 @@
{
"app": {
"name": "Nakhodka"
},
"nav": {
"home": "Home",
"myAds": "My ads",
"items": "Items",
"pets": "Pets",
"chat": "Chat",
"reviews": "Reviews",
"rules": "Platform Rules",
"support": "Support"
},
"categories": {
"item": "Item",
"pet": "Pet",
"other": "Other",
"select": "Select..."
},
"sidebar": {
"lightTheme": "Light theme",
"darkTheme": "Dark theme"
},
"auth": {
"welcomeBack": "Welcome back",
"enterEmail": "Enter email",
"enterPassword": "Enter password",
"forgotPassword": "Forgot password?",
"noAccount": "No account?",
"register": "Register",
"login": "Log in",
"loggingIn": "Logging in...",
"createAccount": "Create account",
"username": "Username",
"namePlaceholder": "What's your name?",
"loginLabel": "Login",
"loginPlaceholder": "Choose a login",
"passwordLabel": "Password",
"passwordMin": "Minimum 6 characters",
"passwordMinShort": "Min. 6 characters",
"repeatPassword": "Repeat password",
"repeatPasswordPlaceholder": "Enter again",
"repeatLabel": "Repeat",
"repeatPlaceholder": "One more time",
"createAccountBtn": "Create account ✓",
"creating": "Creating...",
"back": "← Back",
"next": "Next →",
"strengthWeak": "Weak",
"strengthMedium": "Medium",
"strengthGood": "Good",
"strengthExcellent": "Excellent",
"validateNameRequired": "Enter your name",
"validateLoginRequired": "Enter a login",
"validateLoginMin": "Minimum 3 characters",
"validatePasswordMin": "Minimum 6 characters",
"validatePasswordMatch": "Passwords don't match",
"checking": "Checking...",
"loginTaken": "✗ Login taken",
"loginFree": "✓ Login available",
"passwordsMatch": "✓ Match",
"passwordsNoMatch": "✗ Don't match",
"welcome": "Welcome, {{name}}!",
"accountCreated": "Account created successfully.",
"loginRedirect": "Log in →",
"hasAccount": "Already have an account?",
"loginLink": "Log in",
"authTitle": "Nakhodka",
"loginModalTitle": "Log in",
"registerModalTitle": "Register",
"enterLoginPassword": "Enter login and password",
"invalidCredentials": "Invalid login or password",
"yourLogin": "Your login",
"yourPassword": "Your password",
"registrationError": "Registration error",
"nameLabel": "Name",
"loginMinChars": "Minimum 3 characters",
"accountCreatedLoginManually": "Account created. Log in manually."
},
"post": {
"editTitle": "Edit ad",
"newTitle": "New ad",
"typeLabel": "Ad type",
"loss": "Lost",
"found": "Found",
"titleLabel": "Title",
"titlePlaceholder": "E.g. Black leather wallet",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Describe the item in detail — color, size, distinctive features...",
"photosLabel": "Photos",
"deletePhoto": "Delete photo",
"processing": "Processing...",
"addMore": "➕ Add more",
"addPhoto": "📷 Add photo",
"photoHint": "JPEG / PNG / WebP · up to {{maxSize}} MB · multiple allowed",
"categoryLabel": "Category",
"mapLabel": "Location on map",
"mapHint": "Click on the map to mark the location",
"addressLabel": "Address",
"addressPlaceholder": "E.g. 123 Main St, New York",
"phoneLabel": "Phone number",
"rewardCheckbox": "I am willing to offer a reward for the return",
"phonePlaceholder": "+1 (___) ___-__-__",
"saveChanges": "Save changes",
"publish": "Publish ad",
"mapLoading": "Please wait, address is loading...",
"blockedTitle": "Ad not published",
"blockedText": "Your ad did not pass moderation review. Please rephrase the text and try again.",
"successTitle": "Your ad is being reviewed",
"successText": "It will be published in the feed once moderation is complete",
"resultClose": "Got it",
"rulesCheckboxPrefix": "I agree with the",
"rulesLinkText": "platform rules",
"deleteError": "Failed to delete the ad",
"deleteErrorWithStatus": "Failed to delete ad (error {{status}})",
"statusUpdateError": "Failed to change the ad status."
},
"feed": {
"loading": "Loading ads...",
"loadError": "Failed to load feed",
"emptyMyAdsTitle": "You have no ads",
"emptyMyAdsSub": "Create your first one — click the button above",
"emptyAllTitle": "No ads yet",
"emptyAllSub": "Be the first — post an ad<br />about a loss or find",
"createPost": "Create ad",
"login": "Log in",
"searchPlaceholder": "Search by title or description...",
"filterLoss": "🔴 Lost",
"filterAll": "All ads",
"filterFound": "🟢 Found",
"filterCitiesPlaceholder": "Select city…",
"filterCitiesEmpty": "No cities found",
"filterCitiesAdd": "Add \"{{city}}\"",
"filterCitiesRemove": "Remove city",
"filterCitiesClear": "Reset cities",
"emptyCityTitle": "No ads in this city",
"emptyCitySub": "Try another city",
"confirmDelete": "Are you sure you want to delete this ad?",
"confirmMarkFound": "Mark this ad as \"Item found\"?"
},
"postCard": {
"lostFoundBureau": "Lost & Found Bureau",
"metro": "Metropolitan",
"loss": "Lost",
"found": "Found",
"foundStatus": "✓ Found",
"write": "Write",
"itemFound": "✓ Item found",
"showOnMap": "Show on map — {{address}}",
"contactError": "Unable to open the chat. Please try again",
"reward": "Reward"
},
"chat": {
"title": "Chat",
"online": "Online",
"offline": "Offline",
"noMessages": "No messages",
"messagePlaceholder": "Message...",
"chats": "Chats",
"loginToSee": "Log in to see chats",
"loading": "Loading...",
"noChats": "You have no chats yet",
"deleteChat": "Delete chat",
"confirmDeleteChat": "Are you sure you want to delete this chat?",
"user": "User"
},
"reviews": {
"anonymous": "Anonymous",
"ratePrompt": "Please rate",
"leaveReview": "Leave a review",
"rating": "Rating:",
"reviewPlaceholder": "Write your review (optional)...",
"sending": "Sending...",
"send": "Send",
"title": "Reviews",
"noReviews": "No reviews yet",
"reviewOne": "review",
"reviewSome": "reviews",
"reviewMany": "reviews",
"loginPrompt": "Log in to leave a review",
"login": "Log in",
"loading": "Loading reviews...",
"emptyTitle": "No reviews yet",
"emptySub": "Be the first — leave a review about the platform",
"you": "You"
},
"rightSidebar": {
"advertisement": "Advertisement",
"emptyText": "Your ad<br />could be here",
"addAd": "+ Add advertisement"
},
"landing": {
"eyebrow": "Service for finding lost pets and items",
"heroTitle": "Lost doesn't mean",
"heroAccent": "lost forever",
"heroLead": "Post about your loss or find — Nakhodka will automatically match similar listings by photo, location, and description, showing matches near you.",
"startSearch": "Start search",
"howItWorks": "How it works",
"step1Title": "Post an ad",
"step1Text": "Describe the loss or find: photo, location, details. It takes less than a minute.",
"step2Title": "System finds matches",
"step2Text": "Nakhodka compares similar ads by photo, location, and description across the entire city.",
"step3Title": "Contact and retrieve",
"step3Text": "Once there's a match, chat directly in the app and arrange a meeting.",
"stat1Value": "12,480",
"stat1Label": "ads published",
"stat2Value": "9,200+",
"stat2Label": "reunions with owners",
"stat3Value": "24/7",
"stat3Label": "search works around the clock",
"finalCta": "Someone right now is looking for what you found",
"openNakhodka": "Open Nakhodka",
"footer": "© {{year}} Nakhodka. Returning what's lost.",
"login": "Log in"
},
"profile": {
"default": "Profile",
"photoLabel": "Profile photo",
"deletePhoto": "Delete photo",
"processing": "Processing...",
"addPhoto": "📷 Add photo",
"photoHint": "JPEG / PNG / WebP · up to {{maxSize}} MB",
"loginLabel": "Login",
"nameLabel": "First name",
"namePlaceholder": "John",
"surnameLabel": "Last name",
"surnamePlaceholder": "Doe",
"phoneLabel": "Phone number",
"emailLabel": "Email",
"save": "Save profile",
"editTitle": "Edit profile",
"logout": "Log out",
"validateNameRequired": "Enter your first name",
"validateSurnameRequired": "Enter your last name",
"validateEmailInvalid": "Invalid email",
"validatePhoneInvalid": "Invalid phone number"
},
"validation": {
"titleRequired": "Enter a title",
"descriptionRequired": "Enter a description",
"categoryRequired": "Select a category",
"phoneRequired": "Enter a phone number",
"rulesRequired": "You must agree to the platform rules"
},
"calendar": {
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
"days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
},
"support": {
"title": "Support",
"text": "If you are experiencing a technical issue, want to report a bug, or leave a suggestion for improving the service, write to us:",
"footer": "We carefully review every inquiry."
},
"rules": {
"title": "\"Nakhodka\" Platform Rules",
"section1Title": "General Provisions",
"section1Body": "The Nakhodka platform is intended solely to help people find lost items and return found items to their owners. By posting an ad, you agree to comply with these rules and the laws of the Russian Federation.",
"section2Title": "What Can Be Posted",
"section2Items": [
"Ads about lost personal items, documents, animals, vehicles",
"Ads about found property with the purpose of returning to the owner",
"Photos of the found or lost item",
"Contact information for communicating with the owner or finder"
],
"section3Title": "What Is Prohibited",
"section3_1Title": "3.1. Prohibited Content",
"section3_1Items": [
"Ads for selling found property — this constitutes misappropriation of property and is prosecuted under Art. 160 of the Criminal Code of the Russian Federation",
"Knowingly false ads and fictitious finds",
"Fraudulent ads (Art. 159 of the Criminal Code of the Russian Federation)",
"Duplicates of the same ad"
],
"section3_2Title": "3.2. Prohibited Items",
"section3_2Items": [
"Weapons, ammunition, explosives",
"Narcotic and psychotropic substances",
"Stolen property",
"Third-party documents for personal gain"
],
"section3_3Title": "3.3. Prohibited Actions",
"section3_3Items": [
"Offering a reward in exchange for silence or refusal to contact the police",
"Demanding payment for returning found documents — passports and other documents must be returned free of charge",
"Publishing personal data of third parties without their consent (Art. 137 of the Criminal Code, 152-FZ)"
],
"section4Title": "Ad Requirements",
"section4Items": [
"Description must be truthful and accurate",
"Photos must match the description",
"Contact information must be current",
"Location and date of loss or find must be as precise as possible",
"Offensive or profane language is prohibited"
],
"section5Title": "Reward",
"section5Body": "Offering a reward to the finder is a voluntary and legal right of the owner. However:",
"section5Items": [
"The reward amount is determined solely by the owner of the item",
"The finder cannot demand a reward as a condition for return",
"According to Art. 229 of the Civil Code, the finder is entitled to a reward of up to 20% of the item's value if the owner publicly announced it"
],
"section6Title": "Found Documents",
"section6Body": "Finding someone else's documents obligates you under Russian law to:",
"section6Items": [
"Submit the documents to the nearest police station, or",
"Post an ad on the platform to return them to the owner",
"Keep the documents until return or transfer to the police"
],
"section6Warn": "Sale, use, or retention of someone else's documents is prosecuted by law.",
"section7Title": "Animals",
"section7Items": [
"A found animal should be provided with food and shelter where possible until returned to the owner",
"It is prohibited to post ads for selling found animals",
"If the owner is not found within 6 months, ownership of the animal may transfer to the finder under Art. 231 of the Civil Code of the Russian Federation"
],
"section8Title": "Confidentiality",
"section8Items": [
"Do not publish excessive personal data in your ad — a means of contact is sufficient",
"Do not disclose the owner's or finder's data to third parties without their consent",
"The platform is not responsible for data shared directly between users"
],
"section9Title": "Moderation",
"section9Body": "The platform administration reserves the right to:",
"section9Items": [
"Delete an ad without warning if rules are violated",
"Block a user account for systematic violations",
"Transfer user data to law enforcement authorities if there are signs of unlawful actions"
],
"section10Title": "User Responsibility",
"section10Body": "Users are solely responsible for the content of their ads. The Nakhodka platform is an information intermediary and is not responsible for user actions outside the platform."
},
"imageUpload": {
"invalidType": "Images only (JPEG, PNG, WebP)",
"tooLarge": "File too large — maximum {{maxSize}} МБ",
"processingFailed": "Failed to process the image"
},
"common": {
"close": "Close"
},
"viewer": {
"goToImage": "Go to image {{number}}"
},
"chats": {
"fetchError": "Failed to load chats",
"createError": "Failed to create chat",
"loadError": "Failed to load messages",
"sendError": "No connection. Message not sent",
"deleteError": "Failed to delete the chat",
"deleteErrorWithStatus": "Failed to delete chat (error {{status}})"
},
"favorites": {
"toggleError": "Failed to change the favorite status.",
"loadError": "Failed to load favorites"
}
}

356
src/locales/ru.json Normal file
View File

@@ -0,0 +1,356 @@
{
"app": {
"name": "Находка"
},
"nav": {
"home": "Главная",
"myAds": "Мои объявления",
"items": "Вещи",
"pets": "Питомцы",
"chat": "Чат",
"reviews": "Отзывы",
"rules": "Правила площадки",
"support": "Поддержка"
},
"categories": {
"item": "Вещь",
"pet": "Питомец",
"other": "Другое",
"select": "Выбрать..."
},
"sidebar": {
"lightTheme": "Светлая тема",
"darkTheme": "Тёмная тема"
},
"auth": {
"welcomeBack": "С возвращением",
"enterEmail": "Введите email",
"enterPassword": "Введите пароль",
"forgotPassword": "Забыли пароль?",
"noAccount": "Нет аккаунта?",
"register": "Зарегистрироваться",
"login": "Войти",
"loggingIn": "Входим...",
"createAccount": "Создайте аккаунт",
"username": "Имя пользователя",
"namePlaceholder": "Как вас зовут?",
"loginLabel": "Логин",
"loginPlaceholder": "Придумайте логин",
"passwordLabel": "Пароль",
"passwordMin": "Минимум 6 символов",
"passwordMinShort": "Мин. 6 символов",
"repeatPassword": "Повторите пароль",
"repeatPasswordPlaceholder": "Введите ещё раз",
"repeatLabel": "Повторите",
"repeatPlaceholder": "Ещё раз",
"createAccountBtn": "Создать аккаунт ✓",
"creating": "Создаём...",
"back": "← Назад",
"next": "Далее →",
"strengthWeak": "Слабый",
"strengthMedium": "Средний",
"strengthGood": "Хороший",
"strengthExcellent": "Отличный",
"validateNameRequired": "Введите имя",
"validateLoginRequired": "Введите логин",
"validateLoginMin": "Минимум 3 символа",
"validatePasswordMin": "Минимум 6 символов",
"validatePasswordMatch": "Пароли не совпадают",
"checking": "Проверяем...",
"loginTaken": "✗ Логин занят",
"loginFree": "✓ Логин свободен",
"passwordsMatch": "✓ Совпадают",
"passwordsNoMatch": "✗ Не совпадают",
"welcome": "Добро пожаловать, {{name}}!",
"accountCreated": "Аккаунт создан успешно.",
"loginRedirect": "Войти →",
"hasAccount": "Уже есть аккаунт?",
"loginLink": "Войти",
"authTitle": "Находка",
"loginModalTitle": "Вход в аккаунт",
"registerModalTitle": "Регистрация",
"enterLoginPassword": "Введите логин и пароль",
"invalidCredentials": "Неверный логин или пароль",
"yourLogin": "Ваш логин",
"yourPassword": "Ваш пароль",
"registrationError": "Ошибка регистрации",
"nameLabel": "Имя",
"loginMinChars": "Минимум 3 символа",
"accountCreatedLoginManually": "Аккаунт создан. Войдите вручную"
},
"post": {
"editTitle": "Редактировать объявление",
"newTitle": "Новое объявление",
"typeLabel": "Тип объявления",
"loss": "Потеря",
"found": "Находка",
"titleLabel": "Название",
"titlePlaceholder": "Например: Кошелёк чёрный кожаный",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Подробно опишите предмет — цвет, размер, особые приметы...",
"photosLabel": "Фотографии",
"deletePhoto": "Удалить фото",
"processing": "Обработка...",
"addMore": "➕ Добавить ещё",
"addPhoto": "📷 Добавить фото",
"photoHint": "JPEG / PNG / WebP · до {{maxSize}} МБ · можно несколько",
"categoryLabel": "Категория",
"mapLabel": "Место на карте",
"mapHint": "Нажмите на карту, чтобы отметить место",
"addressLabel": "Адрес",
"addressPlaceholder": "Например: Москва, ул. Ленина, 5",
"phoneLabel": "Номер телефона",
"rewardCheckbox": "Вознаграждение за возврат",
"phonePlaceholder": "+7 (___) ___-__-__",
"saveChanges": "Сохранить изменения",
"publish": "Разместить объявление",
"mapLoading": "Подождите, адрес загружается...",
"blockedTitle": "Объявление не размещено",
"blockedText": "Содержимое вашего объявления не прошло проверку модерации. Пожалуйста, переформулируйте текст и попробуйте снова.",
"successTitle": "Ваше объявление проверяется",
"successText": "После завершения модерации оно будет опубликовано в ленте",
"resultClose": "Понятно",
"rulesCheckboxPrefix": "Я согласен с",
"rulesLinkText": "правилами размещения площадки",
"deleteError": "Не удалось удалить объявление",
"deleteErrorWithStatus": "Не удалось удалить объявление (ошибка {{status}})",
"statusUpdateError": "Не удалось изменить статус объявления"
},
"feed": {
"loading": "Загружаем объявления...",
"loadError": "Не удалось загрузить ленту",
"emptyMyAdsTitle": "У вас нет объявлений",
"emptyMyAdsSub": "Создайте первое — нажмите кнопку выше",
"emptyAllTitle": "Объявлений пока нет",
"emptyAllSub": "Будьте первым — разместите объявление<br />о потере или находке",
"createPost": "Разместить объявление",
"login": "Войти",
"searchPlaceholder": "Поиск по названию или описанию...",
"filterLoss": "🔴 Потери",
"filterAll": "Все объявления",
"filterFound": "🟢 Находки",
"filterCitiesPlaceholder": "Выберите город…",
"filterCitiesEmpty": "Города не найдены",
"filterCitiesAdd": "Добавить «{{city}}»",
"filterCitiesRemove": "Убрать город",
"filterCitiesClear": "Сбросить города",
"emptyCityTitle": "В этом городе объявлений нет",
"emptyCitySub": "Попробуйте выбрать другой город",
"confirmDelete": "Вы уверены, что хотите удалить это объявление?",
"confirmMarkFound": "Отметить это объявление как \"Вещь найдена\"?"
},
"postCard": {
"lostFoundBureau": "Бюро находок",
"metro": "Метрополитен",
"loss": "Потеря",
"found": "Находка",
"foundStatus": "✓ Найдено",
"write": "Написать",
"itemFound": "✓ Вещь найдена",
"showOnMap": "Показать на карте — {{address}}",
"contactError": "Не удалось открыть чат. Попробуйте ещё раз",
"reward": "Вознаграждение"
},
"chat": {
"title": "Чат",
"online": "В сети",
"offline": "Отключено",
"noMessages": "Нет сообщений",
"messagePlaceholder": "Сообщение...",
"chats": "Чаты",
"loginToSee": "Войдите, чтобы видеть чаты",
"loading": "Загрузка...",
"noChats": "У вас пока нет чатов",
"deleteChat": "Удалить чат",
"confirmDeleteChat": "Вы уверены, что хотите удалить этот чат?",
"user": "Пользователь"
},
"reviews": {
"anonymous": "Аноним",
"ratePrompt": "Поставьте оценку",
"leaveReview": "Оставить отзыв",
"rating": "Оценка:",
"reviewPlaceholder": "Напишите ваш отзыв (необязательно)...",
"sending": "Отправка...",
"send": "Отправить",
"title": "Отзывы",
"noReviews": "Пока нет отзывов",
"reviewOne": "отзыв",
"reviewSome": "отзыва",
"reviewMany": "отзывов",
"loginPrompt": "Войдите, чтобы оставить отзыв",
"login": "Войти",
"loading": "Загружаем отзывы...",
"emptyTitle": "Отзывов пока нет",
"emptySub": "Станьте первым — оставьте отзыв о площадке",
"you": "Вы"
},
"rightSidebar": {
"advertisement": "Реклама",
"emptyText": "Здесь будет<br />ваша реклама",
"addAd": "+ Добавить рекламу"
},
"landing": {
"eyebrow": "Сервис поиска пропавших питомцев и вещей",
"heroTitle": "Потерял — не значит",
"heroAccent": "потерял навсегда",
"heroLead": "Разместите объявление о пропаже или находке — Находка сама сопоставит похожие записи по фото, месту и описанию и покажет совпадения рядом с вами.",
"startSearch": "Начать поиск",
"howItWorks": "Как это работает",
"step1Title": "Разместите объявление",
"step1Text": "Опишите пропажу или находку: фото, место, приметы. Это занимает меньше минуты.",
"step2Title": "Система ищет совпадения",
"step2Text": "Находка сама сравнивает похожие объявления по фото, месту и описанию — по всему городу.",
"step3Title": "Свяжитесь и заберите",
"step3Text": "Как только есть совпадение — пишите в чат прямо в приложении и договоритесь о встрече.",
"stat1Value": "12 480",
"stat1Label": "находок опубликовано",
"stat2Value": "9 200+",
"stat2Label": "воссоединений с хозяевами",
"stat3Value": "24/7",
"stat3Label": "поиск работает без перерыва",
"finalCta": "Кто-то прямо сейчас ищет то, что вы нашли",
"openNakhodka": "Открыть Находку",
"footer": "© {{year}} Находка. Возвращаем найденное.",
"login": "Войти"
},
"profile": {
"default": "Профиль",
"photoLabel": "Фото профиля",
"deletePhoto": "Удалить фото",
"processing": "Обработка...",
"addPhoto": "📷 Добавить фото",
"photoHint": "JPEG / PNG / WebP · до {{maxSize}} МБ",
"loginLabel": "Логин",
"nameLabel": "Имя",
"namePlaceholder": "Иван",
"surnameLabel": "Фамилия",
"surnamePlaceholder": "Иванов",
"phoneLabel": "Номер телефона",
"emailLabel": "Электронная почта",
"save": "Сохранить профиль",
"editTitle": "Редактирование профиля",
"logout": "Выйти",
"validateNameRequired": "Введите имя",
"validateSurnameRequired": "Введите фамилию",
"validateEmailInvalid": "Некорректный email",
"validatePhoneInvalid": "Некорректный номер телефона"
},
"validation": {
"titleRequired": "Введите название",
"descriptionRequired": "Введите описание",
"categoryRequired": "Выберите категорию",
"phoneRequired": "Введите номер телефона",
"rulesRequired": "Необходимо согласиться с правилами размещения площадки"
},
"calendar": {
"months": ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"],
"days": ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
},
"support": {
"title": "Служба поддержки",
"text": "Если вы столкнулись с технической проблемой, хотите сообщить об ошибке или оставить предложение по улучшению сервиса, напишите нам:",
"footer": "Мы внимательно рассматриваем каждое обращение."
},
"rules": {
"title": "Правила площадки «Находка»",
"section1Title": "Общие положения",
"section1Body": "Платформа «Находка» предназначена исключительно для помощи людям в поиске утерянных вещей и возврате найденных предметов их владельцам. Размещая объявление, вы соглашаетесь соблюдать настоящие правила и законодательство Российской Федерации.",
"section2Title": "Что можно размещать",
"section2Items": [
"Объявления о потере личных вещей, документов, животных, транспортных средств",
"Объявления о находке чужого имущества с целью возврата владельцу",
"Фотографии найденного или утерянного предмета",
"Контактные данные для связи с владельцем или нашедшим"
],
"section3Title": "Что запрещено размещать",
"section3_1Title": "3.1. Запрещённый контент",
"section3_1Items": [
"Объявления с целью продажи найденного имущества — это является присвоением чужого имущества и преследуется по ст. 160 УК РФ",
"Заведомо ложные объявления и фиктивные находки",
"Объявления с целью мошенничества (ст. 159 УК РФ)",
"Дубликаты одного и того же объявления"
],
"section3_2Title": "3.2. Запрещённые предметы",
"section3_2Items": [
"Оружие, боеприпасы, взрывчатые вещества",
"Наркотические и психотропные вещества",
"Похищенное имущество",
"Документы третьих лиц с целью использования в корыстных целях"
],
"section3_3Title": "3.3. Запрещённые действия",
"section3_3Items": [
"Указывать вознаграждение в обмен на молчание или отказ от обращения в полицию",
"Требовать оплату за возврат найденных документов — паспорт и иные документы подлежат безвозмездному возврату",
"Публиковать персональные данные третьих лиц без их согласия (ст. 137 УК РФ, 152-ФЗ)"
],
"section4Title": "Требования к объявлению",
"section4Items": [
"Описание должно быть достоверным и точным",
"Фотографии должны соответствовать описанию",
"Контактные данные должны быть актуальными",
"Место и дата потери или находки указываются максимально точно",
"Запрещено использовать оскорбительные, нецензурные выражения"
],
"section5Title": "Вознаграждение",
"section5Body": "Предложение вознаграждения нашедшему является добровольным и законным правом владельца. При этом:",
"section5Items": [
"Размер вознаграждения определяет только владелец вещи",
"Нашедший не вправе требовать вознаграждение как условие возврата",
"Согласно ст. 229 ГК РФ нашедший имеет право на вознаграждение до 20% от стоимости вещи, если владелец публично его объявил"
],
"section6Title": "Найденные документы",
"section6Body": "Нахождение чужих документов обязывает вас согласно законодательству РФ:",
"section6Items": [
"Сдать документы в ближайшее отделение полиции, либо",
"Разместить объявление на платформе для возврата владельцу",
"Хранить документы до момента возврата или передачи в полицию"
],
"section6Warn": "Продажа, использование или удержание чужих документов преследуется по закону.",
"section7Title": "Животные",
"section7Items": [
"Найденное животное необходимо по возможности обеспечить едой и кровом до возврата владельцу",
"Запрещено размещать объявления о продаже найденных животных",
"Если владелец не найден в течение 6 месяцев, право собственности на животное может перейти к нашедшему согласно ст. 231 ГК РФ"
],
"section8Title": "Конфиденциальность",
"section8Items": [
"Не публикуйте в объявлении избыточные персональные данные — достаточно способа связи",
"Не раскрывайте данные владельца или нашедшего третьим лицам без их согласия",
"Платформа не несёт ответственности за передачу данных пользователями напрямую"
],
"section9Title": "Модерация",
"section9Body": "Администрация платформы вправе:",
"section9Items": [
"Удалить объявление без предупреждения при нарушении правил",
"Заблокировать аккаунт пользователя при систематических нарушениях",
"Передать данные пользователя в правоохранительные органы при наличии признаков противоправных действий"
],
"section10Title": "Ответственность пользователей",
"section10Body": "Пользователь самостоятельно несёт ответственность за содержание размещаемых объявлений. Платформа «Находка» является информационным посредником и не несёт ответственности за действия пользователей вне платформы."
},
"imageUpload": {
"invalidType": "Только изображения (JPEG, PNG, WebP)",
"tooLarge": "Файл слишком большой — максимум {{maxSize}} МБ",
"processingFailed": "Не удалось обработать изображение"
},
"common": {
"close": "Закрыть"
},
"viewer": {
"goToImage": "Перейти к изображению {{number}}"
},
"chats": {
"fetchError": "Не удалось загрузить чаты",
"createError": "Не удалось создать чат",
"loadError": "Не удалось загрузить сообщения",
"sendError": "Нет соединения. Сообщение не отправлено",
"deleteError": "Не удалось удалить чат",
"deleteErrorWithStatus": "Не удалось удалить чат (ошибка {{status}})"
},
"favorites": {
"toggleError": "Не удалось изменить статус избранного",
"loadError": "Не удалось загрузить избранное"
}
}

View File

@@ -1,20 +1,20 @@
import { useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from 'react-i18next';
import { useChats } from "../hooks/useChats";
import { useAuthContext } from "../context/AuthContext";
import { useUnread } from "../context/UnreadContext";
import { getPostForChat } from "../stores/chatPostStore";
import { getImageUrl } from "../config/authConstants";
import { useDeleteChat } from "../hooks/useDeleteChat";
import TrashIcon from "../components/icons/TrashIcon";
import { useTranslation } from 'react-i18next';
import ChatHeader from "../components/chat/ChatHeader";
import ChatListItem from "../components/chat/ChatListItem";
export default function ChatListPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { chats, loading, fetchChats } = useChats();
const { isAuthenticated } = useAuthContext();
const { updateFromChats, unreadMap } = useUnread();
const { unreadMap } = useUnread();
const { deleteChat } = useDeleteChat();
useEffect(() => {
@@ -23,98 +23,52 @@ export default function ChatListPage() {
}
}, [isAuthenticated, fetchChats]);
useEffect(() => {
if (chats.length > 0) {
updateFromChats(chats);
}
}, [chats, updateFromChats]);
const handleDeleteChat = useCallback(async (chatId) => {
if (!window.confirm(t('chat.confirmDeleteChat'))) return;
const ok = await deleteChat(chatId);
if (ok) fetchChats();
}, [t, deleteChat, fetchChats]);
const goBack = () => navigate("/feed");
const openChat = (chatId) => navigate(`/chats/${chatId}`);
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">{t('chat.chats')}</div>
<div />
<div className="f-app">
<div className="chat-page">
<ChatHeader title={t('chat.chats')} onBack={goBack} />
<div className="chat-empty-full">{t('chat.loginToSee')}</div>
</div>
<div className="chat-empty-full">{t('chat.loginToSee')}</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">{t('chat.chats')}</div>
<div />
</div>
<div className="f-app">
<div className="chat-page">
<ChatHeader title={t('chat.chats')} onBack={goBack} />
<div className="chat-list">
{loading && (
<div className="chat-empty-full">{t('chat.loading')}</div>
)}
<div className="chat-list">
{loading && (
<div className="chat-empty-full">{t('chat.loading')}</div>
)}
{!loading && chats.length === 0 && (
<div className="chat-empty-full">{t('chat.noChats')}</div>
)}
{!loading && chats.length === 0 && (
<div className="chat-empty-full">{t('chat.noChats')}</div>
)}
{!loading && chats.map((chat) => {
const post = getPostForChat(chat.id);
const title = post?.title || chat.otherUser?.name || t('chat.user');
const imgSrc = getImageUrl(post?.images?.[0]);
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 || t('chat.noMessages')}
</div>
</div>
{unreadCount > 0 && (
<div className="chat-list-unread-badge">{unreadCount > 99 ? "99+" : unreadCount}</div>
)}
<button
className="chat-list-delete-btn"
title={t('chat.deleteChat')}
onClick={(e) => {
e.stopPropagation();
handleDeleteChat(chat.id);
}}
>
<TrashIcon />
</button>
</div>
);
})}
{!loading && chats.map((chat) => (
<ChatListItem
key={chat.id}
chat={chat}
post={getPostForChat(chat.id)}
unreadMap={unreadMap}
onOpen={openChat}
onDelete={handleDeleteChat}
/>
))}
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -1,14 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from 'react-i18next';
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 { useAuthContext } from "../context/AuthContext";
import { getImageUrl } from "../config/authConstants";
import Picker from "@emoji-mart/react";
import emojiData from "@emoji-mart/data";
import { useTranslation } from 'react-i18next';
import ChatHeader from "../components/chat/ChatHeader";
import MessageBubble from "../components/chat/MessageBubble";
import ChatInputBar from "../components/chat/ChatInputBar";
import Toast from "../components/Toast";
export default function ChatPage() {
const { t } = useTranslation();
@@ -16,135 +19,125 @@ export default function ChatPage() {
const navigate = useNavigate();
const { fetchMessages, markAsRead } = useChats();
const { clearChatUnread } = useUnread();
const { user } = useAuthContext();
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [showEmoji, setShowEmoji] = useState(false);
const [loadError, setLoadError] = useState("");
const [sendError, setSendError] = useState("");
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 = user?.id;
/** Мержит новое сообщение по id — защита от дублей (REST + WS эхо). */
const addMessage = useCallback((msg) => {
setMessages((prev) => {
if (msg.id != null && prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
}, []);
const myUserId = (() => {
try { return JSON.parse(localStorage.getItem("user"))?.id; }
catch { return null; }
})();
const handleIncoming = useCallback((msg) => {
setMessages((prev) => [...prev, msg]);
}, []);
addMessage(msg);
}, [addMessage]);
const { sendMessage, connected } = useChatSocket(chatId, handleIncoming);
useEffect(() => {
if (chatId) {
setActiveChatId(chatId);
fetchMessages(chatId).then(setMessages);
markAsRead(chatId);
clearChatUnread(chatId);
}
if (!chatId) return;
setActiveChatId(chatId);
setLoadError("");
fetchMessages(chatId)
.then((list) => {
// Мержим историю с уже пришедшими по сокету сообщениями,
// а не затираем их — иначе можно потерять сообщение, пришедшее
// по WS, пока REST-запрос ещё letел.
setMessages((prev) => {
const byId = new Map(list.map((m) => [m.id, m]));
const extra = prev.filter((m) => !byId.has(m.id));
return [...list, ...extra];
});
})
.catch(() => setLoadError(t('chat.loadError')));
markAsRead(chatId);
clearChatUnread(chatId);
return () => setActiveChatId(null);
}, [chatId, fetchMessages, markAsRead, clearChatUnread]);
}, [chatId, fetchMessages, markAsRead, clearChatUnread, t]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = (e) => {
const handleSend = useCallback((e) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage(input);
setInput("");
};
const trimmed = input.trim();
if (!trimmed) return;
const handleEmojiSelect = (emoji) => {
const sent = sendMessage(trimmed);
if (!sent) {
setSendError(t('chat.sendError'));
return;
}
setInput("");
}, [input, sendMessage, t]);
const handleEmojiSelect = useCallback((emoji) => {
setInput((prev) => prev + emoji.native);
};
}, []);
const post = getPostForChat(chatId);
const postImg = getImageUrl(post?.images?.[0]);
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 className="f-app">
<div className="chat-page">
<ChatHeader
title={post?.title || t('chat.title')}
onBack={() => navigate("/chats")}
avatar={postImg ? (
<img src={postImg} alt="" className="chat-header-avatar-img" />
) : (
post?.emoji || "📋"
)}
statusLabel={connected ? t('chat.online') : t('chat.offline')}
statusConnected={connected}
/>
<div className="chat-messages">
{loadError && <div className="chat-empty">{loadError}</div>}
{!loadError && messages.length === 0 && (
<div className="chat-empty">{t('chat.noMessages')}</div>
)}
{messages.map((m) => (
<MessageBubble key={m.id} message={m} isMine={m.senderId === myUserId} />
))}
<div ref={messagesEndRef} />
</div>
<div className="chat-header-title">{post?.title || t('chat.title')}</div>
<div className={`chat-status${connected ? " connected" : ""}`}>
{connected ? t('chat.online') : t('chat.offline')}
</div>
</div>
<div className="chat-messages">
{messages.length === 0 && (
<div className="chat-empty">{t('chat.noMessages')}</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)}
<ChatInputBar
input={input}
onInputChange={setInput}
onSend={handleSend}
onEmojiSelect={handleEmojiSelect}
showEmoji={showEmoji}
onToggleEmoji={() => setShowEmoji((v) => !v)}
onCloseEmoji={() => setShowEmoji(false)}
connected={connected}
placeholder={t('chat.messagePlaceholder')}
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>
{sendError && (
<Toast
message={sendError}
type="error"
onDismiss={() => setSendError("")}
/>
)}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,395 @@
import { useCallback, useEffect, useState } from "react";
import CloseIcon from "../components/icons/CloseIcon";
import RulesModal from "../components/RulesModal";
import { EMPTY_FORM, CATEGORY_KEYS } from "../config/feedConstants";
import { useTranslation } from 'react-i18next';
import { usePostMutation } from "../hooks/usePostMutation";
import { useImageUpload, ACCEPT, MAX_SIZE_MB } from "../hooks/useImageUpload";
import { validatePostForm } from "../utils/validatePostForm";
import { formatPhone } from "../utils/formatPhone";
import { useYandexMap } from "../hooks/useYandexMap";
import { isMobileDevice } from "../utils/isMobileDevice";
/**
* CreateUpdatePost — модальная форма создания и редактирования объявления.
*
* @param {function} onClose — закрыть модалку без сохранения
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
* @param {object} [initialData] — если передан, форма работает в режиме редактирования
*/
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
const isEdit = !!initialData;
const { t } = useTranslation();
const [form, setForm] = useState(
isEdit
? {
type: (initialData.type || "loss").toLowerCase(),
title: initialData.title || "",
description: initialData.description || "",
category: initialData.category || "",
date: initialData.date || "",
phone: initialData.phone || "",
address: initialData.city || "",
rulesAccepted: true,
reward: initialData.reward ?? false
}
: EMPTY_FORM
);
const [rulesModalOpen, setRulesModalOpen] = useState(false);
const [errors, setErrors] = useState({});
const {
images: uploadedImages,
imgError,
compressing,
fileInputRef,
handleFileChange,
removeImage,
getFilesForUpload,
getExistingUrlsToKeep,
} = useImageUpload(isEdit ? initialData.images : []);
const { save, loading: saving, error: apiError } = usePostMutation();
const [resultModal, setResultModal] = useState(null); // null | "success" | "blocked"
const initialCoords = isEdit && initialData.latitude && initialData.longitude
? { latitude: initialData.latitude, longitude: initialData.longitude }
: null;
const { mapRef, coords, address, addressRef } = useYandexMap(
true,
initialCoords,
isEdit ? (initialData.location || "") : ""
);
useEffect(() => {
if (address) {
setForm(prev => ({ ...prev, address }));
// Адрес пришёл — очищаем и ошибку поля, и "карта грузится",
// иначе последняя может остаться висеть на экране навсегда.
setErrors(prev => ({ ...prev, address: "", map: "" }));
}
}, [address]);
const loading = saving || compressing;
/** Обновляет одно поле формы и сбрасывает его ошибку валидации. */
const set = useCallback((key, value) => {
setForm(prev => ({ ...prev, [key]: value }));
setErrors(prev => ({ ...prev, [key]: "" }));
}, []);
/** Собирает FormData и отправляет запрос на создание или обновление. */
const handleSubmit = async () => {
const validationErrors = validatePostForm(form);
if (Object.keys(validationErrors).length) {
setErrors(validationErrors);
return;
}
if (coords && !addressRef.current) {
setErrors(prev => ({ ...prev, map: t('post.mapLoading') }));
return;
}
const data = new FormData();
data.append("type", form.type.toUpperCase());
data.append("title", form.title.trim());
data.append("description", form.description.trim());
data.append("category", form.category);
data.append("phone", form.phone.trim());
data.append("reward", form.reward ? "true" : "false");
if (form.date) data.append("date", form.date);
const files = getFilesForUpload();
files.forEach(f => data.append("images", f));
if (isEdit) {
const existing = getExistingUrlsToKeep();
if (existing.length > 0) {
data.append("existingImages", JSON.stringify(existing));
}
}
if (coords) {
data.append("latitude", coords.latitude);
data.append("longitude", coords.longitude);
data.append("address", form.address.trim());
}
if (!coords && form.address.trim()) {
data.append("address", form.address.trim());
}
data.append("rulesAccepted", form.rulesAccepted ? "true" : "false");
const result = await save(isEdit ? initialData.id : null, data);
if (!result) return;
if (result.blocked) {
setResultModal("blocked");
return;
}
if (result.data) {
setResultModal("success");
}
};
return (
<>
<div
className="f-modal-bg"
onClick={e => e.target === e.currentTarget && !loading && onClose()}
>
<div className="f-modal">
<div className="f-modal-head">
<span className="f-modal-title">
{isEdit ? t('post.editTitle') : t('post.newTitle')}
</span>
<button className="f-close-btn" type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
{/* Тип: Потеря / Находка */}
<div className="f-m-group">
<label className="f-m-label">{t('post.typeLabel')}</label>
<div className="f-type-toggle">
<button
type="button"
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
onClick={() => set("type", "loss")}
>
{t('post.loss')}
</button>
<button
type="button"
className={`f-type-opt${form.type === "found" ? " found" : ""}`}
onClick={() => set("type", "found")}
>
{t('post.found')}
</button>
</div>
</div>
{/* Название */}
<div className="f-m-group">
<label className="f-m-label">{t('post.titleLabel')}</label>
<input
className={`f-m-field${errors.title ? " err" : ""}`}
type="text"
placeholder={t('post.titlePlaceholder')}
value={form.title}
onChange={e => set("title", e.target.value)}
/>
{errors.title && <p className="f-err-msg">{errors.title}</p>}
</div>
{/* Описание */}
<div className="f-m-group">
<label className="f-m-label">{t('post.descriptionLabel')}</label>
<textarea
className={`f-m-field${errors.description ? " err" : ""}`}
rows={3}
placeholder={t('post.descriptionPlaceholder')}
value={form.description}
onChange={e => set("description", e.target.value)}
/>
{errors.description && <p className="f-err-msg">{errors.description}</p>}
</div>
{/* Фотографии */}
<div className="f-m-group">
<label className="f-m-label">{t('post.photosLabel')}</label>
{uploadedImages.length > 0 && (
<div className="f-img-grid">
{uploadedImages.map(img => (
<div key={img.id} className="f-img-grid-item">
<img src={img.preview} alt="" className="f-img-grid-preview" />
<button
className="f-img-remove"
onClick={() => removeImage(img.id)}
type="button"
aria-label={t('post.deletePhoto')}
>
<CloseIcon />
</button>
</div>
))}
</div>
)}
<label className="f-img-upload-label">
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
multiple
capture={isMobileDevice() ? "environment" : undefined}
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? (
<span>{t('post.processing')}</span>
) : (
<span>{uploadedImages.length > 0 ? t('post.addMore') : t('post.addPhoto')}</span>
)}
</label>
{imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">{t('post.photoHint', { maxSize: MAX_SIZE_MB })}</p>
</div>
{/* Категория */}
<div className="f-m-row">
<div className="f-m-group">
<label className="f-m-label">{t('post.categoryLabel')}</label>
<select
className={`f-m-field${errors.category ? " err" : ""}`}
value={form.category}
onChange={e => set("category", e.target.value)}
>
<option value="">{t('categories.select')}</option>
{CATEGORY_KEYS.map(k => <option key={k} value={t(k)}>{t(k)}</option>)}
</select>
{errors.category && <p className="f-err-msg">{errors.category}</p>}
</div>
</div>
{/* Карта */}
<div className="f-m-group">
<label className="f-m-label">{t('post.mapLabel')}</label>
<div className="f-map-frame">
<div ref={mapRef} className="f-map-inner" />
</div>
{!coords && <p className="f-hint">{t('post.mapHint')}</p>}
{errors.map && <p className="f-err-msg">{errors.map}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">{t('post.addressLabel')}</label>
<input
className={`f-m-field${errors.address ? " err" : ""}`}
type="text"
placeholder={t('post.addressPlaceholder')}
value={form.address}
onChange={e => set("address", e.target.value)}
/>
{errors.address && <p className="f-err-msg">{errors.address}</p>}
</div>
{/* Телефон */}
<div className="f-m-group">
<label className="f-m-label">{t('post.phoneLabel')}</label>
<input
className={`f-m-field${errors.phone ? " err" : ""}`}
type="tel"
placeholder={t('post.phonePlaceholder')}
value={form.phone}
onChange={e => set("phone", formatPhone(e.target.value))}
/>
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
</div>
{/* Вознаграждение */}
<div className="f-m-group">
<label className="f-reward-checkbox">
<input
type="checkbox"
checked={form.reward}
onChange={e => set("reward", e.target.checked)}
/>
<span className="f-reward-checkmark"/>
<span className="f-reward-text">
{t("post.rewardCheckbox")}
</span>
</label>
</div>
{/* Чекбокс согласия с правилами */}
<div className="f-m-group">
<label className="f-rules-checkbox">
<input
type="checkbox"
className="f-rules-input"
checked={form.rulesAccepted}
onChange={e => set("rulesAccepted", e.target.checked)}
/>
<span className="f-rules-checkmark" />
<span className="f-rules-text">
{t('post.rulesCheckboxPrefix')}{' '}
<span
className="f-rules-link"
onClick={e => {
e.preventDefault();
setRulesModalOpen(true);
}}
>
{t('post.rulesLinkText')}
</span>
</span>
</label>
{errors.rulesAccepted && <p className="f-err-msg">{errors.rulesAccepted}</p>}
</div>
{apiError && <p className="f-err-msg">{apiError}</p>}
<button className="f-m-submit" type="button" onClick={handleSubmit} disabled={loading}>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner" />}
{isEdit ? t('post.saveChanges') : t('post.publish')}
</div>
</button>
</div>
</div>
{rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} />
)}
{resultModal && (
<div
className="f-modal-bg"
onClick={e => {
if (e.target === e.currentTarget) {
if (resultModal === "success") onSuccess?.();
onClose();
}
}}
>
<div className="f-modal f-modal-result">
<div className={`f-result-icon ${resultModal === "blocked" ? "blocked" : "success"}`}>
{resultModal === "blocked" ? "✕" : "✓"}
</div>
<h3 className="f-result-title">
{resultModal === "blocked" ? t('post.blockedTitle') : t('post.successTitle')}
</h3>
<p className="f-result-text">
{resultModal === "blocked" ? t('post.blockedText') : t('post.successText')}
</p>
<button
className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`}
type="button"
onClick={() => {
if (resultModal === "success") onSuccess?.();
onClose();
}}
>
{t('post.resultClose')}
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -1,147 +1,90 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "../styles/index.css";
import { API_URL, getImageUrl } from "../config/authConstants";
import { getImageUrl } from "../config/authConstants";
import Sidebar from "../components/Sidebar";
import RightSidebar from "../components/RightSidebar";
import PostOnFeed from "../components/PostOnFeed";
import CreateUpdatePost from "../components/CreateUpdatePost";
import CreateUpdatePost from "./CreateUpdatePost";
import ImageViewer from "../components/ImageViewer";
import CityFilter from "../components/CityFilter";
import MenuIcon from "../components/icons/MenuIcon";
import ProfileAvatar from "../components/profile/ProfileAvatar";
import { ProfileModal } from "../components/profile";
import AuthModal from "../components/AuthModal";
import RulesModal from "../components/RulesModal";
import FeedTopBar from "../components/feed/FeedTopBar";
import FeedSearchBar from "../components/feed/FeedSearchBar";
import TypeFilterBar from "../components/feed/TypeFilterBar";
import FeedContent from "../components/feed/FeedContent";
import { useAuthContext } from "../context/AuthContext";
import { useDeletePost } from "../hooks/useDeletePost";
import { useFavorites } from "../hooks/useFavorites";
import { usePostStatus } from "../hooks/usePostStatus";
import { useCities } from "../hooks/useCities";
import { useSearchPosts } from "../hooks/useSearchPosts";
import { useFeedPosts } from "../hooks/useFeedPosts";
import { useMyPosts } from "../hooks/useMyPosts";
import { useCityFeed } from "../hooks/useCityFeed";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
import { useModals } from "../hooks/useModals";
import { useFeedActions } from "../hooks/useFeedActions";
import { useFavorites } from "../hooks/useFavorites";
import { useTranslation } from 'react-i18next';
/**
* MainFeed — корневой компонент приложения.
* Управляет состоянием ленты, модалок и сайдбара;
* остальные компоненты получают данные через пропсы.
* Композиция хуков состояния + презентационных компонентов ленты.
*/
export default function MainFeed() {
const navigate = useNavigate();
const location = useLocation();
const { user, token, isAuthenticated } = useAuthContext();
const { deletePost } = useDeletePost();
const { toggleFavorite, loadFavorites } = useFavorites();
const { markAsFound } = usePostStatus();
const { cities } = useCities();
const { loading: cityLoading, searchPosts } = useSearchPosts();
const { loadFavorites } = useFavorites();
const { t } = useTranslation();
const modals = useModals();
// Открыт ли левый сайдбар (мобильный слайд-аут)
const [sidebarOpen, setSidebarOpen] = useState(false);
// Активный пункт навигации в сайдбаре
const [activeNav, setActiveNav] = useState("home");
// Фильтр по типу объявления: null (все) | "loss" | "found"
const [typeFilter, setTypeFilter] = useState(null);
// Выбранные города для фильтра: [] (все) | ["Город", ...]
const [activeNav, setActiveNav] = useState("home");
const [typeFilter, setTypeFilter] = useState(null);
const [selectedCities, setSelectedCities] = useState([]);
// Результаты поиска по выбранным городам (бэк /posts/search)
const [cityAds, setCityAds] = useState([]);
// Поисковый запрос (фильтр по title / description)
const [searchQuery, setSearchQuery] = useState("");
// Открыта ли модалка создания нового объявления
const [modalOpen, setModalOpen] = useState(false);
// Объявление, которое сейчас редактируется (null — модалка закрыта)
const [editAd, setEditAd] = useState(null);
// Открыто ли модальное окно редактирования профиля
const [profileModalOpen, setProfileModalOpen] = useState(false);
// Открыто ли модальное окно аутентификации (login / register)
const [authModalOpen, setAuthModalOpen] = useState(false);
// Открыто ли модальное окно правил площадки
const [rulesModalOpen, setRulesModalOpen] = useState(false);
// Просмотр изображений: null — закрыто, { images, index } — открыто
const [viewerData, setViewerData] = useState(null);
// Массив объявлений из API (основная лента)
const [ads, setAds] = useState([]);
// Объявления текущего пользователя (загружаются отдельно по userId)
const [myAds, setMyAds] = useState([]);
// Set id объявлений, которые пользователь лайкнул (только локально)
const [liked, setLiked] = useState(new Set());
// Состояния загрузки и ошибки для ленты
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Пагинация ленты
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [liked, setLiked] = useState(new Set());
const PAGE_SIZE = 20;
const feed = useFeedPosts();
const myPosts = useMyPosts(user?.id);
const cityFeed = useCityFeed(selectedCities);
// Данные профиля — из контекста авторизации (или пустой объект для гостя)
const profileData = useMemo(() => {
if (!user) return {};
// Если бэк присылает имя одним полем "name" — разбиваем на firstName/lastName
// Если бэк присылает отдельные поля firstName/lastName — используем их напрямую
const nameParts = (user.name || "").trim().split(/\s+/);
const firstName = user.firstName || nameParts[0] || "";
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
const avatar = getImageUrl(user.avatar);
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
return {
login: user.login || "",
login: user.login || "",
firstName,
lastName,
email: user.email || "",
phone: user.phone || "",
avatar,
id: user.id,
email: user.email || "",
phone: user.phone || "",
avatar: getImageUrl(user.avatar),
id: user.id,
};
}, [user]);
/** Загружает ленту объявлений с бэкенда. */
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
if (pageNum === 0) {
setLoading(true);
const refreshCurrentList = useCallback(() => {
if (selectedCities.length > 0) {
cityFeed.searchCities(selectedCities); // triggers internal effect via selectedCities dep isn't needed here
} else {
setLoadingMore(true);
feed.loadFeed();
}
setError("");
try {
const params = new URLSearchParams({
page: pageNum,
size: PAGE_SIZE,
});
if (search) params.set("search", search);
}, [selectedCities, cityFeed, feed]);
const res = await fetch(`${API_URL}/api/v1/posts?${params}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const content = data.content ?? [];
const normalizedAds = content.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setAds(prev => append ? [...prev, ...normalizedAds] : normalizedAds);
setHasMore(!data.last); // data.last — стандартное поле Spring Page
setPage(pageNum);
} catch (err) {
setError(err.message || "Failed to load ads");
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
const actions = useFeedActions({
refresh: refreshCurrentList,
loadMyPosts: myPosts.loadMyPosts,
onAuthRequired: () => modals.open("auth"),
isAuthenticated,
t,
});
// Загружаем лайки пользователя при авторизации
useEffect(() => {
if (isAuthenticated && token) {
loadFavorites(token).then(favoriteIds => {
setLiked(new Set(favoriteIds));
});
loadFavorites().then(favoriteIds => setLiked(new Set(favoriteIds)));
} else {
setLiked(new Set());
}
@@ -150,454 +93,166 @@ export default function MainFeed() {
// Открываем модалку аутентификации если пришли с лендинга
useEffect(() => {
if (location.state?.openAuth) {
setAuthModalOpen(true);
modals.open("auth");
window.history.replaceState({}, document.title);
}
}, [location.state]);
}, [location.state, modals]);
// Первичная загрузка при монтировании компонента.
// При активном фильтре по городам ленту не грузим — показываем /posts/search
// Первичная загрузка ленты (дебаунс поиска), только когда нет фильтра по городам
useEffect(() => {
if (selectedCities.length > 0) return;
const timer = setTimeout(() => {
loadFeed(0, searchQuery, false);
feed.loadFeed(0, searchQuery, false);
}, 400);
return () => clearTimeout(timer);
}, [searchQuery, loadFeed, selectedCities]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedCities]);
/** Загружает объявления текущего пользователя по userId с бэкенда. */
const loadMyPosts = useCallback(async () => {
if (!user?.id) return;
setLoading(true);
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/posts/${user.id}`);
if (res.status === 404) {
// У пользователя пока нет объявлений — это нормальная ситуация
setMyAds([]);
return;
}
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const posts = Array.isArray(data) ? data : (data.items ?? []);
const normalizedPosts = posts.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setMyAds(normalizedPosts);
} catch (err) {
setError(err.message || "Failed to load your ads");
} finally {
setLoading(false);
}
}, [user?.id]);
/** Ищет объявления сразу по нескольким городам и объединяет результаты (без дублей). */
const searchCities = useCallback((citiesToSearch) => {
if (!citiesToSearch.length) return Promise.resolve([]);
return Promise.all(citiesToSearch.map(c => searchPosts(c)))
.then(results => {
const seen = new Set();
const merged = [];
results.flat().forEach(ad => {
if (!seen.has(ad.id)) {
seen.add(ad.id);
merged.push(ad);
}
});
return merged;
});
}, [searchPosts]);
/** Перезагружает текущий список: городской поиск либо обычную ленту. */
const refreshCurrentList = useCallback(() => {
if (selectedCities.length > 0) {
searchCities(selectedCities).then(result => setCityAds(result));
} else {
loadFeed();
}
}, [selectedCities, searchCities, loadFeed]);
// Поиск объявлений по выбранным городам через /posts/search
useEffect(() => {
if (selectedCities.length === 0) {
setCityAds([]);
return;
}
let cancelled = false;
searchCities(selectedCities).then(result => {
if (!cancelled) setCityAds(result);
});
return () => { cancelled = true; };
}, [selectedCities, searchCities]);
/**
* Переключает лайк на объявлении через API.
*/
const toggleLike = useCallback(async (id) => {
if (!isAuthenticated || !token) {
setAuthModalOpen(true);
return;
}
const success = await toggleFavorite(id, token);
if (success) {
// Перезагружаем ленту для получения обновлённых данных
refreshCurrentList();
if (activeNav === "myads") {
loadMyPosts();
}
}
}, [isAuthenticated, token, toggleFavorite, refreshCurrentList, loadMyPosts, activeNav]);
/** Удаляет объявление и перезагружает ленту */
const handleDelete = useCallback(async (postId) => {
if (!window.confirm(t('feed.confirmDelete'))) {
return;
}
const success = await deletePost(postId);
if (success) {
refreshCurrentList();
loadMyPosts();
}
}, [t, deletePost, refreshCurrentList, loadMyPosts]);
/** Отмечает объявление как "Вещь найдена" и перезагружает ленту */
const handleMarkFound = useCallback(async (postId) => {
if (!window.confirm(t('feed.confirmMarkFound'))) {
return;
}
const success = await markAsFound(postId, token);
if (success) {
refreshCurrentList();
loadMyPosts();
}
}, [t, markAsFound, token, refreshCurrentList, loadMyPosts]);
// После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
const handleEditSave = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) {
setViewerData({ images, index, ad });
}
}, []);
/**
* Обработчик нажатия «Разместить объявление».
* Если пользователь не авторизован — открывает модалку входа.
*/
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setModalOpen(true);
}
}, [isAuthenticated]);
/**
* Обработчик редактирования — разрешён только автору объявления.
*/
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setEditAd(ad);
}
}, [isAuthenticated]);
// Сентинел для бесконечного скролла — подгружает следующую страницу
const observerRef = useRef(null);
const sentinelRef = useCallback(node => {
if (loadingMore) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadFeed(page + 1, searchQuery, true);
}
}, { rootMargin: "200px" });
if (node) observerRef.current.observe(node);
}, [loadingMore, hasMore, loading, page, searchQuery, loadFeed]);
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
useEffect(() => {
if (activeNav === "myads" && isAuthenticated && user?.id) {
loadMyPosts();
myPosts.loadMyPosts();
}
}, [activeNav, isAuthenticated, user?.id, loadMyPosts]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNav, isAuthenticated, user?.id]);
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
const filteredAds = (() => {
// База: мои объявления / результаты поиска по городу / обычная лента
const sentinelRef = useInfiniteScroll(
() => feed.loadFeed(feed.page + 1, searchQuery, true),
activeNav === "home" && selectedCities.length === 0 && feed.hasMore && !feed.loading && !feed.loadingMore
);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) modals.open("viewer", { images, index, ad });
}, [modals]);
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) modals.open("auth");
else modals.open("create");
}, [isAuthenticated, modals]);
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) modals.open("auth");
else modals.open("edit", ad);
}, [isAuthenticated, modals]);
const filteredAds = useMemo(() => {
let result;
if (activeNav === "myads") {
// Используем объявления, загруженные с бэкенда по userId
result = myAds;
result = myPosts.myAds;
} else if (selectedCities.length > 0) {
// Выбраны города — используем результаты /posts/search
result = cityAds;
result = cityFeed.cityAds;
} else {
result = ads;
result = feed.ads;
}
// Фильтр по категории из навигации (Вещи / Питомцы)
if (activeNav === "items") {
result = result.filter(ad =>
["Вещь", "Другое"].includes(ad.category)
);
result = result.filter(ad => ["Вещь", "Другое"].includes(ad.category));
} else if (activeNav === "pets") {
result = result.filter(ad => ad.category === "Питомец");
}
// Применяем фильтр по типу объявления
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
return result;
})();
}, [activeNav, myPosts.myAds, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
/**
* Рендерит содержимое ленты в зависимости от текущего состояния:
* — спиннер во время загрузки
* — сообщение об ошибке
* — заглушка при пустой ленте
* — список карточек объявлений
*/
const renderContent = () => {
if (loading || (selectedCities.length > 0 && cityLoading)) {
return (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
{t('feed.loading')}
</div>
</div>
);
}
if (error) {
return (
<div className="f-center">
<div className="f-center-icon">⚠️</div>
<div className="f-center-title">{t('feed.loadError')}</div>
<div className="f-center-sub">{error}</div>
</div>
);
}
if (!filteredAds.length) {
const emptyMsg = activeNav === "myads"
? { title: t('feed.emptyMyAdsTitle'), sub: t('feed.emptyMyAdsSub') }
: selectedCities.length > 0
? { title: t('feed.emptyCityTitle'), sub: t('feed.emptyCitySub') }
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
return (
<div className="f-center">
<div className="f-center-icon">🔍</div>
<div className="f-center-title">{emptyMsg.title}</div>
<div className="f-center-sub"
dangerouslySetInnerHTML={{ __html: emptyMsg.sub }}
/>
</div>
);
}
return filteredAds.map(ad => (
<PostOnFeed
key={ad.id}
ad={ad}
liked={liked.has(ad.id)}
onLike={toggleLike}
onEdit={activeNav === "myads" ? handleEdit : undefined}
onDelete={activeNav === "myads" ? handleDelete : undefined}
onMarkFound={activeNav === "myads" ? handleMarkFound : undefined}
isOwner={activeNav === "myads"}
onImageClick={handleImageClick}
onAuthRequired={() => setAuthModalOpen(true)}
/>
));
};
const listLoading = activeNav === "myads" ? myPosts.loading : feed.loading;
const listError = activeNav === "myads" ? myPosts.error : feed.error;
return (
<div className="f-app">
{/* Левый сайдбар с навигацией */}
<Sidebar
open={sidebarOpen}
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
onNavClick={setActiveNav}
isAuthenticated={isAuthenticated}
onLoginClick={() => setAuthModalOpen(true)}
onRulesClick={() => setRulesModalOpen(true)}
onLoginClick={() => modals.open("auth")}
onRulesClick={() => modals.open("rules")}
onReviewsClick={() => navigate("/reviews")}
/>
<div className="f-main">
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
<div className="f-topbar">
<button className="f-menu-btn"
onClick={() => setSidebarOpen(v => !v)}>
<MenuIcon/>
</button>
<div className="f-topbar-center">
<button className="f-post-btn" onClick={handleCreatePost}>
<span style={{
fontSize: "1.15rem",
fontWeight: 900,
lineHeight: 1
}}>+</span>
{t('feed.createPost')}
</button>
</div>
{/* Иконка профиля в правом углу */}
{isAuthenticated ? (
<div className="f-topbar-profile"
onClick={() => setProfileModalOpen(true)}>
<ProfileAvatar avatar={profileData.avatar}
name={`${profileData.firstName
|| ""} ${profileData.lastName || ""}`}/>
</div>
) : (
<button className="f-login-btn"
onClick={() => setAuthModalOpen(true)}>
{t('feed.login')}
</button>
)}
</div>
<FeedTopBar
onMenuClick={() => setSidebarOpen(v => !v)}
onCreatePost={handleCreatePost}
isAuthenticated={isAuthenticated}
profileData={profileData}
onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")}
t={t}
/>
{/* Строка поиска */}
<div className="f-search-bar">
<svg className="f-search-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input
className="f-search-input"
type="text"
placeholder={t('feed.searchPlaceholder')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button className="f-search-clear"
onClick={() => setSearchQuery("")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" width="18" height="18">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
)}
</div>
<FeedSearchBar value={searchQuery} onChange={setSearchQuery} t={t} />
{/* Фильтр по городам */}
{activeNav === "home" && (
<div className="f-city-filter-wrap">
<CityFilter
cities={cities}
value={selectedCities}
onChange={setSelectedCities}
/>
<CityFilter cities={cities} value={selectedCities} onChange={setSelectedCities} />
</div>
)}
{/* Фильтр по типу объявления */}
<div className="f-filter-bar">
<button
className={`f-filter-btn${typeFilter === "loss" ? " active"
: ""}`}
onClick={() => setTypeFilter("loss")}
>
{t('feed.filterLoss')}
</button>
<button
className={`f-filter-btn${typeFilter === null ? " active"
: ""}`}
onClick={() => setTypeFilter(null)}
>
{t('feed.filterAll')}
</button>
<button
className={`f-filter-btn${typeFilter === "found" ? " active"
: ""}`}
onClick={() => setTypeFilter("found")}
>
{t('feed.filterFound')}
</button>
</div>
<TypeFilterBar value={typeFilter} onChange={setTypeFilter} t={t} />
{/* Основная область: лента + правый сайдбар */}
<div className="f-wrap">
<div className="f-feed">
{renderContent()}
{activeNav === "home" && selectedCities.length === 0 && !loading && (
<div ref={sentinelRef} style={{height: "1px"}}/>
<FeedContent
loading={listLoading}
cityLoading={cityFeed.cityLoading}
citySearchActive={selectedCities.length > 0}
error={listError}
filteredAds={filteredAds}
activeNav={activeNav}
liked={liked}
onLike={actions.toggleLike}
onEdit={handleEdit}
onDelete={actions.handleDelete}
onMarkFound={actions.handleMarkFound}
onImageClick={handleImageClick}
onAuthRequired={() => modals.open("auth")}
t={t}
/>
{activeNav === "home" && selectedCities.length === 0 && !feed.loading && (
<div ref={sentinelRef} style={{ height: "1px" }} />
)}
{loadingMore && (
<div className="f-center" style={{padding: "1rem"}}>
<div className="f-spinner"/>
{feed.loadingMore && (
<div className="f-center" style={{ padding: "1rem" }}>
<div className="f-spinner" />
</div>
)}
</div>
{/* Правый сайдбар: промо-блоки / фильтры */}
<RightSidebar/>
<RightSidebar />
</div>
</div>
{/* Модалка создания нового объявления (только для авторизованных) */}
{modalOpen && isAuthenticated && (
{modals.isOpen("create") && isAuthenticated && (
<CreateUpdatePost onClose={modals.close} onSuccess={actions.handleNewAd} />
)}
{modals.isOpen("edit") && isAuthenticated && (
<CreateUpdatePost
onClose={() => setModalOpen(false)}
onSuccess={handleNewAd}
initialData={modals.modal.payload}
onClose={modals.close}
onSuccess={actions.handleEditSave}
/>
)}
{/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && isAuthenticated && (
<CreateUpdatePost
initialData={editAd}
onClose={() => setEditAd(null)}
onSuccess={handleEditSave}
/>
{modals.isOpen("profile") && isAuthenticated && (
<ProfileModal onClose={modals.close} initialData={profileData} />
)}
{/* Модальное окно редактирования профиля */}
{profileModalOpen && isAuthenticated && (
<ProfileModal
onClose={() => setProfileModalOpen(false)}
initialData={profileData}
/>
)}
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
{/* Модальное окно входа / регистрации */}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
/>
)}
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
{/* Модальное окно правил площадки */}
{rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} />
)}
{/* Просмотр изображений */}
{viewerData && (
{modals.isOpen("viewer") && (
<ImageViewer
images={viewerData.images}
initialIndex={viewerData.index}
onClose={() => setViewerData(null)}
postData={viewerData.ad}
images={modals.modal.payload.images}
initialIndex={modals.modal.payload.index}
onClose={modals.close}
postData={modals.modal.payload.ad}
/>
)}
</div>
);
}
}

View File

@@ -1,74 +1,69 @@
import PinIcon from "./icons/PinIcon";
import CalendarIcon from "./icons/CalendarIcon";
import PhoneIcon from "./icons/PhoneIcon";
import MapIcon from "./icons/MapIcon";
import HeartIcon from "./icons/HeartIcon";
import TrashIcon from "./icons/TrashIcon";
// src/components/PostOnFeed.jsx
import PinIcon from "../components/icons/PinIcon";
import CalendarIcon from "../components/icons/CalendarIcon";
import PhoneIcon from "../components/icons/PhoneIcon";
import MapIcon from "../components/icons/MapIcon";
import HeartIcon from "../components/icons/HeartIcon";
import TrashIcon from "../components/icons/TrashIcon";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useChats } from "../hooks/useChats";
import { useContactSeller } from "../hooks/useContactSeller";
import { useAuthContext } from "../context/AuthContext";
import { getImageUrl } from "../config/authConstants";
import DateCalendarPopup from "./DateCalendarPopup";
import DateCalendarPopup from "../components/DateCalendarPopup";
import Toast from "../components/Toast";
import { useTranslation } from 'react-i18next';
export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onAuthRequired }) {
const { t } = useTranslation();
const [showCalendar, setShowCalendar] = useState(false);
const navigate = useNavigate();
const { createOrGetChat } = useChats();
const { isAuthenticated } = useAuthContext();
const { contactSeller, error: contactError, clearError } = useContactSeller(ad);
const handleContact = async (e) => {
const handleContact = (e) => {
e.stopPropagation();
if (!isAuthenticated) {
if (onAuthRequired) onAuthRequired();
return;
}
try {
const chat = await createOrGetChat(ad.userId, ad.id, {
title: ad.title,
images: ad.images,
emoji: ad.emoji,
});
navigate(`/chats/${chat.id}`);
} catch (err) {
console.error(err);
}
contactSeller();
};
const imageUrl = getImageUrl(ad.images?.[0]);
// ✅ Формируем строку адреса из city + district
const address = [ad.city, ad.district].filter(Boolean).join(", ");
const imageUrl = getImageUrl(ad.images?.[0]);
const address = ad.city;
const createdAt = ad.createdAt
? new Date(ad.createdAt).toLocaleDateString("ru-RU")
: null;
// Проверяем, закрыт ли пост (вещь найдена)
const isClosed = ad.status === 'CLOSED';
const roleClass = ad.userRole === 'LOST_AND_FOUND' ? 'f-card-role-lost-found'
: ad.userRole === 'METRO' ? 'f-card-role-metro' : '';
: ad.userRole === 'METRO' ? 'f-card-role-metro' : '';
const roleBadge = ad.userRole === 'LOST_AND_FOUND' ? (
<div className="f-role-badge f-role-lost-found" title={t('postCard.lostFoundBureau')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
{t('postCard.lostFoundBureau')}
</div>
<div className="f-role-badge f-role-lost-found" title={t('postCard.lostFoundBureau')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
{t('postCard.lostFoundBureau')}
</div>
) : ad.userRole === 'METRO' ? (
<div className="f-role-badge f-role-metro" title={t('postCard.metro')}>
<span className="f-role-metro-letter">М</span>
{t('postCard.metro')}
</div>
<div className="f-role-badge f-role-metro" title={t('postCard.metro')}>
<span className="f-role-metro-letter">М</span>
{t('postCard.metro')}
</div>
) : null;
return (
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
<div className="f-card-inner">
<div className="f-card-img" onClick={e => { e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0, ad); }}>
{ad.reward && (
<div className="f-card-reward">
💰 {t("postCard.reward")}
</div>
)}
{imageUrl
? <img src={imageUrl} alt={ad.title} />
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
@@ -88,20 +83,14 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
</div>
<div className="f-card-desc">{ad.description}</div>
<div className="f-card-meta">
{/* ✅ address вместо ad.location */}
{address && <div className="f-meta-pill"><PinIcon /> {address}</div>}
{createdAt && (
<div className="f-meta-pill f-date-pill" onClick={e => { e.stopPropagation(); setShowCalendar(s => !s); }}>
<CalendarIcon /> {createdAt}
{showCalendar && <DateCalendarPopup date={ad.createdAt} onClose={() => setShowCalendar(false)} />}
</div>
<div className="f-meta-pill f-date-pill" onClick={e => { e.stopPropagation(); setShowCalendar(s => !s); }}>
<CalendarIcon /> {createdAt}
{showCalendar && <DateCalendarPopup date={ad.createdAt} onClose={() => setShowCalendar(false)} />}
</div>
)}
</div>
{address && (
<button className="f-card-map" onClick={e => e.stopPropagation()}>
<MapIcon /> {t('postCard.showOnMap', { address })}
</button>
)}
<div className="f-card-foot">
<div className="f-card-foot-left">
{ad.phone && (
@@ -113,29 +102,29 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
</div>
<div className="f-card-foot-right">
{!isOwner && !isClosed && (
<button className="f-card-contact" onClick={handleContact}>
<button type="button" className="f-card-contact" onClick={handleContact}>
{t('postCard.write')}
</button>
)}
{onEdit && isOwner && !isClosed && (
<button className="f-card-edit-btn"
<button type="button" className="f-card-edit-btn"
onClick={e => { e.stopPropagation(); onEdit(ad); }}>
✏️
</button>
)}
{onDelete && isOwner && (
<button className="f-card-delete-btn"
<button type="button" className="f-card-delete-btn"
onClick={e => { e.stopPropagation(); onDelete(ad.id); }}>
<TrashIcon />
</button>
)}
{onMarkFound && isOwner && !isClosed && (
<button className="f-card-found-btn"
<button type="button" className="f-card-found-btn"
onClick={e => { e.stopPropagation(); onMarkFound(ad.id); }}>
{t('postCard.itemFound')}
</button>
)}
<button className={`f-like-btn${liked ? " liked" : ""}`}
<button type="button" className={`f-like-btn${liked ? " liked" : ""}`}
onClick={e => { e.stopPropagation(); onLike(ad.id); }}>
<HeartIcon filled={liked} />
{ad.likeCount || 0}
@@ -144,6 +133,14 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
</div>
</div>
</div>
{contactError && (
<Toast
message={contactError}
type="error"
onDismiss={clearError}
/>
)}
</div>
);
}
}

View File

@@ -1,347 +1,128 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL, getImageUrl } from "../config/authConstants";
import { getImageUrl } from "../config/authConstants";
import { useAuthContext } from "../context/AuthContext";
import MenuIcon from "../components/icons/MenuIcon";
import Sidebar from "../components/Sidebar";
import ProfileAvatar from "../components/profile/ProfileAvatar";
import AppTopBar from "../components/layout/AppTopBar";
import { ProfileModal } from "../components/profile";
import AuthModal from "../components/AuthModal";
import ReviewCard from "../components/reviews/ReviewCard";
import ReviewForm from "../components/reviews/ReviewForm";
import RatingSummary from "../components/reviews/RatingSummary";
import { useReviews } from "../hooks/useReviews";
import "../styles/reviews.css";
import { useTranslation } from 'react-i18next';
/* ── 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 { t } = useTranslation();
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 || t('reviews.anonymous')}</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 { t } = useTranslation();
const [rating, setRating] = useState(0);
const [text, setText] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (rating === 0) {
setError(t('reviews.ratePrompt'));
return;
}
setError("");
onSubmit({ rating, text: text.trim() });
setRating(0);
setText("");
};
return (
<form className="rv-form" onSubmit={handleSubmit}>
<h3 className="rv-form-title">{t('reviews.leaveReview')}</h3>
<div className="rv-form-rating">
<span className="rv-form-label">{t('reviews.rating')}</span>
<StarRating value={rating} onChange={setRating} size={32} />
</div>
{error && <div className="rv-form-error">{error}</div>}
<textarea
className="rv-form-textarea"
placeholder={t('reviews.reviewPlaceholder')}
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 ? t('reviews.sending') : t('reviews.send')}
</button>
</div>
</form>
);
}
/* ── Main Reviews page ── */
export default function ReviewsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { user, isAuthenticated } = useAuthContext();
const { user, token, isAuthenticated } = useAuthContext();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [activeNav] = useState("reviews");
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const profileData = useMemo(() => {
if (!user) return {};
const nameParts = (user.name || "").trim().split(/\s+/);
return {
login: user.login || "",
firstName: user.firstName || nameParts[0] || "",
lastName: user.lastName || nameParts.slice(1).join(" ") || "",
email: user.email || "",
phone: user.phone || "",
avatar: getImageUrl(user.avatar),
id: user.id,
};
}, [user]);
/* 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: getImageUrl(user.avatar),
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(`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 || "Failed to load reviews");
// Show empty state with mock data hint
setReviews([]);
} finally {
setLoading(false);
}
}, []);
const {
reviews, loading, error, submitting, submitError,
loadReviews, submitReview, avgRating, distribution,
} = useReviews({
token,
isAuthenticated,
onAuthRequired: () => setAuthModalOpen(true),
authorFallbackName: profileData.firstName || t('reviews.you'),
});
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(`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 || t('reviews.you'),
rating,
text,
createdAt: new Date().toISOString(),
},
...prev,
]);
} finally {
setSubmitting(false);
}
}, [t, 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-app">
<Sidebar
open={sidebarOpen}
activeNav="reviews"
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">{t('reviews.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)}>
{t('reviews.login')}
</button>
)}
</div>
<div className="f-main">
<AppTopBar
onMenuClick={() => setSidebarOpen((v) => !v)}
isAuthenticated={isAuthenticated}
profileData={profileData}
onProfileClick={() => setProfileModalOpen(true)}
onLoginClick={() => setAuthModalOpen(true)}
loginLabel={t('reviews.login')}
centerContent={<h1 className="rv-page-title">{t('reviews.title')}</h1>}
/>
<div className="rv-layout">
{/* Left: Summary + form */}
<div className="rv-sidebar">
{/* Summary */}
<div className="rv-summary">
<div className="rv-summary-score">
{avgRating ? (
<div className="rv-layout">
<div className="rv-sidebar">
<RatingSummary
avgRating={avgRating}
totalCount={reviews.length}
distribution={distribution}
/>
{isAuthenticated ? (
<>
<span className="rv-summary-num">{avgRating}</span>
<StarRating value={Math.round(Number(avgRating))} readOnly size={22} />
<ReviewForm onSubmit={submitReview} submitting={submitting} />
{submitError && (
<div className="rv-form-error">{t('reviews.submitError')}</div>
)}
</>
) : (
<span className="rv-summary-none">{t('reviews.noReviews')}</span>
)}
</div>
<span className="rv-summary-total">
{t('reviews.title')} ({reviews.length})
</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 className="rv-login-prompt">
<p>{t('reviews.loginPrompt')}</p>
<button className="rv-login-btn" onClick={() => setAuthModalOpen(true)}>
{t('reviews.login')}
</button>
</div>
)}
</div>
{/* Add review form */}
{isAuthenticated ? (
<ReviewForm onSubmit={handleSubmitReview} submitting={submitting} />
) : (
<div className="rv-login-prompt">
<p>{t('reviews.loginPrompt')}</p>
<button className="rv-login-btn" onClick={() => setAuthModalOpen(true)}>
{t('reviews.login')}
</button>
</div>
)}
</div>
{/* Right: Reviews list */}
<div className="rv-list">
{loading ? (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
{t('reviews.loading')}
</div>
</div>
) : error && reviews.length === 0 ? (
<div className="f-center">
<div className="f-center-icon">💬</div>
<div className="f-center-title">{t('reviews.emptyTitle')}</div>
<div className="f-center-sub">{t('reviews.emptySub')}</div>
</div>
) : (
reviews.map((review) => <ReviewCard key={review.id} review={review} />)
)}
<div className="rv-list">
{loading ? (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
{t('reviews.loading')}
</div>
</div>
) : error && reviews.length === 0 ? (
<div className="f-center">
<div className="f-center-icon">💬</div>
<div className="f-center-title">{t('reviews.emptyTitle')}</div>
<div className="f-center-sub">{t('reviews.emptySub')}</div>
</div>
) : (
reviews.map((review) => <ReviewCard key={review.id} review={review} />)
)}
</div>
</div>
</div>
{profileModalOpen && isAuthenticated && (
<ProfileModal onClose={() => setProfileModalOpen(false)} initialData={profileData} />
)}
{authModalOpen && <AuthModal onClose={() => setAuthModalOpen(false)} />}
</div>
{/* Profile modal */}
{profileModalOpen && isAuthenticated && (
<ProfileModal onClose={() => setProfileModalOpen(false)} initialData={profileData} />
)}
{/* Auth modal */}
{authModalOpen && (
<AuthModal onClose={() => setAuthModalOpen(false)} />
)}
</div>
);
}
}

View File

@@ -361,3 +361,13 @@
animation: none !important;
}
}
.nk-cta-primary .nk-arrow {
display: inline-block;
margin-left: 8px;
transition: transform .2s;
}
.nk-cta-primary:hover .nk-arrow {
transform: translateX(3px);
}

View File

@@ -286,4 +286,23 @@
width: 130px;
height: 130px;
animation: logoPulse 3s ease-in-out infinite;
}
.auth-modal {
width: 420px;
}
.auth-form-error {
margin-bottom: 10px;
}
.auth-submit-btn {
margin-top: 6px;
}
.auth-row {
text-align: center;
margin-top: 14px;
color: rgba(255,255,255,.45);
font-size: .85rem
}

View File

@@ -104,15 +104,28 @@
}
/* Meta pills */
.f-card-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.f-meta-pill {
display: flex; align-items: center; gap: 4px;
background: var(--hover-bg); border: 1px solid var(--border);
border-radius: 20px; padding: 3px 9px; font-size: .73rem; color: var(--muted);
.f-card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.f-meta-pill {
display: flex;
align-items: center;
gap: 4px;
background: var(--hover-bg);
border: 1px solid var(--border);
border-radius: 20px;
padding: 3px 9px;
font-size: .73rem;
color: var(--muted);
}
.f-date-pill {
cursor: pointer;
position: relative;
margin-left: auto;
}
.f-date-pill:hover {
background: rgba(128,128,128,.12);
@@ -286,4 +299,64 @@
background: rgba(var(--teal-rgb), .25);
color: var(--teal);
font-weight: 800;
}
.f-reward-checkbox {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: #d4af37;
user-select: none;
}
.f-reward-checkbox input {
display: none;
}
.f-reward-checkmark {
width: 20px;
height: 20px;
flex-shrink: 0;
border: 2px solid #d4af37;
border-radius: 5px;
transition: .2s;
position: relative;
}
.f-reward-checkbox input:checked + .f-reward-checkmark {
background: #d4af37;
box-shadow: 0 0 10px rgba(212, 175, 55, .35);
}
.f-reward-checkbox input:checked + .f-reward-checkmark::after {
content: "";
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 10px;
border: solid #17130a;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.f-reward-text {
font-size: .9rem;
font-weight: 700;
}
.f-card-img {
position: relative;
}
.f-card-reward {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
font-size: .72rem;
font-weight: 800;
color: #f5d76e;
}

View File

@@ -366,4 +366,20 @@
background: rgba(255, 107, 107, .12);
border: 1.5px solid rgba(255, 107, 107, .35);
color: #ff6b6b;
}
.f-map-frame {
width: 100%;
height: 260px;
border-radius: 8px;
overflow: hidden;
}
.f-map-inner {
width: 100%;
height: 100%;
}
.f-modal-sm {
width: 420px;
}

View File

@@ -13,11 +13,25 @@
.f-sb-nav { padding: 12px 0; flex: 1; }
.f-sb-bottom { padding: 12px 0; border-top: 1px solid var(--border); }
.f-sb-item {
display: flex; align-items: center; gap: 13px;
padding: 11px 20px; cursor: pointer;
display: flex;
align-items: center;
gap: 13px;
width: 100%;
padding: 11px 20px;
cursor: pointer;
transition: background .15s, color .15s;
color: var(--muted); font-size: .9rem; font-weight: 700;
color: var(--muted);
font: inherit;
font-size: .9rem;
font-weight: 700;
text-align: left;
border: none;
border-left: 3px solid transparent;
background: none;
}
.f-sb-item:hover { background: var(--hover-bg); color: var(--text-primary); }
.f-sb-item.active { background: rgba(var(--teal-rgb), .1); color: var(--teal); border-left-color: var(--teal); }

View File

@@ -75,3 +75,44 @@
border: 2px solid rgba(var(--teal-rgb), .2); border-top-color: var(--teal);
border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0;
}
.f-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
font-size: .9rem;
color: #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, .3);
z-index: 1000;
animation: f-toast-in .2s ease-out;
}
.f-toast-error {
background: #e5484d;
}
.f-toast-success {
background: #2de0c8;
color: #0a0a0a;
}
.f-toast-close {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0;
}
@keyframes f-toast-in {
from { opacity: 0; transform: translate(-50%, 8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}

14
src/utils/chatDisplay.jsx Normal file
View File

@@ -0,0 +1,14 @@
import { getImageUrl } from "../config/authConstants";
/**
* Резолвит данные для отображения одного чата в списке:
* заголовок, картинку/эмодзи-аватар и счётчик непрочитанных.
*/
export function getChatDisplayInfo(chat, post, unreadMap, t) {
const title = post?.title || chat.otherUser?.name || t('chat.user');
const imgSrc = getImageUrl(post?.images?.[0]);
const emoji = post?.emoji;
const unreadCount = unreadMap[chat.id] ?? chat.unreadCount ?? chat.unreadMessages ?? 0;
return { title, imgSrc, emoji, unreadCount };
}

View File

@@ -0,0 +1,3 @@
export function isMobileDevice() {
return /Mobi|Android|iPhone/i.test(navigator.userAgent);
}