diff --git a/.env b/.env new file mode 100644 index 0000000..e82fbf1 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3 \ No newline at end of file diff --git a/src/App.js b/src/App.js index 07906e8..ba1f7be 100644 --- a/src/App.js +++ b/src/App.js @@ -4,7 +4,6 @@ import { ThemeProvider } from './context/ThemeContext'; import { useAuthContext } from './context/AuthContext'; import { useUnreadSocket } from './hooks/useUnreadSocket'; import LandingPage from './pages/NakhodkaLanding'; -import RegisterPage from './components/Authentication'; import MainFeed from './pages/MainFeed'; import ChatPage from './pages/ChatPage'; import ChatListPage from './pages/ChatListPage'; @@ -25,8 +24,6 @@ function App() { } /> - - } /> } /> } /> } /> diff --git a/src/api/apiClient.jsx b/src/api/apiClient.jsx index c5e9741..15ba63a 100644 --- a/src/api/apiClient.jsx +++ b/src/api/apiClient.jsx @@ -1,3 +1,4 @@ +// src/api/apiClient.jsx import { API_URL } from "../config/authConstants"; function getAccessToken() { @@ -22,8 +23,6 @@ function clearTokens() { let refreshPromise = null; export async function refreshAccessToken() { - // Если несколько запросов одновременно словили 401 — не долбим /refresh параллельно, - // а ждём один и тот же промис if (refreshPromise) return refreshPromise; refreshPromise = (async () => { @@ -82,4 +81,35 @@ export async function apiFetch(url, options = {}) { return res; } +/** + * login/register — неавторизованные эндпоинты, без apiFetch (нет токена на входе). + * Обе бросают Error с сообщением от сервера или переданным fallback-текстом. + */ +export async function login(loginValue, password, fallbackErrorMessage) { + const res = await fetch(`${API_URL}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ login: loginValue, password }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.message || fallbackErrorMessage); + } + + return res.json(); // { token, refreshToken, user } +} + +export async function register(name, loginValue, password, fallbackErrorMessage) { + const res = await fetch(`${API_URL}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, login: loginValue, password }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message || fallbackErrorMessage); + return data; +} + export { getAccessToken, getRefreshToken, saveTokens, clearTokens }; \ No newline at end of file diff --git a/src/assets/bag.png b/src/assets/bag.png deleted file mode 100644 index dbee96c..0000000 Binary files a/src/assets/bag.png and /dev/null differ diff --git a/src/assets/card.png b/src/assets/card.png deleted file mode 100644 index cc103cb..0000000 Binary files a/src/assets/card.png and /dev/null differ diff --git a/src/assets/cat.png b/src/assets/cat.png deleted file mode 100644 index 54ba1c1..0000000 Binary files a/src/assets/cat.png and /dev/null differ diff --git a/src/assets/dog.png b/src/assets/dog.png deleted file mode 100644 index e042c2d..0000000 Binary files a/src/assets/dog.png and /dev/null differ diff --git a/src/assets/glasses.png b/src/assets/glasses.png deleted file mode 100644 index bba7315..0000000 Binary files a/src/assets/glasses.png and /dev/null differ diff --git a/src/assets/headphones.png b/src/assets/headphones.png deleted file mode 100644 index ad4fd73..0000000 Binary files a/src/assets/headphones.png and /dev/null differ diff --git a/src/assets/keys.png b/src/assets/keys.png deleted file mode 100644 index ce68cb2..0000000 Binary files a/src/assets/keys.png and /dev/null differ diff --git a/src/assets/parcel.png b/src/assets/parcel.png deleted file mode 100644 index e9225f3..0000000 Binary files a/src/assets/parcel.png and /dev/null differ diff --git a/src/assets/passwords_keys_6032.png b/src/assets/passwords_keys_6032.png deleted file mode 100644 index 7f37a85..0000000 Binary files a/src/assets/passwords_keys_6032.png and /dev/null differ diff --git a/src/assets/phone.png b/src/assets/phone.png deleted file mode 100644 index 1758e6b..0000000 Binary files a/src/assets/phone.png and /dev/null differ diff --git a/src/assets/wallet.png b/src/assets/wallet.png deleted file mode 100644 index a556415..0000000 Binary files a/src/assets/wallet.png and /dev/null differ diff --git a/src/assets/watch.png b/src/assets/watch.png deleted file mode 100644 index 891cfe5..0000000 Binary files a/src/assets/watch.png and /dev/null differ diff --git a/src/components/AuthModal.jsx b/src/components/AuthModal.jsx index 288034a..39288a8 100644 --- a/src/components/AuthModal.jsx +++ b/src/components/AuthModal.jsx @@ -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 ( -
e.target === e.currentTarget && onClose()}> -
- {/* Header */} -
+
e.target === e.currentTarget && onClose()}> +
+
{mode === "login" ? t('auth.loginModalTitle') : t('auth.registerModalTitle')} - -
+ +
- {mode === "login" - ? setMode("register")} - /> - : setMode("login")} - /> - } + {mode === "login" + ? setMode("register")} + /> + : setMode("login")} + /> + } +
-
); } @@ -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 ( - <> -
- - { setLogin(e.target.value); setError(""); }} - autoComplete="off" - /> -
+
+
+ + { setLogin(e.target.value); setError(""); }} + autoComplete="off" + /> +
-
- - { setPassword(e.target.value); setError(""); }} - onKeyDown={e => e.key === "Enter" && handleSubmit()} - /> -
+
+ + { setPassword(e.target.value); setError(""); }} + /> +
- {error &&

{error}

} + {error &&

{error}

} - + -

