fixed code
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
183
src/components/CityFilter.jsx
Normal file
183
src/components/CityFilter.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
28
src/components/Toast.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/chat/ChatHeader.jsx
Normal file
20
src/components/chat/ChatHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/chat/ChatInputBar.jsx
Normal file
56
src/components/chat/ChatInputBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/chat/ChatListItem.jsx
Normal file
40
src/components/chat/ChatListItem.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/chat/MessageBubble.jsx
Normal file
7
src/components/chat/MessageBubble.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function MessageBubble({ message, isMine }) {
|
||||
return (
|
||||
<div className={`chat-bubble${isMine ? " mine" : ""}`}>
|
||||
{message.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/feed/FeedContent.jsx
Normal file
70
src/components/feed/FeedContent.jsx
Normal 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}
|
||||
/>
|
||||
));
|
||||
}
|
||||
26
src/components/feed/FeedSearchBar.jsx
Normal file
26
src/components/feed/FeedSearchBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/feed/FeedTopBar.jsx
Normal file
28
src/components/feed/FeedTopBar.jsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/components/feed/TypeFilterBar.jsx
Normal file
21
src/components/feed/TypeFilterBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/components/icons/GlobeIcon.jsx
Normal file
8
src/components/icons/GlobeIcon.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/icons/MoonIcon.jsx
Normal file
7
src/components/icons/MoonIcon.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
8
src/components/icons/SunIcon.jsx
Normal file
8
src/components/icons/SunIcon.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/components/layout/AppTopBar.jsx
Normal file
37
src/components/layout/AppTopBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
38
src/components/reviews/RatingSummary.jsx
Normal file
38
src/components/reviews/RatingSummary.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/reviews/ReviewCard.jsx
Normal file
33
src/components/reviews/ReviewCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/reviews/ReviewForm.jsx
Normal file
47
src/components/reviews/ReviewForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/reviews/StarRating.jsx
Normal file
32
src/components/reviews/StarRating.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user