added english language

This commit is contained in:
SlimusMinus
2026-07-09 00:09:45 +03:00
parent 3b503623f6
commit fffcdaa526
33 changed files with 454 additions and 687 deletions

View File

@@ -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>
</>

View File

@@ -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")} />

View File

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

View File

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

View File

@@ -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" : ""}`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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">

View File

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

View File

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