- {t('auth.noAccount')}{" "} - -

- +

+ {t('auth.noAccount')}{" "} + +

+ ); } @@ -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 ( - <> -
- - setField("name", e.target.value)} - /> - {errors.name &&

{errors.name}

} -
- -
- - setField("login", e.target.value)} - /> - {errors.login &&

{errors.login}

} -
- -
+
- + 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 &&

{errors.password}

} + {errors.name &&

{errors.name}

}
+
- + 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 &&

{errors.password2}

} + {errors.login &&

{errors.login}

}
-
- {error &&

{error}

} +
+
+ + setField("password", e.target.value)} + /> + {errors.password &&

{errors.password}

} +
+
+ + setField("password2", e.target.value)} + /> + {errors.password2 &&

{errors.password2}

} +
+
- + {error &&

{error}

} -

- {t('auth.hasAccount')}{" "} - -

- + + +

+ {t('auth.hasAccount')}{" "} + +

+ ); } // ─── Shared submit button ────────────────────────────────────────────────── -function SubmitButton({ loading, label, onClick }) { +function SubmitButton({ loading, label }) { return ( - + ); -} +} \ No newline at end of file diff --git a/src/components/Authentication.jsx b/src/components/Authentication.jsx deleted file mode 100644 index c45e981..0000000 --- a/src/components/Authentication.jsx +++ /dev/null @@ -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 ( - - ); -} - -// ─── Step dots ──────────────────────────────────────────────────────────────── -function StepDots({ current, total }) { - return ( -
- {Array.from({ length: total }).map((_, i) => ( -
- ))} -
- ); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// 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 ( -
-

{t('auth.welcomeBack')}

- -
{ e.preventDefault(); login(email, password); }} noValidate> -
- setEmail(e.target.value)} - autoComplete="off" - readOnly - onFocus={(e) => e.target.removeAttribute("readOnly")} - /> -
- -
- setPassword(e.target.value)} - style={{ paddingRight: "44px" }} - autoComplete="off" - readOnly - onFocus={(e) => e.target.removeAttribute("readOnly")} - /> - setShowPass(v => !v)} /> -
- - {error &&

{error}

} - - -
- - - -

- {t('auth.noAccount')}{" "} - -

