fixed code
This commit is contained in:
@@ -1,50 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { saveTokens } from "../api/apiClient";
|
||||
|
||||
export function useAuth() {
|
||||
const navigate = useNavigate();
|
||||
const { login: saveAuth } = useAuthContext();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const login = async (login, password) => {
|
||||
if (!login || !password) { setError("Enter login and password"); 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 || "Invalid login or password");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log("LOGIN RESPONSE", data);
|
||||
|
||||
const { token, refreshToken, user } = data;
|
||||
|
||||
console.log("ACCESS", token);
|
||||
console.log("REFRESH", refreshToken);
|
||||
|
||||
saveTokens(token, refreshToken);
|
||||
saveAuth(user, token);
|
||||
|
||||
navigate("/feed");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { login, loading, error };
|
||||
}
|
||||
@@ -22,7 +22,6 @@ export function useChatSocket(chatId, onMessage) {
|
||||
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
|
||||
reconnectDelay: 3000,
|
||||
beforeConnect: async () => {
|
||||
// На каждую попытку коннекта — свежий токен из localStorage
|
||||
client.connectHeaders = {
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
};
|
||||
@@ -30,13 +29,16 @@ export function useChatSocket(chatId, onMessage) {
|
||||
onConnect: () => {
|
||||
setConnected(true);
|
||||
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
|
||||
const body = JSON.parse(msg.body);
|
||||
onMessageRef.current(body);
|
||||
try {
|
||||
const body = JSON.parse(msg.body);
|
||||
onMessageRef.current(body);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse chat message:", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
onDisconnect: () => setConnected(false),
|
||||
onStompError: async (frame) => {
|
||||
// Если сервер отклонил CONNECT из-за протухшего токена — пробуем обновить и переподключиться
|
||||
if (frame.headers?.message?.includes("Unauthorized")) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
@@ -53,12 +55,14 @@ export function useChatSocket(chatId, onMessage) {
|
||||
return () => client.deactivate();
|
||||
}, [chatId]);
|
||||
|
||||
/** @returns {boolean} true если сообщение реально ушло в сокет, false если соединения нет */
|
||||
const sendMessage = useCallback((content) => {
|
||||
if (!clientRef.current?.connected) return;
|
||||
if (!clientRef.current?.connected) return false;
|
||||
clientRef.current.publish({
|
||||
destination: "/app/chat.send",
|
||||
body: JSON.stringify({ chatId, content }),
|
||||
});
|
||||
return true;
|
||||
}, [chatId]);
|
||||
|
||||
return { connected, sendMessage };
|
||||
|
||||
@@ -1,47 +1,57 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
import { cachePostsFromChats, setPostForChat } from "../stores/chatPostStore";
|
||||
import { useUnread } from "../context/UnreadContext";
|
||||
|
||||
export function useChats() {
|
||||
const { t } = useTranslation();
|
||||
const [chats, setChats] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { updateFromChats, clearChatUnread } = useUnread();
|
||||
|
||||
const fetchChats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/api/v1/chats`);
|
||||
if (!res.ok) throw new Error(t('chats.fetchError'));
|
||||
|
||||
const data = await res.json();
|
||||
// API may return an array directly or a wrapper object like { items: [] } / { content: [] }
|
||||
const list = Array.isArray(data) ? data : (data.items ?? data.content ?? []);
|
||||
cachePostsFromChats(list);
|
||||
setChats(list);
|
||||
updateFromChats(list);
|
||||
} catch (e) {
|
||||
setError(e.message || t('chats.fetchError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [updateFromChats, t]);
|
||||
|
||||
const createOrGetChat = useCallback(async (otherUserId, postId, postInfo) => {
|
||||
const url = new URL(`${API_URL}/api/v1/chats/with/${otherUserId}`);
|
||||
if (postId) url.searchParams.set("postId", postId);
|
||||
|
||||
const res = await apiFetch(url, { method: "POST" });
|
||||
if (!res.ok) throw new Error("Не удалось создать чат");
|
||||
if (!res.ok) throw new Error(t('chats.createError'));
|
||||
const chat = await res.json();
|
||||
if (postInfo && chat.id) {
|
||||
setPostForChat(chat.id, postInfo);
|
||||
}
|
||||
return chat;
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const fetchMessages = useCallback(async (chatId) => {
|
||||
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}/messages`);
|
||||
const data = await res.json();
|
||||
// Normalize: array or wrapper object
|
||||
return Array.isArray(data) ? data : (data.items ?? data.content ?? []);
|
||||
}, []);
|
||||
|
||||
const markAsRead = useCallback(async (chatId) => {
|
||||
clearChatUnread(chatId);
|
||||
try {
|
||||
await apiFetch(`${API_URL}/api/v1/chats/${chatId}/read`, {
|
||||
method: "POST",
|
||||
@@ -49,7 +59,7 @@ export function useChats() {
|
||||
} catch (e) {
|
||||
console.error("Failed to mark as read:", e);
|
||||
}
|
||||
}, []);
|
||||
}, [clearChatUnread]);
|
||||
|
||||
return { chats, loading, fetchChats, createOrGetChat, fetchMessages, markAsRead };
|
||||
return { chats, loading, error, fetchChats, createOrGetChat, fetchMessages, markAsRead };
|
||||
}
|
||||
35
src/hooks/useCities.js
Normal file
35
src/hooks/useCities.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
|
||||
/**
|
||||
* useCities — загружает список уникальных городов с бэкенда.
|
||||
*
|
||||
* @returns {Object} { cities, loading, error }
|
||||
*/
|
||||
export function useCities() {
|
||||
const [cities, setCities] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/posts/cities`);
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setCities([...new Set(list.filter(c => c && c.trim()))]);
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to load cities");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
return { cities, loading, error };
|
||||
}
|
||||
41
src/hooks/useCityFeed.jsx
Normal file
41
src/hooks/useCityFeed.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchPosts } from "./useSearchPosts";
|
||||
|
||||
/**
|
||||
* Поиск объявлений по нескольким выбранным городам, с объединением
|
||||
* результатов без дублей. Автоматически перезапускается при смене
|
||||
* selectedCities.
|
||||
*/
|
||||
export function useCityFeed(selectedCities) {
|
||||
const { loading: cityLoading, searchPosts } = useSearchPosts();
|
||||
const [cityAds, setCityAds] = useState([]);
|
||||
|
||||
const searchCities = useCallback((citiesToSearch) => {
|
||||
if (!citiesToSearch.length) return Promise.resolve([]);
|
||||
return Promise.all(citiesToSearch.map(c => searchPosts(c))).then(results => {
|
||||
const seen = new Set();
|
||||
const merged = [];
|
||||
results.flat().forEach(ad => {
|
||||
if (!seen.has(ad.id)) {
|
||||
seen.add(ad.id);
|
||||
merged.push(ad);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
}, [searchPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCities.length === 0) {
|
||||
setCityAds([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
searchCities(selectedCities).then(result => {
|
||||
if (!cancelled) setCityAds(result);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedCities, searchCities]);
|
||||
|
||||
return { cityAds, cityLoading, searchCities };
|
||||
}
|
||||
20
src/hooks/useClickOutside.jsx
Normal file
20
src/hooks/useClickOutside.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Вызывает callback при клике вне элемента, на который повешен ref.
|
||||
* @param {React.RefObject} ref
|
||||
* @param {Function} onOutsideClick
|
||||
* @param {boolean} enabled — можно временно отключать слушатель
|
||||
*/
|
||||
export function useClickOutside(ref, onOutsideClick, enabled = true) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleClick = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
onOutsideClick();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [ref, onOutsideClick, enabled]);
|
||||
}
|
||||
46
src/hooks/useContactSeller.js
Normal file
46
src/hooks/useContactSeller.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useChats } from "./useChats";
|
||||
|
||||
/**
|
||||
* useContactSeller — общая логика открытия/создания чата с автором объявления.
|
||||
* Используется в PostOnFeed (карточка в ленте) и ImageViewer (просмотр фото).
|
||||
*
|
||||
* @param {object} post — объявление: { id, userId, title, images, emoji }
|
||||
* @returns {{
|
||||
* contactSeller: (onBeforeNavigate?: function) => Promise<void>,
|
||||
* error: string,
|
||||
* clearError: function
|
||||
* }}
|
||||
*/
|
||||
export function useContactSeller(post) {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const { createOrGetChat } = useChats();
|
||||
|
||||
/**
|
||||
* @param {function} [onBeforeNavigate] — вызывается перед переходом в чат
|
||||
* (например, чтобы закрыть модалку просмотра фото до навигации)
|
||||
*/
|
||||
const contactSeller = useCallback(async (onBeforeNavigate) => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const chat = await createOrGetChat(post.userId, post.id, {
|
||||
title: post.title,
|
||||
images: post.images,
|
||||
emoji: post.emoji,
|
||||
});
|
||||
onBeforeNavigate?.();
|
||||
navigate(`/chats/${chat.id}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(t('postCard.contactError'));
|
||||
}
|
||||
}, [post, createOrGetChat, navigate, t]);
|
||||
|
||||
const clearError = useCallback(() => setError(""), []);
|
||||
|
||||
return { contactSeller, error, clearError };
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
|
||||
export function useCreatePost() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const createAd = async (formData) => { // ← принимаем FormData
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/api/v1/posts`, {
|
||||
method: "POST",
|
||||
// Content-Type НЕ указываем — браузер сам добавит multipart/form-data с boundary
|
||||
body: formData, // ← передаём FormData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || "Error creating ad");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
return data;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { createAd, loading, error, success };
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
import { useUnread } from "../context/UnreadContext";
|
||||
|
||||
export function useDeleteChat() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { clearChatUnread } = useUnread();
|
||||
|
||||
const deleteChat = useCallback(async (chatId) => {
|
||||
setLoading(true);
|
||||
@@ -13,17 +17,22 @@ export function useDeleteChat() {
|
||||
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ошибка ${res.status}`);
|
||||
let body;
|
||||
try { body = await res.json(); } catch { body = null; }
|
||||
throw new Error(body?.message || t('chat.deleteErrorWithStatus', { status: res.status }));
|
||||
}
|
||||
|
||||
clearChatUnread(chatId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to delete chat");
|
||||
setError(err.message || t('chat.deleteError'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t, clearChatUnread]);
|
||||
|
||||
return { deleteChat, loading, error };
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
|
||||
/**
|
||||
* useDeletePost — хук для удаления объявления.
|
||||
*
|
||||
* deletePost возвращает { success, error } вместо голого boolean — так
|
||||
* вызывающий код получает актуальный текст ошибки сразу, без риска прочитать
|
||||
* устаревшее значение error state до его обновления React'ом.
|
||||
*
|
||||
* @returns {Object} { deletePost, loading, error }
|
||||
*/
|
||||
export function useDeletePost() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -21,17 +27,20 @@ export function useDeletePost() {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ошибка ${res.status}`);
|
||||
let body;
|
||||
try { body = await res.json(); } catch { body = null; }
|
||||
throw new Error(body?.message || t('post.deleteErrorWithStatus', { status: res.status }));
|
||||
}
|
||||
|
||||
return true;
|
||||
return { success: true, error: null };
|
||||
} catch (err) {
|
||||
setError(err.message || "Не удалось удалить объявление");
|
||||
return false;
|
||||
const message = err.message || t('post.deleteError');
|
||||
setError(message);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
return { deletePost, loading, error };
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
// src/hooks/useFavorites.js
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from '../config/authConstants';
|
||||
import { apiFetch } from '../api/apiClient';
|
||||
|
||||
@@ -7,51 +9,59 @@ import { apiFetch } from '../api/apiClient';
|
||||
* Использует backend API для добавления/удаления лайков.
|
||||
*/
|
||||
export function useFavorites() {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
/**
|
||||
* Переключает лайк на посте через API.
|
||||
* @param {number} postId - ID поста
|
||||
* @returns {Promise<boolean>} - true если операция успешна
|
||||
* @returns {Promise<{success: boolean, error: string|null}>}
|
||||
*/
|
||||
const toggleFavorite = useCallback(async (postId) => {
|
||||
setError("");
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/api/v1/favorites/${postId}/toggle`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to toggle favorite: ${response.status}`);
|
||||
throw new Error(t('favorites.toggleError'));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
return false;
|
||||
return { success: true, error: null };
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
const message = err.message || t('favorites.toggleError');
|
||||
setError(message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
/**
|
||||
* Загружает список лайкнутых постов текущего пользователя.
|
||||
* @returns {Promise<Array>} - массив ID лайкнутых постов
|
||||
*/
|
||||
const loadFavorites = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/api/v1/favorites`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load favorites: ${response.status}`);
|
||||
throw new Error(t('favorites.loadError'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// API возвращает массив постов, извлекаем ID
|
||||
return Array.isArray(data) ? data.map(post => post.id) : [];
|
||||
} catch (error) {
|
||||
console.error('Error loading favorites:', error);
|
||||
} catch (err) {
|
||||
console.error('Error loading favorites:', err);
|
||||
setError(err.message || t('favorites.loadError'));
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
return {
|
||||
toggleFavorite,
|
||||
loadFavorites,
|
||||
error,
|
||||
};
|
||||
}
|
||||
81
src/hooks/useFeedActions.jsx
Normal file
81
src/hooks/useFeedActions.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// src/hooks/useFeedActions.js
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDeletePost } from "./useDeletePost";
|
||||
import { useFavorites } from "./useFavorites";
|
||||
import { usePostStatus } from "./usePostStatus";
|
||||
|
||||
/**
|
||||
* Объединяет действия над объявлениями (лайк, удаление, отметка "найдено")
|
||||
* и общий паттерн "выполнить действие -> перезагрузить ленту и мои объявления".
|
||||
*
|
||||
* Авторизация (Authorization header + авто-обновление токена) обрабатывается
|
||||
* внутри apiFetch — сам token сюда передавать не нужно, только isAuthenticated
|
||||
* как ранняя проверка перед запросом.
|
||||
*
|
||||
* Каждое действие (deletePost/toggleFavorite/markAsFound) возвращает
|
||||
* { success, error } напрямую из вызова — это исключает гонку между
|
||||
* обновлением error state дочернего хука и его чтением здесь же в том же
|
||||
* тике (React batching иначе мог бы подставить устаревшее значение).
|
||||
*
|
||||
* @param {Function} refresh — перезагрузить текущий список (лента/город)
|
||||
* @param {Function} loadMyPosts — перезагрузить "мои объявления"
|
||||
* @param {Function} onAuthRequired — вызывается когда нужна авторизация
|
||||
* @param {boolean} isAuthenticated
|
||||
*/
|
||||
export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthenticated, t }) {
|
||||
const { deletePost } = useDeletePost();
|
||||
const { toggleFavorite } = useFavorites();
|
||||
const { markAsFound } = usePostStatus();
|
||||
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const afterMutation = useCallback(() => {
|
||||
refresh();
|
||||
loadMyPosts();
|
||||
}, [refresh, loadMyPosts]);
|
||||
|
||||
const toggleLike = useCallback(async (id) => {
|
||||
if (!isAuthenticated) {
|
||||
onAuthRequired();
|
||||
return;
|
||||
}
|
||||
const result = await toggleFavorite(id);
|
||||
if (result.success) {
|
||||
afterMutation();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
}, [isAuthenticated, onAuthRequired, toggleFavorite, afterMutation]);
|
||||
|
||||
const handleDelete = useCallback(async (postId) => {
|
||||
if (!window.confirm(t('feed.confirmDelete'))) return;
|
||||
const result = await deletePost(postId);
|
||||
if (result.success) {
|
||||
afterMutation();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
}, [t, deletePost, afterMutation]);
|
||||
|
||||
const handleMarkFound = useCallback(async (postId) => {
|
||||
if (!window.confirm(t('feed.confirmMarkFound'))) return;
|
||||
const result = await markAsFound(postId);
|
||||
if (result.success) {
|
||||
afterMutation();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
}, [t, markAsFound, afterMutation]);
|
||||
|
||||
const clearError = useCallback(() => setError(""), []);
|
||||
|
||||
return {
|
||||
toggleLike,
|
||||
handleDelete,
|
||||
handleMarkFound,
|
||||
handleNewAd: afterMutation,
|
||||
handleEditSave: afterMutation,
|
||||
error,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
50
src/hooks/useFeedPosts.jsx
Normal file
50
src/hooks/useFeedPosts.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Управляет основной лентой объявлений: загрузка, пагинация, поиск.
|
||||
*/
|
||||
export function useFeedPosts() {
|
||||
const [ads, setAds] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
|
||||
if (pageNum === 0) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams({ page: pageNum, size: PAGE_SIZE });
|
||||
if (search) params.set("search", search);
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1/posts?${params}`);
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const content = data.content ?? [];
|
||||
const normalizedAds = content.map(ad => ({
|
||||
...ad,
|
||||
type: ad.type ? ad.type.toLowerCase() : "loss",
|
||||
}));
|
||||
|
||||
setAds(prev => (append ? [...prev, ...normalizedAds] : normalizedAds));
|
||||
setHasMore(!data.last);
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to load ads");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { ads, loading, error, page, hasMore, loadingMore, loadFeed };
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { compressImage } from "../utils/compressImage";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
|
||||
const MAX_SIZE_MB = 5;
|
||||
export const MAX_SIZE_MB = 5;
|
||||
export const ACCEPT = "image/jpeg,image/png,image/webp";
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,7 @@ function generateImageId() {
|
||||
}
|
||||
|
||||
export function useImageUpload(existingUrls = []) {
|
||||
const { t } = useTranslation();
|
||||
const [images, setImages] = useState([]);
|
||||
const [imgError, setImgError] = useState("");
|
||||
const [compressing, setCompressing] = useState(false);
|
||||
@@ -45,11 +47,11 @@ export function useImageUpload(existingUrls = []) {
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setImgError("Images only (JPEG, PNG, WebP)");
|
||||
setImgError(t('imageUpload.invalidType'));
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
|
||||
setImgError(`File too large — max ${MAX_SIZE_MB} MB`);
|
||||
setImgError(t('imageUpload.tooLarge', { maxSize: MAX_SIZE_MB }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -66,12 +68,12 @@ export function useImageUpload(existingUrls = []) {
|
||||
}));
|
||||
setImages(prev => [...prev, ...newImages]);
|
||||
} catch {
|
||||
setImgError("Failed to process image");
|
||||
setImgError(t('imageUpload.processingFailed'));
|
||||
} finally {
|
||||
setCompressing(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const removeImage = useCallback((id) => {
|
||||
setImages(prev => {
|
||||
@@ -91,15 +93,22 @@ export function useImageUpload(existingUrls = []) {
|
||||
return images.filter(i => i.isExisting).map(i => i.serverUrl);
|
||||
}, [images]);
|
||||
|
||||
// Ref нужен, чтобы cleanup при размонтировании видел АКТУАЛЬНЫЙ список
|
||||
// изображений, а не тот, что был на момент первого рендера (иначе
|
||||
// object URL'ы для реально загруженных файлов никогда не отзываются).
|
||||
const imagesRef = useRef(images);
|
||||
useEffect(() => {
|
||||
imagesRef.current = images;
|
||||
}, [images]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
images.forEach(img => {
|
||||
imagesRef.current.forEach(img => {
|
||||
if (!img.isExisting && img.preview) {
|
||||
URL.revokeObjectURL(img.preview);
|
||||
}
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
27
src/hooks/useInfiniteScroll.jsx
Normal file
27
src/hooks/useInfiniteScroll.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Универсальный хук для бесконечного скролла через IntersectionObserver.
|
||||
* Возвращает ref, который нужно повесить на сентинел-элемент в конце списка.
|
||||
*
|
||||
* @param {Function} onIntersect — вызывается, когда сентинел попадает в вьюпорт
|
||||
* @param {boolean} enabled — можно ли сейчас триггерить подгрузку
|
||||
*/
|
||||
export function useInfiniteScroll(onIntersect, enabled) {
|
||||
const observerRef = useRef(null);
|
||||
|
||||
const sentinelRef = useCallback(node => {
|
||||
if (!enabled) return;
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
|
||||
observerRef.current = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && enabled) {
|
||||
onIntersect();
|
||||
}
|
||||
}, { rootMargin: "200px" });
|
||||
|
||||
if (node) observerRef.current.observe(node);
|
||||
}, [enabled, onIntersect]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
22
src/hooks/useLanguageToggle.js
Normal file
22
src/hooks/useLanguageToggle.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* useLanguageToggle — переключение между ru/en.
|
||||
*
|
||||
* i18next сам сохраняет выбранный язык в localStorage
|
||||
* через LanguageDetector (ключ 'i18nextLng'),
|
||||
* если detection.caches включает localStorage.
|
||||
*/
|
||||
export function useLanguageToggle() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next = i18n.language === 'ru' ? 'en' : 'ru';
|
||||
i18n.changeLanguage(next);
|
||||
};
|
||||
|
||||
return {
|
||||
language: i18n.language,
|
||||
toggleLanguage,
|
||||
};
|
||||
}
|
||||
21
src/hooks/useModals.jsx
Normal file
21
src/hooks/useModals.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Управляет всеми модалками MainFeed через единое состояние,
|
||||
* чтобы гарантировать что открыта максимум одна модалка сразу.
|
||||
*
|
||||
* Типы: "create" | "edit" | "profile" | "auth" | "rules" | "viewer"
|
||||
*/
|
||||
export function useModals() {
|
||||
const [modal, setModal] = useState(null); // { type, payload }
|
||||
|
||||
const open = useCallback((type, payload = null) => {
|
||||
setModal({ type, payload });
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => setModal(null), []);
|
||||
|
||||
const isOpen = useCallback((type) => modal?.type === type, [modal]);
|
||||
|
||||
return { modal, open, close, isOpen };
|
||||
}
|
||||
38
src/hooks/useMyPosts.jsx
Normal file
38
src/hooks/useMyPosts.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
|
||||
/**
|
||||
* Загружает объявления текущего пользователя по userId.
|
||||
*/
|
||||
export function useMyPosts(userId) {
|
||||
const [myAds, setMyAds] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadMyPosts = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/posts/${userId}`);
|
||||
if (res.status === 404) {
|
||||
setMyAds([]);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const data = await res.json();
|
||||
const posts = Array.isArray(data) ? data : (data.items ?? []);
|
||||
const normalizedPosts = posts.map(ad => ({
|
||||
...ad,
|
||||
type: ad.type ? ad.type.toLowerCase() : "loss",
|
||||
}));
|
||||
setMyAds(normalizedPosts);
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to load your ads");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
return { myAds, loading, error, loadMyPosts };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
|
||||
@@ -19,6 +20,7 @@ import { apiFetch } from "../api/apiClient";
|
||||
* null — серверная ошибка (свойство error содержит текст)
|
||||
*/
|
||||
export function usePostMutation() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -46,12 +48,12 @@ export function usePostMutation() {
|
||||
return { blocked: true, reason: body.reason || "" };
|
||||
}
|
||||
|
||||
throw new Error(body?.message || `Ошибка сервера: ${res.status}`);
|
||||
throw new Error(body?.message || t('post.serverError', { status: res.status }));
|
||||
}
|
||||
|
||||
return { data: await res.json() };
|
||||
} catch (e) {
|
||||
setError(e.message || "Неизвестная ошибка");
|
||||
setError(e.message || t('post.unknownError'));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,66 +1,61 @@
|
||||
import { useCallback } from 'react';
|
||||
// src/hooks/usePostStatus.js
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from '../config/authConstants';
|
||||
import { apiFetch } from '../api/apiClient';
|
||||
|
||||
/**
|
||||
* Хук для изменения статуса поста (ACTIVE/CLOSED).
|
||||
* Используется для отметки "Вещь найдена".
|
||||
* Используется для отметки "Вещь найдена" и (в будущем) для reopen.
|
||||
*/
|
||||
export function usePostStatus() {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
/**
|
||||
* Изменяет статус поста на CLOSED (вещь найдена).
|
||||
* @param {number} postId - ID поста
|
||||
* @returns {Promise<boolean>} - true если операция успешна
|
||||
* @returns {Promise<{success: boolean, error: string|null}>}
|
||||
*/
|
||||
const markAsFound = useCallback(async (postId) => {
|
||||
const updateStatus = useCallback(async (postId, status) => {
|
||||
setError("");
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/api/v1/posts/${postId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update status: ${response.status}`);
|
||||
throw new Error(t('post.statusUpdateError'));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error marking post as found:', error);
|
||||
return false;
|
||||
return { success: true, error: null };
|
||||
} catch (err) {
|
||||
console.error('Error updating post status:', err);
|
||||
const message = err.message || t('post.statusUpdateError');
|
||||
setError(message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
/**
|
||||
* Изменяет статус поста на CLOSED (вещь найдена).
|
||||
* @param {number} postId - ID поста
|
||||
* @returns {Promise<{success: boolean, error: string|null}>}
|
||||
*/
|
||||
const markAsFound = useCallback((postId) => updateStatus(postId, 'CLOSED'), [updateStatus]);
|
||||
|
||||
/**
|
||||
* Возвращает статус поста в ACTIVE (если нужно reopen).
|
||||
* @param {number} postId - ID поста
|
||||
* @returns {Promise<boolean>} - true если операция успешна
|
||||
* @returns {Promise<{success: boolean, error: string|null}>}
|
||||
*/
|
||||
const reopenPost = useCallback(async (postId) => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/api/v1/posts/${postId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: 'ACTIVE' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update status: ${response.status}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error reopening post:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
const reopenPost = useCallback((postId) => updateStatus(postId, 'ACTIVE'), [updateStatus]);
|
||||
|
||||
return {
|
||||
markAsFound,
|
||||
reopenPost,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { saveTokens } from "../api/apiClient";
|
||||
|
||||
export function useCheckLogin(login) {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [exists, setExists] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!login || login.length < 3) {
|
||||
setExists(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
setChecking(true);
|
||||
const res = await fetch(`${API_URL}/api/v1/auth/check-login?login=${encodeURIComponent(login)}`);
|
||||
const json = await res.json();
|
||||
setExists(json.exists);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [login]);
|
||||
|
||||
return { checking, exists };
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const register = async ({ name, login, password }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, login, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || "Ошибка регистрации");
|
||||
}
|
||||
|
||||
saveTokens(data.token, data.refreshToken);
|
||||
setSuccess(true);
|
||||
setTimeout(() => navigate("/feed"), 1500);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { register, loading, error, success };
|
||||
}
|
||||
91
src/hooks/useReviews.jsx
Normal file
91
src/hooks/useReviews.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
|
||||
/**
|
||||
* Загрузка/отправка отзывов + производные данные (средний рейтинг,
|
||||
* распределение по звёздам).
|
||||
*/
|
||||
export function useReviews({ token, isAuthenticated, onAuthRequired, authorFallbackName }) {
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState("");
|
||||
|
||||
const loadReviews = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/reviews`);
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : data.items ?? data.reviews ?? [];
|
||||
setReviews(list);
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to load reviews");
|
||||
setReviews([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitReview = useCallback(async ({ rating, text }) => {
|
||||
if (!isAuthenticated) {
|
||||
onAuthRequired();
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setSubmitError("");
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/reviews`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ rating, text }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
await loadReviews();
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || "Failed to submit review");
|
||||
// Отзыв добавляется локально с пометкой "не сохранено",
|
||||
// чтобы пользователь видел, что отправка НЕ удалась,
|
||||
// а не что всё прошло успешно.
|
||||
setReviews((prev) => [
|
||||
{
|
||||
id: `pending-${Date.now()}`,
|
||||
authorName: authorFallbackName,
|
||||
rating,
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
pending: true,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [isAuthenticated, onAuthRequired, token, loadReviews, authorFallbackName]);
|
||||
|
||||
const avgRating = reviews.length
|
||||
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
|
||||
: null;
|
||||
|
||||
const distribution = useMemo(() => {
|
||||
const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
reviews.forEach((r) => {
|
||||
if (counts[r.rating] !== undefined) counts[r.rating] += 1;
|
||||
});
|
||||
return [5, 4, 3, 2, 1].map((star) => ({
|
||||
star,
|
||||
count: counts[star],
|
||||
pct: reviews.length ? Math.round((counts[star] / reviews.length) * 100) : 0,
|
||||
}));
|
||||
}, [reviews]);
|
||||
|
||||
return {
|
||||
reviews, loading, error, submitting, submitError,
|
||||
loadReviews, submitReview, avgRating, distribution,
|
||||
};
|
||||
}
|
||||
43
src/hooks/useSearchPosts.js
Normal file
43
src/hooks/useSearchPosts.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
|
||||
/**
|
||||
* useSearchPosts — поиск объявлений по городу.
|
||||
* Ходит на GET /api/v1/posts/search?city=..., возвращает список без пагинации.
|
||||
*
|
||||
* @returns {Object} { posts, loading, error, searchPosts }
|
||||
*/
|
||||
export function useSearchPosts() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const searchPosts = useCallback(async (city) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (city) params.set("city", city);
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1/posts/search?${params}`);
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
const normalized = list.map(ad => ({
|
||||
...ad,
|
||||
type: ad.type ? ad.type.toLowerCase() : "loss"
|
||||
}));
|
||||
setPosts(normalized);
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to search posts");
|
||||
setPosts([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { posts, loading, error, searchPosts };
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
|
||||
export function useUpdatePost() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const updateAd = async (id, formData) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/api/v1/posts/${id}`, {
|
||||
method: "PUT",
|
||||
body: formData, // FormData
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to save");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { updateAd, loading, error };
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const YANDEX_GEOCODER_API_KEY = import.meta.env.VITE_YANDEX_GEOCODER_KEY;
|
||||
|
||||
export function useYandexMap(enabled, initialCoords = null, initialAddress = "") {
|
||||
const mapRef = useRef(null);
|
||||
const ymapRef = useRef(null);
|
||||
const placemarkRef = useRef(null);
|
||||
const initializedRef = useRef(false); // защита от двойного вызова StrictMode
|
||||
const requestIdRef = useRef(0); // защита от гонки при быстрых кликах/драгах
|
||||
|
||||
const [coords, setCoords] = useState(initialCoords);
|
||||
const [address, setAddress] = useState(initialAddress ?? "");
|
||||
const addressRef = useRef(initialAddress ?? ""); // ← добавьте
|
||||
const addressRef = useRef(initialAddress ?? "");
|
||||
|
||||
const resolveAddress = useCallback((lat, lng) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
const resolveAddress = (lat, lng) => {
|
||||
fetch(
|
||||
`https://geocode-maps.yandex.ru/1.x/?apikey=c2d3c717-a109-4494-8863-7edbc32044e3&geocode=${lng},${lat}&format=json&lang=ru_RU`
|
||||
`https://geocode-maps.yandex.ru/1.x/?apikey=${YANDEX_GEOCODER_API_KEY}&geocode=${lng},${lat}&format=json&lang=ru_RU`
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Игнорируем ответ, если пришёл более новый запрос — иначе
|
||||
// устаревший ответ может перезаписать адрес актуальных координат.
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
const found =
|
||||
data?.response?.GeoObjectCollection?.featureMember?.[0]
|
||||
?.GeoObject?.metaDataProperty?.GeocoderMetaData?.text;
|
||||
@@ -25,11 +33,13 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
||||
addressRef.current = resolved;
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
const fallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||||
setAddress(fallback);
|
||||
addressRef.current = fallback;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !mapRef.current) return;
|
||||
@@ -37,7 +47,6 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
||||
initializedRef.current = true;
|
||||
|
||||
const init = () => {
|
||||
// Если карта уже создана — не создаём снова
|
||||
if (ymapRef.current) return;
|
||||
|
||||
const center = initialCoords
|
||||
@@ -90,13 +99,12 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Уничтожаем карту только при размонтировании компонента
|
||||
ymapRef.current?.destroy();
|
||||
ymapRef.current = null;
|
||||
placemarkRef.current = null;
|
||||
initializedRef.current = false;
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, initialCoords, resolveAddress]);
|
||||
|
||||
return { mapRef, coords, address, addressRef };
|
||||
}
|
||||
Reference in New Issue
Block a user