fixed code

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import StarRating from "./StarRating";
export default function RatingSummary({ avgRating, totalCount, distribution }) {
const { t } = useTranslation();
return (
<div className="rv-summary">
<div className="rv-summary-score">
{avgRating ? (
<>
<span className="rv-summary-num">{avgRating}</span>
<StarRating value={Math.round(Number(avgRating))} readOnly size={22} />
</>
) : (
<span className="rv-summary-none">{t('reviews.noReviews')}</span>
)}
</div>
<span className="rv-summary-total">
{t('reviews.title')} ({totalCount})
</span>
{totalCount > 0 && (
<div className="rv-distribution">
{distribution.map(({ star, count, pct }) => (
<div key={star} className="rv-dist-row">
<span className="rv-dist-star">{star} ★</span>
<div className="rv-dist-bar">
<div className="rv-dist-fill" style={{ width: `${pct}%` }} />
</div>
<span className="rv-dist-count">{count}</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import StarRating from "./StarRating";
export default function ReviewCard({ review }) {
const { t } = useTranslation();
const date = review.createdAt
? new Date(review.createdAt).toLocaleDateString("ru-RU", {
day: "numeric", month: "long", year: "numeric",
})
: "";
return (
<div className={`rv-card${review.pending ? " rv-card-pending" : ""}`}>
<div className="rv-card-head">
<div className="rv-card-avatar">
{review.authorName ? review.authorName.charAt(0).toUpperCase() : "?"}
</div>
<div className="rv-card-meta">
<span className="rv-card-name">{review.authorName || t('reviews.anonymous')}</span>
{date && <span className="rv-card-date">{date}</span>}
{review.pending && (
<span className="rv-card-pending-badge">{t('reviews.notSaved')}</span>
)}
</div>
<div className="rv-card-rating">
<StarRating value={review.rating} readOnly size={18} />
<span className="rv-card-rating-num">{review.rating}/5</span>
</div>
</div>
{review.text && <p className="rv-card-text">{review.text}</p>}
</div>
);
}

View File

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

View File

@@ -0,0 +1,32 @@
import { useState } from "react";
export default function StarRating({ value, onChange, size = 28, readOnly = false }) {
const [hovered, setHovered] = useState(0);
return (
<div className="rv-stars">
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= (hovered || value);
return (
<svg
key={star}
width={size}
height={size}
viewBox="0 0 24 24"
className={`rv-star${filled ? " filled" : ""}${!readOnly ? " interactive" : ""}`}
onClick={() => !readOnly && onChange(star)}
onMouseEnter={() => !readOnly && setHovered(star)}
onMouseLeave={() => !readOnly && setHovered(0)}
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
fill={filled ? "#f5a623" : "none"}
stroke={filled ? "#f5a623" : "currentColor"}
strokeWidth={1.5}
/>
</svg>
);
})}
</div>
);
}