-
- ); -} - -// ─── 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 ( - <> -
- - { - onChange("name", e.target.value); - setErrors(p => ({...p, name: ""})); - }} - autoComplete="off" - /> - {errors.name &&

{errors.name}

} -
- -
- - { - onChange("login", e.target.value); - setErrors(p => ({...p, login: ""})); - }} - autoComplete="off" - /> - - {checkingLogin &&

{t('auth.checking')}

} - - {!checkingLogin && data.login.length >= 3 && ( -

- {loginExists ? t('auth.loginTaken') : t('auth.loginFree')} -

- )} - - {errors.login &&

{errors.login}

} -
- -
- - -
- { - onChange("password", e.target.value); - setErrors(p => ({...p, password: ""})); - }} - autoComplete="new-password" - /> - setShow1(v => !v)}/> -
- - {data.password && ( - <> -
- {[0, 1, 2, 3].map(i => ( -
- ))} -
-

- {strength.label} -

- - )} - - {errors.password &&

{errors.password}

} -
- - -
- - -
- { - onChange("password2", e.target.value); - setErrors(p => ({...p, password2: ""})); - }} - autoComplete="new-password" - /> - setShow2(v => !v)}/> -
- - {data.password2 && ( -

- {matched ? t('auth.passwordsMatch') : t('auth.passwordsNoMatch')} -

- )} - - {errors.password2 &&

{errors.password2}

} -
- - - - ); -} - -// ─── step 2: confirm ────────────────────────────────────────────────────────── -function RegStep2({data, onSubmit, onBack, loading, error}) { - const { t } = useTranslation(); - return ( - <> -
-
- {t('auth.nameLabel')} - {data.name} -
-
-
- {t('auth.loginLabel')} - @{data.login} -
-
- {error &&

{error}

} - - - - ); -} - -// ─── success ────────────────────────────────────────────────────────────────── -function RegSuccess({name, onLogin}) { - const { t } = useTranslation(); - return ( -
- success -

{t('auth.welcome', { name })}

-

{t('auth.accountCreated')}

- -
- ); -} - -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 ( -
- -
- ); - } - - return ( -
-

{t('auth.createAccount')}

- - - {step === 0 && ( - setStep(1)} /> - )} - {step === 1 && ( - register({ name: form.name, login: form.login, password: form.password })} - onBack={() => setStep(0)} - loading={loading} - error={error} - /> - )} - - {step === 0 && ( -

- {t('auth.hasAccount')}{" "} - -

- )} -
- ); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// 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 ( -
-
- - {FLOAT_ICONS.map(({ icon, delay, size, ...pos }, i) => ( - - ))} - -
- - -
- -
-

{t('app.name')}

- - {mode === "login" - ? switchTo("register")} /> - : switchTo("login")} /> - } -
-
- ); -} diff --git a/src/components/CityFilter.jsx b/src/components/CityFilter.jsx new file mode 100644 index 0000000..ee5e631 --- /dev/null +++ b/src/components/CityFilter.jsx @@ -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 ( +
+
setOpen(v => !v)}> + + + + + +
+ {value.map(city => ( + + {city} + + + ))} + { + setQuery(e.target.value); + setOpen(true); + }} + onClick={e => e.stopPropagation()} + onKeyDown={handleKeyDown} + /> +
+ + {value.length > 0 && ( + + )} + + + + +
+ + {open && ( +
+ {options.length === 0 && !canAddCustom && ( +
{t('feed.filterCitiesEmpty')}
+ )} + + {canAddCustom && ( + + )} + + {options.map(city => { + const selected = value.includes(city); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/CreateUpdatePost.jsx b/src/components/CreateUpdatePost.jsx deleted file mode 100644 index 0b3b2ea..0000000 --- a/src/components/CreateUpdatePost.jsx +++ /dev/null @@ -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 ( - <> - {/* Клик по фону (не по самой модалке) закрывает её */} -
e.target === e.currentTarget && onClose()}> -
- - {/* Шапка модалки */} -
- - {isEdit ? t('post.editTitle') : t('post.newTitle')} - - -
- - {/* Тип: Потеря / Находка */} -
- -
- - -
-
- - {/* Название */} -
- - set("title", e.target.value)} - /> - {errors.title &&

{errors.title}

} -
- - {/* Описание */} -
- -