fixed code

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

View File

@@ -1,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 };
}

View File

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

View File

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

View 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]);
}

View 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 };
}

View File

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

View File

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

View File

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

View File

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

View 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,
};
}

View 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 };
}

View File

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

View 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;
}

View 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
View 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
View 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 };
}

View File

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

View File

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

View File

@@ -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
View 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,
};
}

View 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 };
}

View File

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

View File

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