added english language
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from "./icons/CloseIcon";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
@@ -8,6 +9,7 @@ import { API_URL } from "../config/authConstants";
|
||||
* Открывается когда неавторизованный пользователь пытается создать объявление.
|
||||
*/
|
||||
export default function AuthModal({ onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState("login"); // "login" | "register"
|
||||
const { login: saveAuth } = useAuthContext();
|
||||
|
||||
@@ -17,7 +19,7 @@ export default function AuthModal({ onClose }) {
|
||||
{/* Header */}
|
||||
<div className="f-modal-head">
|
||||
<span className="f-modal-title">
|
||||
{mode === "login" ? "Вход в аккаунт" : "Регистрация"}
|
||||
{mode === "login" ? t('auth.loginModalTitle') : t('auth.registerModalTitle')}
|
||||
</span>
|
||||
<button className="f-close-btn" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
@@ -43,13 +45,14 @@ export default function AuthModal({ onClose }) {
|
||||
|
||||
// ─── Login form (inline modal) ──────────────────────────────────────────────
|
||||
function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
const { t } = useTranslation();
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!login || !password) { setError("Введите логин и пароль"); return; }
|
||||
if (!login || !password) { setError(t('auth.enterLoginPassword')); return; }
|
||||
setLoading(true); setError("");
|
||||
|
||||
try {
|
||||
@@ -61,7 +64,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message || "Неверный логин или пароль");
|
||||
throw new Error(data.message || t('auth.invalidCredentials'));
|
||||
}
|
||||
|
||||
const { token, user } = await res.json();
|
||||
@@ -77,11 +80,11 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
return (
|
||||
<>
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Логин</label>
|
||||
<label className="f-m-label">{t('auth.loginLabel')}</label>
|
||||
<input
|
||||
className="f-m-field"
|
||||
type="text"
|
||||
placeholder="Ваш логин"
|
||||
placeholder={t('auth.yourLogin')}
|
||||
value={login}
|
||||
onChange={e => { setLogin(e.target.value); setError(""); }}
|
||||
autoComplete="off"
|
||||
@@ -89,11 +92,11 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
</div>
|
||||
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Пароль</label>
|
||||
<label className="f-m-label">{t('auth.passwordLabel')}</label>
|
||||
<input
|
||||
className="f-m-field"
|
||||
type="password"
|
||||
placeholder="Ваш пароль"
|
||||
placeholder={t('auth.yourPassword')}
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setError(""); }}
|
||||
onKeyDown={e => e.key === "Enter" && handleSubmit()}
|
||||
@@ -104,7 +107,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
|
||||
<SubmitButton
|
||||
loading={loading}
|
||||
label="Войти"
|
||||
label={t('auth.login')}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
|
||||
@@ -112,7 +115,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
textAlign: "center", marginTop: 14,
|
||||
color: "rgba(255,255,255,.45)", fontSize: ".85rem"
|
||||
}}>
|
||||
Нет аккаунта?{" "}
|
||||
{t('auth.noAccount')}{" "}
|
||||
<button
|
||||
onClick={onSwitch}
|
||||
style={{
|
||||
@@ -121,7 +124,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
fontFamily: "var(--font)",
|
||||
}}
|
||||
>
|
||||
Зарегистрироваться
|
||||
{t('auth.register')}
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
@@ -130,6 +133,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
|
||||
// ─── Register form (inline modal) ──────────────────────────────────────────
|
||||
function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({
|
||||
name: "", login: "", password: "", password2: "",
|
||||
});
|
||||
@@ -144,11 +148,11 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
|
||||
const validate = () => {
|
||||
const e = {};
|
||||
if (!form.name.trim()) e.name = "Введите имя";
|
||||
if (!form.login.trim()) e.login = "Введите логин";
|
||||
else if (form.login.length < 3) e.login = "Минимум 3 символа";
|
||||
if (form.password.length < 6) e.password = "Минимум 6 символов";
|
||||
if (form.password !== form.password2) e.password2 = "Пароли не совпадают";
|
||||
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;
|
||||
};
|
||||
@@ -170,7 +174,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
});
|
||||
|
||||
const regData = await regRes.json();
|
||||
if (!regRes.ok) throw new Error(regData.message || "Ошибка регистрации");
|
||||
if (!regRes.ok) throw new Error(regData.message || t('auth.registrationError'));
|
||||
|
||||
// 2. Автоматический вход после регистрации
|
||||
const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, {
|
||||
@@ -194,11 +198,11 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
return (
|
||||
<>
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Имя</label>
|
||||
<label className="f-m-label">{t('auth.nameLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.name ? " err" : ""}`}
|
||||
type="text"
|
||||
placeholder="Как вас зовут?"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
value={form.name}
|
||||
onChange={e => setField("name", e.target.value)}
|
||||
/>
|
||||
@@ -206,11 +210,11 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
</div>
|
||||
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Логин</label>
|
||||
<label className="f-m-label">{t('auth.loginLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.login ? " err" : ""}`}
|
||||
type="text"
|
||||
placeholder="Придумайте логин"
|
||||
placeholder={t('auth.loginPlaceholder')}
|
||||
value={form.login}
|
||||
onChange={e => setField("login", e.target.value)}
|
||||
/>
|
||||
@@ -219,22 +223,22 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
|
||||
<div className="f-m-row">
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Пароль</label>
|
||||
<label className="f-m-label">{t('auth.passwordLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.password ? " err" : ""}`}
|
||||
type="password"
|
||||
placeholder="Мин. 6 символов"
|
||||
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">Повторите</label>
|
||||
<label className="f-m-label">{t('auth.repeatLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.password2 ? " err" : ""}`}
|
||||
type="password"
|
||||
placeholder="Ещё раз"
|
||||
placeholder={t('auth.repeatPlaceholder')}
|
||||
value={form.password2}
|
||||
onChange={e => setField("password2", e.target.value)}
|
||||
/>
|
||||
@@ -246,7 +250,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
|
||||
<SubmitButton
|
||||
loading={loading}
|
||||
label="Создать аккаунт"
|
||||
label={t('auth.createAccountBtn').replace(' ✓', '')}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
|
||||
@@ -254,7 +258,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
textAlign: "center", marginTop: 14,
|
||||
color: "rgba(255,255,255,.45)", fontSize: ".85rem"
|
||||
}}>
|
||||
Уже есть аккаунт?{" "}
|
||||
{t('auth.hasAccount')}{" "}
|
||||
<button
|
||||
onClick={onSwitch}
|
||||
style={{
|
||||
@@ -263,7 +267,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
||||
fontFamily: "var(--font)",
|
||||
}}
|
||||
>
|
||||
Войти
|
||||
{t('auth.loginLink')}
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MagnifierLogo from "./MagnifierLogo";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useRegister } from "../hooks/useRegister";
|
||||
@@ -9,14 +10,14 @@ import likeSmile from '../assets/success.png';
|
||||
import { useCheckLogin } from "../hooks/useRegister";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
function getStrength(val) {
|
||||
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 = ["", "Слабый", "Средний", "Хороший", "Отличный"];
|
||||
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] };
|
||||
@@ -49,6 +50,7 @@ function StepDots({ current, total }) {
|
||||
// LOGIN FORM
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
function LoginForm({ onSwitchToRegister }) {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
@@ -56,14 +58,14 @@ function LoginForm({ onSwitchToRegister }) {
|
||||
|
||||
return (
|
||||
<div className="form-body">
|
||||
<p className="form-subtitle">С возвращением</p>
|
||||
<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="Введите email"
|
||||
placeholder={t('auth.enterEmail')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="off"
|
||||
@@ -76,7 +78,7 @@ function LoginForm({ onSwitchToRegister }) {
|
||||
<input
|
||||
className="field"
|
||||
type={showPass ? "text" : "password"}
|
||||
placeholder="Введите пароль"
|
||||
placeholder={t('auth.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ paddingRight: "44px" }}
|
||||
@@ -90,16 +92,16 @@ function LoginForm({ onSwitchToRegister }) {
|
||||
{error && <p className="err-msg">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? "Входим..." : "Войти"}
|
||||
{loading ? t('auth.loggingIn') : t('auth.login')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button className="ghost-btn">Забыли пароль?</button>
|
||||
<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>
|
||||
@@ -108,21 +110,22 @@ function LoginForm({ onSwitchToRegister }) {
|
||||
|
||||
// ─── 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);
|
||||
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 = "Введите имя";
|
||||
if (!data.login.trim()) e.login = "Введите логин";
|
||||
else if (data.login.length < 3) e.login = "Минимум 3 символа";
|
||||
if (data.password.length < 6) e.password = "Минимум 6 символов";
|
||||
else if (data.password !== data.password2) e.password2 = "Пароли не совпадают";
|
||||
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;
|
||||
@@ -131,11 +134,11 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
return (
|
||||
<>
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Имя пользователя</label>
|
||||
<label className="field-label">{t('auth.username')}</label>
|
||||
<input
|
||||
className={`field ${errors.name ? "field-err" : ""}`}
|
||||
type="text"
|
||||
placeholder="Как вас зовут?"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
value={data.name}
|
||||
onChange={(e) => {
|
||||
onChange("name", e.target.value);
|
||||
@@ -147,12 +150,12 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
</div>
|
||||
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Логин</label>
|
||||
<label className="field-label">{t('auth.loginLabel')}</label>
|
||||
<input
|
||||
className={`field ${errors.login || loginExists ? "field-err"
|
||||
: ""}`}
|
||||
type="text"
|
||||
placeholder="Придумайте логин"
|
||||
placeholder={t('auth.loginPlaceholder')}
|
||||
value={data.login}
|
||||
onChange={(e) => {
|
||||
onChange("login", e.target.value);
|
||||
@@ -161,12 +164,12 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{checkingLogin && <p className="hint-msg">Проверяем...</p>}
|
||||
{checkingLogin && <p className="hint-msg">{t('auth.checking')}</p>}
|
||||
|
||||
{!checkingLogin && data.login.length >= 3 && (
|
||||
<p className="err-msg"
|
||||
style={{color: loginExists ? "#ff6b6b" : "#2de0c8"}}>
|
||||
{loginExists ? "✗ Логин занят" : "✓ Логин свободен"}
|
||||
{loginExists ? t('auth.loginTaken') : t('auth.loginFree')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -174,13 +177,13 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
</div>
|
||||
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Пароль</label>
|
||||
<label className="field-label">{t('auth.passwordLabel')}</label>
|
||||
|
||||
<div className="input-wrap">
|
||||
<input
|
||||
className={`field ${errors.password ? "field-err" : ""}`}
|
||||
type={show1 ? "text" : "password"}
|
||||
placeholder="Минимум 6 символов"
|
||||
placeholder={t('auth.passwordMin')}
|
||||
value={data.password}
|
||||
onChange={(e) => {
|
||||
onChange("password", e.target.value);
|
||||
@@ -211,14 +214,14 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
|
||||
|
||||
<div className="field-wrap">
|
||||
<label className="field-label">Повторите пароль</label>
|
||||
<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="Введите ещё раз"
|
||||
placeholder={t('auth.repeatPasswordPlaceholder')}
|
||||
value={data.password2}
|
||||
onChange={(e) => {
|
||||
onChange("password2", e.target.value);
|
||||
@@ -232,7 +235,7 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
{data.password2 && (
|
||||
<p className="err-msg"
|
||||
style={{color: matched ? "#2de0c8" : "#ff6b6b"}}>
|
||||
{matched ? "✓ Совпадают" : "✗ Не совпадают"}
|
||||
{matched ? t('auth.passwordsMatch') : t('auth.passwordsNoMatch')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -240,7 +243,7 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={() => validate() && onNext()}>
|
||||
Далее →
|
||||
{t('auth.next')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
@@ -248,16 +251,17 @@ function RegStep01({ data, onChange, onNext }) {
|
||||
|
||||
// ─── 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">Имя</span>
|
||||
<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">Логин</span>
|
||||
<span className="confirm-label">{t('auth.loginLabel')}</span>
|
||||
<span className="confirm-val accent">@{data.login}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,30 +270,32 @@ function RegStep2({data, onSubmit, onBack, loading, error}) {
|
||||
marginBottom: "10px"
|
||||
}}>{error}</p>}
|
||||
<button className="btn-primary" onClick={onSubmit} disabled={loading}>
|
||||
{loading ? "Создаём..." : "Создать аккаунт ✓"}
|
||||
{loading ? t('auth.creating') : t('auth.createAccountBtn')}
|
||||
</button>
|
||||
<button className="back-btn" onClick={onBack}>← Назад</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">Добро пожаловать, {name}!</p>
|
||||
<p className="success-sub">Аккаунт создан успешно.</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: ""});
|
||||
@@ -307,7 +313,7 @@ function RegisterForm({onSwitchToLogin}) {
|
||||
|
||||
return (
|
||||
<div className="form-body">
|
||||
<p className="form-subtitle">Создайте аккаунт</p>
|
||||
<p className="form-subtitle">{t('auth.createAccount')}</p>
|
||||
<StepDots current={step} total={2} />
|
||||
|
||||
{step === 0 && (
|
||||
@@ -325,8 +331,8 @@ function RegisterForm({onSwitchToLogin}) {
|
||||
|
||||
{step === 0 && (
|
||||
<p className="switch-row">
|
||||
Уже есть аккаунт?{" "}
|
||||
<button className="link-btn" onClick={onSwitchToLogin}>Войти</button>
|
||||
{t('auth.hasAccount')}{" "}
|
||||
<button className="link-btn" onClick={onSwitchToLogin}>{t('auth.loginLink')}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,6 +343,7 @@ function RegisterForm({onSwitchToLogin}) {
|
||||
// AUTH PAGE — единственная страница, переключает login ↔ register
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
export default function Authentication() {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState("login"); // "login" | "register"
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
@@ -367,7 +374,7 @@ export default function Authentication() {
|
||||
<div className="logo-wrap">
|
||||
<MagnifierLogo />
|
||||
</div>
|
||||
<h1 className="auth-title">Находка</h1>
|
||||
<h1 className="auth-title">{t('app.name')}</h1>
|
||||
|
||||
{mode === "login"
|
||||
? <LoginForm onSwitchToRegister={() => switchTo("register")} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useCallback, useEffect, useState} from "react";
|
||||
import CloseIcon from "./icons/CloseIcon";
|
||||
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
|
||||
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";
|
||||
@@ -19,6 +20,7 @@ const MAX_SIZE_MB = 5;
|
||||
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
// Режим: редактирование (true) или создание нового (false)
|
||||
const isEdit = !!initialData;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Поля формы; при редактировании заполняем из initialData
|
||||
const [form, setForm] = useState(
|
||||
@@ -88,7 +90,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
}
|
||||
|
||||
if (coords && !addressRef.current) {
|
||||
setErrors(prev => ({ ...prev, map: "Подождите, адрес загружается..." }));
|
||||
setErrors(prev => ({ ...prev, map: t('post.mapLoading') }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
{/* Шапка модалки */}
|
||||
<div className="f-modal-head">
|
||||
<span className="f-modal-title">
|
||||
{isEdit ? "Редактировать объявление" : "Новое объявление"}
|
||||
{isEdit ? t('post.editTitle') : t('post.newTitle')}
|
||||
</span>
|
||||
<button className="f-close-btn" onClick={onClose}>
|
||||
<CloseIcon/>
|
||||
@@ -145,31 +147,31 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
|
||||
{/* Тип: Потеря / Находка */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Тип объявления</label>
|
||||
<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">Название</label>
|
||||
<label className="f-m-label">{t('post.titleLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.title ? " err" : ""}`}
|
||||
type="text"
|
||||
placeholder="Например: Кошелёк чёрный кожаный"
|
||||
placeholder={t('post.titlePlaceholder')}
|
||||
value={form.title}
|
||||
onChange={e => set("title", e.target.value)}
|
||||
/>
|
||||
@@ -178,11 +180,11 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
|
||||
{/* Описание */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Описание</label>
|
||||
<label className="f-m-label">{t('post.descriptionLabel')}</label>
|
||||
<textarea
|
||||
className={`f-m-field${errors.description ? " err" : ""}`}
|
||||
rows={3}
|
||||
placeholder="Подробно опишите предмет — цвет, размер, особые приметы..."
|
||||
placeholder={t('post.descriptionPlaceholder')}
|
||||
value={form.description}
|
||||
onChange={e => set("description", e.target.value)}
|
||||
/>
|
||||
@@ -192,7 +194,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
|
||||
{/* Фотографии: сетка превью + кнопка добавления */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Фотографии</label>
|
||||
<label className="f-m-label">{t('post.photosLabel')}</label>
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="f-img-grid">
|
||||
@@ -203,7 +205,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
className="f-img-remove"
|
||||
onClick={() => removeImage(img.id)}
|
||||
type="button"
|
||||
aria-label="Удалить фото"
|
||||
aria-label={t('post.deletePhoto')}
|
||||
>
|
||||
<CloseIcon/>
|
||||
</button>
|
||||
@@ -226,27 +228,27 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
disabled={compressing}
|
||||
/>
|
||||
{compressing ? (
|
||||
<span>Обработка...</span>
|
||||
<span>{t('post.processing')}</span>
|
||||
) : (
|
||||
<span>{uploadedImages.length > 0 ? "➕ Добавить ещё" : "📷 Добавить фото"}</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">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ · можно несколько</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">Категория</label>
|
||||
<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="">Выбрать...</option>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
<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>}
|
||||
@@ -255,7 +257,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
|
||||
{/* Карта */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Место на карте</label>
|
||||
<label className="f-m-label">{t('post.mapLabel')}</label>
|
||||
<div style={{
|
||||
width: "100%",
|
||||
height: 260,
|
||||
@@ -265,17 +267,16 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
<div ref={mapRef} style={{width: "100%", height: "100%"}}/>
|
||||
</div>
|
||||
|
||||
{!coords && <p className="f-hint">Нажмите на карту, чтобы отметить
|
||||
место</p>}
|
||||
{!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">Адрес</label>
|
||||
<label className="f-m-label">{t('post.addressLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.address ? " err" : ""}`}
|
||||
type="text"
|
||||
placeholder="Например: Москва, ул. Ленина, 5"
|
||||
placeholder={t('post.addressPlaceholder')}
|
||||
value={form.address}
|
||||
onChange={e => set("address", e.target.value)}
|
||||
/>
|
||||
@@ -284,11 +285,11 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
|
||||
{/* Телефон с автоматической маской */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Номер телефона</label>
|
||||
<label className="f-m-label">{t('post.phoneLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.phone ? " err" : ""}`}
|
||||
type="tel"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
placeholder={t('post.phonePlaceholder')}
|
||||
value={form.phone}
|
||||
onChange={e => set("phone", formatPhone(e.target.value))}
|
||||
/>
|
||||
@@ -302,7 +303,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
disabled={loading}>
|
||||
<div className="f-m-submit-row">
|
||||
{loading && <div className="f-m-spinner"/>}
|
||||
{isEdit ? "Сохранить изменения" : "Разместить объявление"}
|
||||
{isEdit ? t('post.saveChanges') : t('post.publish')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const MONTHS = ["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
|
||||
const DAYS = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"];
|
||||
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 ref = useRef(null);
|
||||
const d = new Date(date);
|
||||
const [year, setYear] = useState(d.getFullYear());
|
||||
@@ -30,11 +31,11 @@ export default function DateCalendarPopup({ date, onClose }) {
|
||||
<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>
|
||||
<span>{months[month]} {year}</span>
|
||||
<button onClick={() => setMonth(m => m === 11 ? (setYear(y => y + 1), 0) : m + 1)}>›</button>
|
||||
</div>
|
||||
<div className="f-cal-grid">
|
||||
{DAYS.map(d => <div key={d} className="f-cal-day-name">{d}</div>)}
|
||||
{days.map(d => <div key={d} className="f-cal-day-name">{d}</div>)}
|
||||
{cells.map((day, i) =>
|
||||
day ? (
|
||||
<div key={i}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useChats } from "../hooks/useChats";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import DateCalendarPopup from "./DateCalendarPopup";
|
||||
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();
|
||||
@@ -49,17 +51,17 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
||||
: ad.userRole === 'METRO' ? 'f-card-role-metro' : '';
|
||||
|
||||
const roleBadge = ad.userRole === 'LOST_AND_FOUND' ? (
|
||||
<div className="f-role-badge f-role-lost-found" title="Бюро находок">
|
||||
<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="Метрополитен">
|
||||
<div className="f-role-badge f-role-metro" title={t('postCard.metro')}>
|
||||
<span className="f-role-metro-letter">М</span>
|
||||
Метрополитен
|
||||
{t('postCard.metro')}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -76,13 +78,13 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
||||
<div className="f-card-badges">
|
||||
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
|
||||
<div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss" : "f-type-found"}`}>
|
||||
{ad.type === "loss" ? "Потеря" : "Находка"}
|
||||
{ad.type === "loss" ? t('postCard.loss') : t('postCard.found')}
|
||||
</div>
|
||||
{roleBadge}
|
||||
</div>
|
||||
<div className="f-card-title">
|
||||
{ad.title}
|
||||
{isClosed && <span className="f-status-badge f-status-closed">✓ Найдено</span>}
|
||||
{isClosed && <span className="f-status-badge f-status-closed">{t('postCard.foundStatus')}</span>}
|
||||
</div>
|
||||
<div className="f-card-desc">{ad.description}</div>
|
||||
<div className="f-card-meta">
|
||||
@@ -97,7 +99,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
||||
</div>
|
||||
{address && (
|
||||
<button className="f-card-map" onClick={e => e.stopPropagation()}>
|
||||
<MapIcon /> Показать на карте — {address}
|
||||
<MapIcon /> {t('postCard.showOnMap', { address })}
|
||||
</button>
|
||||
)}
|
||||
<div className="f-card-foot">
|
||||
@@ -112,7 +114,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
||||
<div className="f-card-foot-right">
|
||||
{!isOwner && !isClosed && (
|
||||
<button className="f-card-contact" onClick={handleContact}>
|
||||
Написать
|
||||
{t('postCard.write')}
|
||||
</button>
|
||||
)}
|
||||
{onEdit && isOwner && !isClosed && (
|
||||
@@ -130,7 +132,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
||||
{onMarkFound && isOwner && !isClosed && (
|
||||
<button className="f-card-found-btn"
|
||||
onClick={e => { e.stopPropagation(); onMarkFound(ad.id); }}>
|
||||
✓ Вещь найдена
|
||||
{t('postCard.itemFound')}
|
||||
</button>
|
||||
)}
|
||||
<button className={`f-like-btn${liked ? " liked" : ""}`}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function RightSidebar() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="f-right">
|
||||
<div className="f-right-label">Реклама</div>
|
||||
<div className="f-right-label">{t('rightSidebar.advertisement')}</div>
|
||||
<div className="f-right-empty">
|
||||
<div className="f-right-empty-icon">📢</div>
|
||||
<div className="f-right-empty-text">
|
||||
Здесь будет<br />ваша реклама
|
||||
</div>
|
||||
<button className="f-add-promo-btn">+ Добавить рекламу</button>
|
||||
<div className="f-right-empty-text" dangerouslySetInnerHTML={{ __html: t('rightSidebar.emptyText') }} />
|
||||
<button className="f-add-promo-btn">{t('rightSidebar.addAd')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from "./icons/CloseIcon";
|
||||
import "../styles/rules.css";
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
num: "1",
|
||||
title: "Общие положения",
|
||||
titleKey: "rules.section1Title",
|
||||
content: (
|
||||
<p>
|
||||
Платформа «Находка» предназначена исключительно для помощи людям в поиске утерянных вещей
|
||||
@@ -15,7 +16,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "2",
|
||||
title: "Что можно размещать",
|
||||
titleKey: "rules.section2Title",
|
||||
content: (
|
||||
<ul>
|
||||
<li>Объявления о потере личных вещей, документов, животных, транспортных средств</li>
|
||||
@@ -27,7 +28,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "3",
|
||||
title: "Что запрещено размещать",
|
||||
titleKey: "rules.section3Title",
|
||||
content: (
|
||||
<>
|
||||
<p className="rules-sub-title">3.1. Запрещённый контент</p>
|
||||
@@ -55,7 +56,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "4",
|
||||
title: "Требования к объявлению",
|
||||
titleKey: "rules.section4Title",
|
||||
content: (
|
||||
<ul>
|
||||
<li>Описание должно быть достоверным и точным</li>
|
||||
@@ -68,7 +69,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "5",
|
||||
title: "Вознаграждение",
|
||||
titleKey: "rules.section5Title",
|
||||
content: (
|
||||
<>
|
||||
<p>Предложение вознаграждения нашедшему является добровольным и законным правом владельца. При этом:</p>
|
||||
@@ -82,7 +83,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "6",
|
||||
title: "Найденные документы",
|
||||
titleKey: "rules.section6Title",
|
||||
content: (
|
||||
<>
|
||||
<p>Нахождение чужих документов обязывает вас согласно законодательству РФ:</p>
|
||||
@@ -97,7 +98,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "7",
|
||||
title: "Животные",
|
||||
titleKey: "rules.section7Title",
|
||||
content: (
|
||||
<ul>
|
||||
<li>Найденное животное необходимо по возможности обеспечить едой и кровом до возврата владельцу</li>
|
||||
@@ -108,7 +109,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "8",
|
||||
title: "Конфиденциальность",
|
||||
titleKey: "rules.section8Title",
|
||||
content: (
|
||||
<ul>
|
||||
<li>Не публикуйте в объявлении избыточные персональные данные — достаточно способа связи</li>
|
||||
@@ -119,7 +120,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "9",
|
||||
title: "Модерация",
|
||||
titleKey: "rules.section9Title",
|
||||
content: (
|
||||
<>
|
||||
<p>Администрация платформы вправе:</p>
|
||||
@@ -133,7 +134,7 @@ const RULES = [
|
||||
},
|
||||
{
|
||||
num: "10",
|
||||
title: "Ответственность пользователей",
|
||||
titleKey: "rules.section10Title",
|
||||
content: (
|
||||
<p>
|
||||
Пользователь самостоятельно несёт ответственность за содержание размещаемых объявлений.
|
||||
@@ -145,6 +146,7 @@ const RULES = [
|
||||
];
|
||||
|
||||
export default function RulesModal({ onClose }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="f-modal rules-modal">
|
||||
@@ -152,7 +154,7 @@ export default function RulesModal({ onClose }) {
|
||||
<div className="f-modal-head">
|
||||
<div className="rules-modal-title-wrap">
|
||||
<span className="rules-modal-icon">📋</span>
|
||||
<span className="f-modal-title">Правила площадки «Находка»</span>
|
||||
<span className="f-modal-title">{t('rules.title')}</span>
|
||||
</div>
|
||||
<button className="f-close-btn" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
@@ -165,7 +167,7 @@ export default function RulesModal({ onClose }) {
|
||||
<div key={rule.num} className="rules-section">
|
||||
<div className="rules-section-head">
|
||||
<span className="rules-num">{rule.num}</span>
|
||||
<span className="rules-title">{rule.title}</span>
|
||||
<span className="rules-title">{t(rule.titleKey)}</span>
|
||||
</div>
|
||||
<div className="rules-body">{rule.content}</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {NavIcons} from "./icons/NavIcons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useUnread } from "../context/UnreadContext";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Sidebar({
|
||||
open,
|
||||
@@ -18,40 +19,48 @@ export default function Sidebar({
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const { totalUnread } = useUnread();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
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} />
|
||||
<div className={`f-sidebar${open ? " open" : ""}`}>
|
||||
<div className="f-sb-head">
|
||||
<div className="f-sb-logo"><LogoIcon /></div>
|
||||
<span className="f-sb-name">Находка</span>
|
||||
<span className="f-sb-name">{t('app.name')}</span>
|
||||
</div>
|
||||
<nav className="f-sb-nav">
|
||||
{NAV_ITEMS.map(({ icon, label }) => {
|
||||
{NAV_ITEMS.map(({ icon, key: itemKey, labelKey }) => {
|
||||
const Ic = NavIcons[icon];
|
||||
const handleClick = () => {
|
||||
// «Мои объявления» требует авторизации
|
||||
if (!isAuthenticated && label === "Мои объявления") {
|
||||
if (!isAuthenticated && itemKey === "myads") {
|
||||
onLoginClick();
|
||||
} else if (label === "Чат") {
|
||||
} else if (itemKey === "chat") {
|
||||
if (!isAuthenticated) {
|
||||
onLoginClick();
|
||||
} else {
|
||||
navigate("/chats");
|
||||
}
|
||||
} else {
|
||||
onNavClick(label);
|
||||
onNavClick(itemKey);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={`f-sb-item${activeNav === label ? " active" : ""}`}
|
||||
key={itemKey}
|
||||
className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Ic /> {label}
|
||||
{label === "Чат" && totalUnread > 0 && (
|
||||
<Ic /> {t(labelKey)}
|
||||
{itemKey === "chat" && totalUnread > 0 && (
|
||||
<span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -59,29 +68,29 @@ export default function Sidebar({
|
||||
})}
|
||||
</nav>
|
||||
<div className="f-sb-bottom">
|
||||
{NAV_BOTTOM.map(({ icon, label }) => {
|
||||
{NAV_BOTTOM.map(({ icon, key: itemKey, labelKey }) => {
|
||||
const Ic = NavIcons[icon];
|
||||
const handleBottomClick = () => {
|
||||
if (label === "Правила площадки") {
|
||||
if (itemKey === "rules") {
|
||||
onRulesClick?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (label === "Отзывы") {
|
||||
if (itemKey === "reviews") {
|
||||
onReviewsClick?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
onNavClick(label);
|
||||
onNavClick(itemKey);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={`f-sb-item${activeNav === label ? " active" : ""}`}
|
||||
key={itemKey}
|
||||
className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
|
||||
onClick={handleBottomClick}
|
||||
>
|
||||
<Ic /> {label}
|
||||
<Ic /> {t(labelKey)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -97,7 +106,14 @@ export default function Sidebar({
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
)}
|
||||
{theme === "dark" ? "Светлая тема" : "Тёмная тема"}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function FinalCta({ onClick }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="nk-final-cta">
|
||||
<h2 className="nk-h2">Кто-то прямо сейчас ищет то, что вы нашли</h2>
|
||||
<h2 className="nk-h2">{t('landing.finalCta')}</h2>
|
||||
<button className="nk-cta-primary" onClick={onClick}>
|
||||
Открыть Находку
|
||||
{t('landing.openNakhodka')}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<footer className="nk-footer">
|
||||
© {new Date().getFullYear()} Находка. Возвращаем найденное.
|
||||
{t('landing.footer', { year: new Date().getFullYear() })}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
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 (
|
||||
@@ -14,15 +16,14 @@ export default function Hero({ onCtaClick, onLoginClick }) {
|
||||
<Navbar onLoginClick={onLoginClick || onCtaClick} />
|
||||
|
||||
<div className="nk-hero-text">
|
||||
<span className="nk-eyebrow">Сервис поиска пропавших питомцев и вещей</span>
|
||||
<span className="nk-eyebrow">{t('landing.eyebrow')}</span>
|
||||
<h1 className="nk-h1">
|
||||
Потерял — не значит
|
||||
{t('landing.heroTitle')}
|
||||
<br />
|
||||
<span className="nk-accent">потерял навсегда</span>
|
||||
<span className="nk-accent">{t('landing.heroAccent')}</span>
|
||||
</h1>
|
||||
<p className="nk-lead">
|
||||
Разместите объявление о пропаже или находке — Находка сама сопоставит
|
||||
похожие записи по фото, месту и описанию и покажет совпадения рядом с вами.
|
||||
{t('landing.heroLead')}
|
||||
</p>
|
||||
<div className="nk-cta-row">
|
||||
<button
|
||||
@@ -31,7 +32,7 @@ export default function Hero({ onCtaClick, onLoginClick }) {
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onClick={onCtaClick}
|
||||
>
|
||||
Начать поиск
|
||||
{t('landing.startSearch')}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="nk-nav">
|
||||
<span className="nk-logo">Находка</span>
|
||||
<button className="nk-nav-btn" onClick={onLoginClick}>
|
||||
Войти
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { steps } from "../../config/landingConstants";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STEP_KEYS = [
|
||||
{ titleKey: "landing.step1Title", textKey: "landing.step1Text" },
|
||||
{ titleKey: "landing.step2Title", textKey: "landing.step2Text" },
|
||||
{ titleKey: "landing.step3Title", textKey: "landing.step3Text" },
|
||||
];
|
||||
|
||||
export default function StepsSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="nk-steps-section">
|
||||
<h2 className="nk-h2">Как это работает</h2>
|
||||
<h2 className="nk-h2">{t('landing.howItWorks')}</h2>
|
||||
<div className="nk-steps-grid">
|
||||
{steps.map((s) => (
|
||||
<div className="nk-step-card" key={s.n}>
|
||||
<span className="nk-step-num">{s.n}</span>
|
||||
<h3 className="nk-step-title">{s.title}</h3>
|
||||
<p className="nk-step-text">{s.text}</p>
|
||||
{STEP_KEYS.map((s, i) => (
|
||||
<div className="nk-step-card" key={i}>
|
||||
<span className="nk-step-num">{`0${i + 1}`}</span>
|
||||
<h3 className="nk-step-title">{t(s.titleKey)}</h3>
|
||||
<p className="nk-step-text">{t(s.textKey)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from "../icons/CloseIcon";
|
||||
|
||||
// ─── Input field component ───────────────────────────────────────────────────
|
||||
@@ -24,11 +25,12 @@ function ImageUpload({
|
||||
onFileChange,
|
||||
onRemove,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const MAX_SIZE_MB = 5;
|
||||
|
||||
return (
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">Фото профиля</label>
|
||||
<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" />
|
||||
@@ -36,7 +38,7 @@ function ImageUpload({
|
||||
className="f-img-remove"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
aria-label="Удалить фото"
|
||||
aria-label={t('profile.deletePhoto')}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -51,20 +53,21 @@ function ImageUpload({
|
||||
className="f-img-input"
|
||||
disabled={compressing}
|
||||
/>
|
||||
{compressing ? <span>Обработка...</span> : <span>📷 Добавить фото</span>}
|
||||
{compressing ? <span>{t('profile.processing')}</span> : <span>{t('profile.addPhoto')}</span>}
|
||||
</label>
|
||||
)}
|
||||
{imgError && <p className="f-err-msg">{imgError}</p>}
|
||||
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p>
|
||||
<p className="f-hint">{t('profile.photoHint', { maxSize: MAX_SIZE_MB })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Login field ─────────────────────────────────────────────────────────────
|
||||
function LoginField({ form, errors, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<InputField
|
||||
label="Логин"
|
||||
label={t('profile.loginLabel')}
|
||||
placeholder="username"
|
||||
value={form.login}
|
||||
error={errors.login}
|
||||
@@ -75,18 +78,19 @@ function LoginField({ form, errors, onChange }) {
|
||||
|
||||
// ─── Name fields row ─────────────────────────────────────────────────────────
|
||||
function NameFieldsRow({ form, errors, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="f-m-row">
|
||||
<InputField
|
||||
label="Имя"
|
||||
placeholder="Иван"
|
||||
label={t('profile.nameLabel')}
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
value={form.firstName}
|
||||
error={errors.firstName}
|
||||
onChange={(e) => onChange("firstName", e.target.value)}
|
||||
/>
|
||||
<InputField
|
||||
label="Фамилия"
|
||||
placeholder="Иванов"
|
||||
label={t('profile.surnameLabel')}
|
||||
placeholder={t('profile.surnamePlaceholder')}
|
||||
value={form.lastName}
|
||||
error={errors.lastName}
|
||||
onChange={(e) => onChange("lastName", e.target.value)}
|
||||
@@ -97,10 +101,11 @@ function NameFieldsRow({ form, errors, onChange }) {
|
||||
|
||||
// ─── Contact fields ─────────────────────────────────────────────────────────
|
||||
function ContactFields({ form, errors, onChange, onPhoneChange }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<InputField
|
||||
label="Номер телефона"
|
||||
label={t('profile.phoneLabel')}
|
||||
type="tel"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
value={form.phone}
|
||||
@@ -108,7 +113,7 @@ function ContactFields({ form, errors, onChange, onPhoneChange }) {
|
||||
onChange={(e) => onPhoneChange(e.target.value)}
|
||||
/>
|
||||
<InputField
|
||||
label="Электронная почта"
|
||||
label={t('profile.emailLabel')}
|
||||
type="email"
|
||||
placeholder="example@email.com"
|
||||
value={form.email}
|
||||
@@ -121,6 +126,7 @@ function ContactFields({ form, errors, onChange, onPhoneChange }) {
|
||||
|
||||
// ─── Submit button ──────────────────────────────────────────────────────────
|
||||
function SubmitButton({ loading, onSubmit }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
className="f-m-submit"
|
||||
@@ -129,7 +135,7 @@ function SubmitButton({ loading, onSubmit }) {
|
||||
>
|
||||
<div className="f-m-submit-row">
|
||||
{loading && <div className="f-m-spinner" />}
|
||||
Сохранить профиль
|
||||
{t('profile.save')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -137,9 +143,10 @@ function SubmitButton({ loading, onSubmit }) {
|
||||
|
||||
// ─── Modal header ──────────────────────────────────────────────────────────
|
||||
function ModalHeader({ onClose }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="f-modal-head">
|
||||
<span className="f-modal-title">Редактирование профиля</span>
|
||||
<span className="f-modal-title">{t('profile.editTitle')}</span>
|
||||
<button className="f-close-btn" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ─── Profile info component ─────────────────────────────────────────────────
|
||||
export default function ProfileInfo({ firstName, email }) {
|
||||
const displayName = firstName || "Профиль";
|
||||
const { t } = useTranslation();
|
||||
const displayName = firstName || t('profile.default');
|
||||
|
||||
return (
|
||||
<div className="f-profile-info">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatPhone } from "../../utils/formatPhone";
|
||||
import { useImageUpload } from "../../hooks/useImageUpload";
|
||||
import { useUpdateProfile } from "../../hooks/useUpdateProfile";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "./ProfileFormFields";
|
||||
|
||||
export default function ProfileModal({ onClose, initialData = {} }) {
|
||||
const { t } = useTranslation();
|
||||
const { user, updateUser, logout } = useAuthContext();
|
||||
const { updateProfile, loading: saving, error: saveError } = useUpdateProfile();
|
||||
const [form, setForm] = useState(initializeFormState(initialData));
|
||||
@@ -111,7 +113,7 @@ export default function ProfileModal({ onClose, initialData = {} }) {
|
||||
className="f-modal-logout"
|
||||
onClick={() => { logout(); onClose(); }}
|
||||
>
|
||||
Выйти
|
||||
{t('profile.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import i18n from '../../i18n';
|
||||
|
||||
// ─── Form validation logic ──────────────────────────────────────────────────
|
||||
export function validateProfileForm(form) {
|
||||
const errors = {};
|
||||
|
||||
if (!form.firstName.trim()) {
|
||||
errors.firstName = "Введите имя";
|
||||
errors.firstName = i18n.t('profile.validateNameRequired');
|
||||
}
|
||||
|
||||
if (!form.lastName.trim()) {
|
||||
errors.lastName = "Введите фамилию";
|
||||
errors.lastName = i18n.t('profile.validateSurnameRequired');
|
||||
}
|
||||
|
||||
if (form.email && !form.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||
errors.email = "Некорректный email";
|
||||
errors.email = i18n.t('profile.validateEmailInvalid');
|
||||
}
|
||||
|
||||
if (form.phone && form.phone.replace(/\D/g, "").length < 11) {
|
||||
errors.phone = "Некорректный номер телефона";
|
||||
errors.phone = i18n.t('profile.validatePhoneInvalid');
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
||||
Reference in New Issue
Block a user