fixed code
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useChats } from "../hooks/useChats";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { useUnread } from "../context/UnreadContext";
|
||||
import { getPostForChat } from "../stores/chatPostStore";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
import { useDeleteChat } from "../hooks/useDeleteChat";
|
||||
import TrashIcon from "../components/icons/TrashIcon";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChatHeader from "../components/chat/ChatHeader";
|
||||
import ChatListItem from "../components/chat/ChatListItem";
|
||||
|
||||
export default function ChatListPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { chats, loading, fetchChats } = useChats();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const { updateFromChats, unreadMap } = useUnread();
|
||||
const { unreadMap } = useUnread();
|
||||
const { deleteChat } = useDeleteChat();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,98 +23,52 @@ export default function ChatListPage() {
|
||||
}
|
||||
}, [isAuthenticated, fetchChats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chats.length > 0) {
|
||||
updateFromChats(chats);
|
||||
}
|
||||
}, [chats, updateFromChats]);
|
||||
|
||||
const handleDeleteChat = useCallback(async (chatId) => {
|
||||
if (!window.confirm(t('chat.confirmDeleteChat'))) return;
|
||||
const ok = await deleteChat(chatId);
|
||||
if (ok) fetchChats();
|
||||
}, [t, deleteChat, fetchChats]);
|
||||
|
||||
const goBack = () => navigate("/feed");
|
||||
const openChat = (chatId) => navigate(`/chats/${chatId}`);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="f-app">
|
||||
<div className="chat-page">
|
||||
<div className="chat-header">
|
||||
<button className="chat-back-btn" onClick={() => navigate("/feed")}>
|
||||
←
|
||||
</button>
|
||||
<div className="chat-header-title">{t('chat.chats')}</div>
|
||||
<div />
|
||||
<div className="f-app">
|
||||
<div className="chat-page">
|
||||
<ChatHeader title={t('chat.chats')} onBack={goBack} />
|
||||
<div className="chat-empty-full">{t('chat.loginToSee')}</div>
|
||||
</div>
|
||||
<div className="chat-empty-full">{t('chat.loginToSee')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="f-app">
|
||||
<div className="chat-page">
|
||||
<div className="chat-header">
|
||||
<button className="chat-back-btn" onClick={() => navigate("/feed")}>
|
||||
←
|
||||
</button>
|
||||
<div className="chat-header-title">{t('chat.chats')}</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="f-app">
|
||||
<div className="chat-page">
|
||||
<ChatHeader title={t('chat.chats')} onBack={goBack} />
|
||||
|
||||
<div className="chat-list">
|
||||
{loading && (
|
||||
<div className="chat-empty-full">{t('chat.loading')}</div>
|
||||
)}
|
||||
<div className="chat-list">
|
||||
{loading && (
|
||||
<div className="chat-empty-full">{t('chat.loading')}</div>
|
||||
)}
|
||||
|
||||
{!loading && chats.length === 0 && (
|
||||
<div className="chat-empty-full">{t('chat.noChats')}</div>
|
||||
)}
|
||||
{!loading && chats.length === 0 && (
|
||||
<div className="chat-empty-full">{t('chat.noChats')}</div>
|
||||
)}
|
||||
|
||||
{!loading && chats.map((chat) => {
|
||||
const post = getPostForChat(chat.id);
|
||||
const title = post?.title || chat.otherUser?.name || t('chat.user');
|
||||
const imgSrc = getImageUrl(post?.images?.[0]);
|
||||
const emoji = post?.emoji;
|
||||
const unreadCount = unreadMap[chat.id] ?? chat.unreadCount ?? chat.unreadMessages ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`chat-list-item${unreadCount > 0 ? " unread" : ""}`}
|
||||
onClick={() => navigate(`/chats/${chat.id}`)}
|
||||
>
|
||||
<div className="chat-list-avatar">
|
||||
{imgSrc ? (
|
||||
<img src={imgSrc} alt="" className="chat-list-avatar-img" />
|
||||
) : (
|
||||
emoji || chat.otherUser?.name?.[0] || "?"
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-list-info">
|
||||
<div className="chat-list-name">{title}</div>
|
||||
<div className="chat-list-preview">
|
||||
{chat.lastMessage?.content || t('chat.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<div className="chat-list-unread-badge">{unreadCount > 99 ? "99+" : unreadCount}</div>
|
||||
)}
|
||||
<button
|
||||
className="chat-list-delete-btn"
|
||||
title={t('chat.deleteChat')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteChat(chat.id);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && chats.map((chat) => (
|
||||
<ChatListItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
post={getPostForChat(chat.id)}
|
||||
unreadMap={unreadMap}
|
||||
onOpen={openChat}
|
||||
onDelete={handleDeleteChat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useChats } from "../hooks/useChats";
|
||||
import { useChatSocket } from "../hooks/useChatSocket";
|
||||
import { getPostForChat } from "../stores/chatPostStore";
|
||||
import { setActiveChatId } from "../stores/activeChatStore";
|
||||
import { useUnread } from "../context/UnreadContext";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import emojiData from "@emoji-mart/data";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChatHeader from "../components/chat/ChatHeader";
|
||||
import MessageBubble from "../components/chat/MessageBubble";
|
||||
import ChatInputBar from "../components/chat/ChatInputBar";
|
||||
import Toast from "../components/Toast";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,135 +19,125 @@ export default function ChatPage() {
|
||||
const navigate = useNavigate();
|
||||
const { fetchMessages, markAsRead } = useChats();
|
||||
const { clearChatUnread } = useUnread();
|
||||
const { user } = useAuthContext();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [sendError, setSendError] = useState("");
|
||||
const messagesEndRef = useRef(null);
|
||||
const emojiPickerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) {
|
||||
setShowEmoji(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
const myUserId = user?.id;
|
||||
|
||||
/** Мержит новое сообщение по id — защита от дублей (REST + WS эхо). */
|
||||
const addMessage = useCallback((msg) => {
|
||||
setMessages((prev) => {
|
||||
if (msg.id != null && prev.some((m) => m.id === msg.id)) return prev;
|
||||
return [...prev, msg];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const myUserId = (() => {
|
||||
try { return JSON.parse(localStorage.getItem("user"))?.id; }
|
||||
catch { return null; }
|
||||
})();
|
||||
|
||||
const handleIncoming = useCallback((msg) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
addMessage(msg);
|
||||
}, [addMessage]);
|
||||
|
||||
const { sendMessage, connected } = useChatSocket(chatId, handleIncoming);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) {
|
||||
setActiveChatId(chatId);
|
||||
fetchMessages(chatId).then(setMessages);
|
||||
markAsRead(chatId);
|
||||
clearChatUnread(chatId);
|
||||
}
|
||||
if (!chatId) return;
|
||||
|
||||
setActiveChatId(chatId);
|
||||
setLoadError("");
|
||||
|
||||
fetchMessages(chatId)
|
||||
.then((list) => {
|
||||
// Мержим историю с уже пришедшими по сокету сообщениями,
|
||||
// а не затираем их — иначе можно потерять сообщение, пришедшее
|
||||
// по WS, пока REST-запрос ещё letел.
|
||||
setMessages((prev) => {
|
||||
const byId = new Map(list.map((m) => [m.id, m]));
|
||||
const extra = prev.filter((m) => !byId.has(m.id));
|
||||
return [...list, ...extra];
|
||||
});
|
||||
})
|
||||
.catch(() => setLoadError(t('chat.loadError')));
|
||||
|
||||
markAsRead(chatId);
|
||||
clearChatUnread(chatId);
|
||||
|
||||
return () => setActiveChatId(null);
|
||||
}, [chatId, fetchMessages, markAsRead, clearChatUnread]);
|
||||
}, [chatId, fetchMessages, markAsRead, clearChatUnread, t]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = (e) => {
|
||||
const handleSend = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
sendMessage(input);
|
||||
setInput("");
|
||||
};
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
const sent = sendMessage(trimmed);
|
||||
if (!sent) {
|
||||
setSendError(t('chat.sendError'));
|
||||
return;
|
||||
}
|
||||
setInput("");
|
||||
}, [input, sendMessage, t]);
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setInput((prev) => prev + emoji.native);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const post = getPostForChat(chatId);
|
||||
const postImg = getImageUrl(post?.images?.[0]);
|
||||
|
||||
return (
|
||||
<div className="f-app">
|
||||
<div className="chat-page">
|
||||
<div className="chat-header">
|
||||
<button className="chat-back-btn" onClick={() => navigate("/chats")}>
|
||||
←
|
||||
</button>
|
||||
<div className="chat-header-avatar">
|
||||
{postImg ? (
|
||||
<img src={postImg} alt="" className="chat-header-avatar-img" />
|
||||
) : (
|
||||
post?.emoji || "📋"
|
||||
<div className="f-app">
|
||||
<div className="chat-page">
|
||||
<ChatHeader
|
||||
title={post?.title || t('chat.title')}
|
||||
onBack={() => navigate("/chats")}
|
||||
avatar={postImg ? (
|
||||
<img src={postImg} alt="" className="chat-header-avatar-img" />
|
||||
) : (
|
||||
post?.emoji || "📋"
|
||||
)}
|
||||
statusLabel={connected ? t('chat.online') : t('chat.offline')}
|
||||
statusConnected={connected}
|
||||
/>
|
||||
|
||||
<div className="chat-messages">
|
||||
{loadError && <div className="chat-empty">{loadError}</div>}
|
||||
{!loadError && messages.length === 0 && (
|
||||
<div className="chat-empty">{t('chat.noMessages')}</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<MessageBubble key={m.id} message={m} isMine={m.senderId === myUserId} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="chat-header-title">{post?.title || t('chat.title')}</div>
|
||||
<div className={`chat-status${connected ? " connected" : ""}`}>
|
||||
{connected ? t('chat.online') : t('chat.offline')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && (
|
||||
<div className="chat-empty">{t('chat.noMessages')}</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`chat-bubble${m.senderId === myUserId ? " mine" : ""}`}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-area">
|
||||
{showEmoji && (
|
||||
<div className="chat-emoji-picker" ref={emojiPickerRef}>
|
||||
<Picker
|
||||
data={emojiData}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
searchPosition="sticky"
|
||||
maxSearchResults={30}
|
||||
perLine={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSend} className="chat-input-row">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
<ChatInputBar
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSend={handleSend}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
showEmoji={showEmoji}
|
||||
onToggleEmoji={() => setShowEmoji((v) => !v)}
|
||||
onCloseEmoji={() => setShowEmoji(false)}
|
||||
connected={connected}
|
||||
placeholder={t('chat.messagePlaceholder')}
|
||||
className="chat-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-emoji-btn${showEmoji ? " active" : ""}`}
|
||||
onClick={() => setShowEmoji((v) => !v)}
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="chat-send-btn"
|
||||
disabled={!connected}
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
</form>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sendError && (
|
||||
<Toast
|
||||
message={sendError}
|
||||
type="error"
|
||||
onDismiss={() => setSendError("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
395
src/pages/CreateUpdatePost.jsx
Normal file
395
src/pages/CreateUpdatePost.jsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import CloseIcon from "../components/icons/CloseIcon";
|
||||
import RulesModal from "../components/RulesModal";
|
||||
import { EMPTY_FORM, CATEGORY_KEYS } from "../config/feedConstants";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePostMutation } from "../hooks/usePostMutation";
|
||||
import { useImageUpload, ACCEPT, MAX_SIZE_MB } from "../hooks/useImageUpload";
|
||||
import { validatePostForm } from "../utils/validatePostForm";
|
||||
import { formatPhone } from "../utils/formatPhone";
|
||||
import { useYandexMap } from "../hooks/useYandexMap";
|
||||
import { isMobileDevice } from "../utils/isMobileDevice";
|
||||
|
||||
/**
|
||||
* CreateUpdatePost — модальная форма создания и редактирования объявления.
|
||||
*
|
||||
* @param {function} onClose — закрыть модалку без сохранения
|
||||
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
|
||||
* @param {object} [initialData] — если передан, форма работает в режиме редактирования
|
||||
*/
|
||||
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
const isEdit = !!initialData;
|
||||
const { t } = useTranslation();
|
||||
|
||||
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,
|
||||
reward: initialData.reward ?? false
|
||||
}
|
||||
: 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;
|
||||
|
||||
const { mapRef, coords, address, addressRef } = useYandexMap(
|
||||
true,
|
||||
initialCoords,
|
||||
isEdit ? (initialData.location || "") : ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (address) {
|
||||
setForm(prev => ({ ...prev, address }));
|
||||
// Адрес пришёл — очищаем и ошибку поля, и "карта грузится",
|
||||
// иначе последняя может остаться висеть на экране навсегда.
|
||||
setErrors(prev => ({ ...prev, address: "", map: "" }));
|
||||
}
|
||||
}, [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());
|
||||
data.append("title", form.title.trim());
|
||||
data.append("description", form.description.trim());
|
||||
data.append("category", form.category);
|
||||
data.append("phone", form.phone.trim());
|
||||
data.append("reward", form.reward ? "true" : "false");
|
||||
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");
|
||||
|
||||
const result = await save(isEdit ? initialData.id : null, data);
|
||||
if (!result) return;
|
||||
|
||||
if (result.blocked) {
|
||||
setResultModal("blocked");
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
setResultModal("success");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="f-modal-bg"
|
||||
onClick={e => e.target === e.currentTarget && !loading && onClose()}
|
||||
>
|
||||
<div className="f-modal">
|
||||
|
||||
<div className="f-modal-head">
|
||||
<span className="f-modal-title">
|
||||
{isEdit ? t('post.editTitle') : t('post.newTitle')}
|
||||
</span>
|
||||
<button className="f-close-btn" type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Тип: Потеря / Находка */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.typeLabel')}</label>
|
||||
<div className="f-type-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
|
||||
onClick={() => set("type", "loss")}
|
||||
>
|
||||
{t('post.loss')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`f-type-opt${form.type === "found" ? " found" : ""}`}
|
||||
onClick={() => set("type", "found")}
|
||||
>
|
||||
{t('post.found')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Название */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.titleLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.title ? " err" : ""}`}
|
||||
type="text"
|
||||
placeholder={t('post.titlePlaceholder')}
|
||||
value={form.title}
|
||||
onChange={e => set("title", e.target.value)}
|
||||
/>
|
||||
{errors.title && <p className="f-err-msg">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
{/* Описание */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.descriptionLabel')}</label>
|
||||
<textarea
|
||||
className={`f-m-field${errors.description ? " err" : ""}`}
|
||||
rows={3}
|
||||
placeholder={t('post.descriptionPlaceholder')}
|
||||
value={form.description}
|
||||
onChange={e => set("description", e.target.value)}
|
||||
/>
|
||||
{errors.description && <p className="f-err-msg">{errors.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Фотографии */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.photosLabel')}</label>
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="f-img-grid">
|
||||
{uploadedImages.map(img => (
|
||||
<div key={img.id} className="f-img-grid-item">
|
||||
<img src={img.preview} alt="" className="f-img-grid-preview" />
|
||||
<button
|
||||
className="f-img-remove"
|
||||
onClick={() => removeImage(img.id)}
|
||||
type="button"
|
||||
aria-label={t('post.deletePhoto')}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="f-img-upload-label">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
multiple
|
||||
capture={isMobileDevice() ? "environment" : undefined}
|
||||
onChange={handleFileChange}
|
||||
className="f-img-input"
|
||||
disabled={compressing}
|
||||
/>
|
||||
{compressing ? (
|
||||
<span>{t('post.processing')}</span>
|
||||
) : (
|
||||
<span>{uploadedImages.length > 0 ? t('post.addMore') : t('post.addPhoto')}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{imgError && <p className="f-err-msg">{imgError}</p>}
|
||||
<p className="f-hint">{t('post.photoHint', { maxSize: MAX_SIZE_MB })}</p>
|
||||
</div>
|
||||
|
||||
{/* Категория */}
|
||||
<div className="f-m-row">
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.categoryLabel')}</label>
|
||||
<select
|
||||
className={`f-m-field${errors.category ? " err" : ""}`}
|
||||
value={form.category}
|
||||
onChange={e => set("category", e.target.value)}
|
||||
>
|
||||
<option value="">{t('categories.select')}</option>
|
||||
{CATEGORY_KEYS.map(k => <option key={k} value={t(k)}>{t(k)}</option>)}
|
||||
</select>
|
||||
{errors.category && <p className="f-err-msg">{errors.category}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Карта */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.mapLabel')}</label>
|
||||
<div className="f-map-frame">
|
||||
<div ref={mapRef} className="f-map-inner" />
|
||||
</div>
|
||||
|
||||
{!coords && <p className="f-hint">{t('post.mapHint')}</p>}
|
||||
{errors.map && <p className="f-err-msg">{errors.map}</p>}
|
||||
</div>
|
||||
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.addressLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.address ? " err" : ""}`}
|
||||
type="text"
|
||||
placeholder={t('post.addressPlaceholder')}
|
||||
value={form.address}
|
||||
onChange={e => set("address", e.target.value)}
|
||||
/>
|
||||
{errors.address && <p className="f-err-msg">{errors.address}</p>}
|
||||
</div>
|
||||
|
||||
{/* Телефон */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.phoneLabel')}</label>
|
||||
<input
|
||||
className={`f-m-field${errors.phone ? " err" : ""}`}
|
||||
type="tel"
|
||||
placeholder={t('post.phonePlaceholder')}
|
||||
value={form.phone}
|
||||
onChange={e => set("phone", formatPhone(e.target.value))}
|
||||
/>
|
||||
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
{/* Вознаграждение */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-reward-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.reward}
|
||||
onChange={e => set("reward", e.target.checked)}
|
||||
/>
|
||||
|
||||
<span className="f-reward-checkmark"/>
|
||||
|
||||
<span className="f-reward-text">
|
||||
{t("post.rewardCheckbox")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Чекбокс согласия с правилами */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-rules-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="f-rules-input"
|
||||
checked={form.rulesAccepted}
|
||||
onChange={e => set("rulesAccepted", e.target.checked)}
|
||||
/>
|
||||
<span className="f-rules-checkmark" />
|
||||
<span className="f-rules-text">
|
||||
{t('post.rulesCheckboxPrefix')}{' '}
|
||||
<span
|
||||
className="f-rules-link"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setRulesModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('post.rulesLinkText')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{errors.rulesAccepted && <p className="f-err-msg">{errors.rulesAccepted}</p>}
|
||||
</div>
|
||||
|
||||
{apiError && <p className="f-err-msg">{apiError}</p>}
|
||||
|
||||
<button className="f-m-submit" type="button" onClick={handleSubmit} disabled={loading}>
|
||||
<div className="f-m-submit-row">
|
||||
{loading && <div className="f-m-spinner" />}
|
||||
{isEdit ? t('post.saveChanges') : t('post.publish')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rulesModalOpen && (
|
||||
<RulesModal onClose={() => setRulesModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{resultModal && (
|
||||
<div
|
||||
className="f-modal-bg"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) {
|
||||
if (resultModal === "success") onSuccess?.();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="f-modal f-modal-result">
|
||||
<div className={`f-result-icon ${resultModal === "blocked" ? "blocked" : "success"}`}>
|
||||
{resultModal === "blocked" ? "✕" : "✓"}
|
||||
</div>
|
||||
<h3 className="f-result-title">
|
||||
{resultModal === "blocked" ? t('post.blockedTitle') : t('post.successTitle')}
|
||||
</h3>
|
||||
<p className="f-result-text">
|
||||
{resultModal === "blocked" ? t('post.blockedText') : t('post.successText')}
|
||||
</p>
|
||||
<button
|
||||
className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (resultModal === "success") onSuccess?.();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('post.resultClose')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +1,90 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import "../styles/index.css";
|
||||
import { API_URL, getImageUrl } from "../config/authConstants";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import RightSidebar from "../components/RightSidebar";
|
||||
import PostOnFeed from "../components/PostOnFeed";
|
||||
import CreateUpdatePost from "../components/CreateUpdatePost";
|
||||
import CreateUpdatePost from "./CreateUpdatePost";
|
||||
import ImageViewer from "../components/ImageViewer";
|
||||
import CityFilter from "../components/CityFilter";
|
||||
import MenuIcon from "../components/icons/MenuIcon";
|
||||
import ProfileAvatar from "../components/profile/ProfileAvatar";
|
||||
import { ProfileModal } from "../components/profile";
|
||||
import AuthModal from "../components/AuthModal";
|
||||
import RulesModal from "../components/RulesModal";
|
||||
import FeedTopBar from "../components/feed/FeedTopBar";
|
||||
import FeedSearchBar from "../components/feed/FeedSearchBar";
|
||||
import TypeFilterBar from "../components/feed/TypeFilterBar";
|
||||
import FeedContent from "../components/feed/FeedContent";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { useDeletePost } from "../hooks/useDeletePost";
|
||||
import { useFavorites } from "../hooks/useFavorites";
|
||||
import { usePostStatus } from "../hooks/usePostStatus";
|
||||
import { useCities } from "../hooks/useCities";
|
||||
import { useSearchPosts } from "../hooks/useSearchPosts";
|
||||
import { useFeedPosts } from "../hooks/useFeedPosts";
|
||||
import { useMyPosts } from "../hooks/useMyPosts";
|
||||
import { useCityFeed } from "../hooks/useCityFeed";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
|
||||
import { useModals } from "../hooks/useModals";
|
||||
import { useFeedActions } from "../hooks/useFeedActions";
|
||||
import { useFavorites } from "../hooks/useFavorites";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* MainFeed — корневой компонент приложения.
|
||||
* Управляет состоянием ленты, модалок и сайдбара;
|
||||
* остальные компоненты получают данные через пропсы.
|
||||
* Композиция хуков состояния + презентационных компонентов ленты.
|
||||
*/
|
||||
export default function MainFeed() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, token, isAuthenticated } = useAuthContext();
|
||||
const { deletePost } = useDeletePost();
|
||||
const { toggleFavorite, loadFavorites } = useFavorites();
|
||||
const { markAsFound } = usePostStatus();
|
||||
const { cities } = useCities();
|
||||
const { loading: cityLoading, searchPosts } = useSearchPosts();
|
||||
const { loadFavorites } = useFavorites();
|
||||
const { t } = useTranslation();
|
||||
const modals = useModals();
|
||||
|
||||
// Открыт ли левый сайдбар (мобильный слайд-аут)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
// Активный пункт навигации в сайдбаре
|
||||
const [activeNav, setActiveNav] = useState("home");
|
||||
// Фильтр по типу объявления: null (все) | "loss" | "found"
|
||||
const [typeFilter, setTypeFilter] = useState(null);
|
||||
// Выбранные города для фильтра: [] (все) | ["Город", ...]
|
||||
const [activeNav, setActiveNav] = useState("home");
|
||||
const [typeFilter, setTypeFilter] = useState(null);
|
||||
const [selectedCities, setSelectedCities] = useState([]);
|
||||
// Результаты поиска по выбранным городам (бэк /posts/search)
|
||||
const [cityAds, setCityAds] = useState([]);
|
||||
// Поисковый запрос (фильтр по title / description)
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Открыта ли модалка создания нового объявления
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
// Объявление, которое сейчас редактируется (null — модалка закрыта)
|
||||
const [editAd, setEditAd] = useState(null);
|
||||
// Открыто ли модальное окно редактирования профиля
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
// Открыто ли модальное окно аутентификации (login / register)
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
// Открыто ли модальное окно правил площадки
|
||||
const [rulesModalOpen, setRulesModalOpen] = useState(false);
|
||||
// Просмотр изображений: null — закрыто, { images, index } — открыто
|
||||
const [viewerData, setViewerData] = useState(null);
|
||||
// Массив объявлений из API (основная лента)
|
||||
const [ads, setAds] = useState([]);
|
||||
// Объявления текущего пользователя (загружаются отдельно по userId)
|
||||
const [myAds, setMyAds] = useState([]);
|
||||
// Set id объявлений, которые пользователь лайкнул (только локально)
|
||||
const [liked, setLiked] = useState(new Set());
|
||||
// Состояния загрузки и ошибки для ленты
|
||||
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 [liked, setLiked] = useState(new Set());
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const feed = useFeedPosts();
|
||||
const myPosts = useMyPosts(user?.id);
|
||||
const cityFeed = useCityFeed(selectedCities);
|
||||
|
||||
// Данные профиля — из контекста авторизации (или пустой объект для гостя)
|
||||
const profileData = useMemo(() => {
|
||||
if (!user) return {};
|
||||
|
||||
// Если бэк присылает имя одним полем "name" — разбиваем на firstName/lastName
|
||||
// Если бэк присылает отдельные поля firstName/lastName — используем их напрямую
|
||||
const nameParts = (user.name || "").trim().split(/\s+/);
|
||||
const firstName = user.firstName || nameParts[0] || "";
|
||||
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
|
||||
|
||||
const avatar = getImageUrl(user.avatar);
|
||||
|
||||
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
|
||||
return {
|
||||
login: user.login || "",
|
||||
login: user.login || "",
|
||||
firstName,
|
||||
lastName,
|
||||
email: user.email || "",
|
||||
phone: user.phone || "",
|
||||
avatar,
|
||||
id: user.id,
|
||||
email: user.email || "",
|
||||
phone: user.phone || "",
|
||||
avatar: getImageUrl(user.avatar),
|
||||
id: user.id,
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
/** Загружает ленту объявлений с бэкенда. */
|
||||
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
|
||||
if (pageNum === 0) {
|
||||
setLoading(true);
|
||||
const refreshCurrentList = useCallback(() => {
|
||||
if (selectedCities.length > 0) {
|
||||
cityFeed.searchCities(selectedCities); // triggers internal effect via selectedCities dep isn't needed here
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
feed.loadFeed();
|
||||
}
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pageNum,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
}, [selectedCities, cityFeed, feed]);
|
||||
|
||||
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); // data.last — стандартное поле Spring Page
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to load ads");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, []);
|
||||
const actions = useFeedActions({
|
||||
refresh: refreshCurrentList,
|
||||
loadMyPosts: myPosts.loadMyPosts,
|
||||
onAuthRequired: () => modals.open("auth"),
|
||||
isAuthenticated,
|
||||
t,
|
||||
});
|
||||
|
||||
// Загружаем лайки пользователя при авторизации
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && token) {
|
||||
loadFavorites(token).then(favoriteIds => {
|
||||
setLiked(new Set(favoriteIds));
|
||||
});
|
||||
loadFavorites().then(favoriteIds => setLiked(new Set(favoriteIds)));
|
||||
} else {
|
||||
setLiked(new Set());
|
||||
}
|
||||
@@ -150,454 +93,166 @@ export default function MainFeed() {
|
||||
// Открываем модалку аутентификации если пришли с лендинга
|
||||
useEffect(() => {
|
||||
if (location.state?.openAuth) {
|
||||
setAuthModalOpen(true);
|
||||
modals.open("auth");
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [location.state]);
|
||||
}, [location.state, modals]);
|
||||
|
||||
// Первичная загрузка при монтировании компонента.
|
||||
// При активном фильтре по городам ленту не грузим — показываем /posts/search
|
||||
// Первичная загрузка ленты (дебаунс поиска), только когда нет фильтра по городам
|
||||
useEffect(() => {
|
||||
if (selectedCities.length > 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
loadFeed(0, searchQuery, false);
|
||||
feed.loadFeed(0, searchQuery, false);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, loadFeed, selectedCities]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery, selectedCities]);
|
||||
|
||||
|
||||
/** Загружает объявления текущего пользователя по userId с бэкенда. */
|
||||
const loadMyPosts = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/posts/${user.id}`);
|
||||
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);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
/** Ищет объявления сразу по нескольким городам и объединяет результаты (без дублей). */
|
||||
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]);
|
||||
|
||||
/** Перезагружает текущий список: городской поиск либо обычную ленту. */
|
||||
const refreshCurrentList = useCallback(() => {
|
||||
if (selectedCities.length > 0) {
|
||||
searchCities(selectedCities).then(result => setCityAds(result));
|
||||
} else {
|
||||
loadFeed();
|
||||
}
|
||||
}, [selectedCities, searchCities, loadFeed]);
|
||||
|
||||
// Поиск объявлений по выбранным городам через /posts/search
|
||||
useEffect(() => {
|
||||
if (selectedCities.length === 0) {
|
||||
setCityAds([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
searchCities(selectedCities).then(result => {
|
||||
if (!cancelled) setCityAds(result);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedCities, searchCities]);
|
||||
|
||||
/**
|
||||
* Переключает лайк на объявлении через API.
|
||||
*/
|
||||
const toggleLike = useCallback(async (id) => {
|
||||
if (!isAuthenticated || !token) {
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await toggleFavorite(id, token);
|
||||
if (success) {
|
||||
// Перезагружаем ленту для получения обновлённых данных
|
||||
refreshCurrentList();
|
||||
if (activeNav === "myads") {
|
||||
loadMyPosts();
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, token, toggleFavorite, refreshCurrentList, loadMyPosts, activeNav]);
|
||||
|
||||
/** Удаляет объявление и перезагружает ленту */
|
||||
const handleDelete = useCallback(async (postId) => {
|
||||
if (!window.confirm(t('feed.confirmDelete'))) {
|
||||
return;
|
||||
}
|
||||
const success = await deletePost(postId);
|
||||
if (success) {
|
||||
refreshCurrentList();
|
||||
loadMyPosts();
|
||||
}
|
||||
}, [t, deletePost, refreshCurrentList, loadMyPosts]);
|
||||
|
||||
/** Отмечает объявление как "Вещь найдена" и перезагружает ленту */
|
||||
const handleMarkFound = useCallback(async (postId) => {
|
||||
if (!window.confirm(t('feed.confirmMarkFound'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await markAsFound(postId, token);
|
||||
if (success) {
|
||||
refreshCurrentList();
|
||||
loadMyPosts();
|
||||
}
|
||||
}, [t, markAsFound, token, refreshCurrentList, loadMyPosts]);
|
||||
|
||||
// После создания или редактирования объявления перезагружаем ленту,
|
||||
// чтобы получить актуальные данные (включая изображения) из БД
|
||||
const handleNewAd = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
|
||||
const handleEditSave = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
|
||||
|
||||
const handleImageClick = useCallback((images, index, ad) => {
|
||||
if (images?.length) {
|
||||
setViewerData({ images, index, ad });
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Обработчик нажатия «Разместить объявление».
|
||||
* Если пользователь не авторизован — открывает модалку входа.
|
||||
*/
|
||||
const handleCreatePost = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setAuthModalOpen(true);
|
||||
} else {
|
||||
setModalOpen(true);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
/**
|
||||
* Обработчик редактирования — разрешён только автору объявления.
|
||||
*/
|
||||
const handleEdit = useCallback((ad) => {
|
||||
if (!isAuthenticated) {
|
||||
setAuthModalOpen(true);
|
||||
} else {
|
||||
setEditAd(ad);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Сентинел для бесконечного скролла — подгружает следующую страницу
|
||||
const observerRef = useRef(null);
|
||||
const sentinelRef = useCallback(node => {
|
||||
if (loadingMore) return;
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
|
||||
observerRef.current = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
|
||||
loadFeed(page + 1, searchQuery, true);
|
||||
}
|
||||
}, { rootMargin: "200px" });
|
||||
|
||||
if (node) observerRef.current.observe(node);
|
||||
}, [loadingMore, hasMore, loading, page, searchQuery, loadFeed]);
|
||||
|
||||
|
||||
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId
|
||||
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
|
||||
useEffect(() => {
|
||||
if (activeNav === "myads" && isAuthenticated && user?.id) {
|
||||
loadMyPosts();
|
||||
myPosts.loadMyPosts();
|
||||
}
|
||||
}, [activeNav, isAuthenticated, user?.id, loadMyPosts]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeNav, isAuthenticated, user?.id]);
|
||||
|
||||
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
|
||||
const filteredAds = (() => {
|
||||
// База: мои объявления / результаты поиска по городу / обычная лента
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
() => feed.loadFeed(feed.page + 1, searchQuery, true),
|
||||
activeNav === "home" && selectedCities.length === 0 && feed.hasMore && !feed.loading && !feed.loadingMore
|
||||
);
|
||||
|
||||
const handleImageClick = useCallback((images, index, ad) => {
|
||||
if (images?.length) modals.open("viewer", { images, index, ad });
|
||||
}, [modals]);
|
||||
|
||||
const handleCreatePost = useCallback(() => {
|
||||
if (!isAuthenticated) modals.open("auth");
|
||||
else modals.open("create");
|
||||
}, [isAuthenticated, modals]);
|
||||
|
||||
const handleEdit = useCallback((ad) => {
|
||||
if (!isAuthenticated) modals.open("auth");
|
||||
else modals.open("edit", ad);
|
||||
}, [isAuthenticated, modals]);
|
||||
|
||||
const filteredAds = useMemo(() => {
|
||||
let result;
|
||||
|
||||
if (activeNav === "myads") {
|
||||
// Используем объявления, загруженные с бэкенда по userId
|
||||
result = myAds;
|
||||
result = myPosts.myAds;
|
||||
} else if (selectedCities.length > 0) {
|
||||
// Выбраны города — используем результаты /posts/search
|
||||
result = cityAds;
|
||||
result = cityFeed.cityAds;
|
||||
} else {
|
||||
result = ads;
|
||||
result = feed.ads;
|
||||
}
|
||||
|
||||
// Фильтр по категории из навигации (Вещи / Питомцы)
|
||||
if (activeNav === "items") {
|
||||
result = result.filter(ad =>
|
||||
["Вещь", "Другое"].includes(ad.category)
|
||||
);
|
||||
result = result.filter(ad => ["Вещь", "Другое"].includes(ad.category));
|
||||
} else if (activeNav === "pets") {
|
||||
result = result.filter(ad => ad.category === "Питомец");
|
||||
}
|
||||
|
||||
// Применяем фильтр по типу объявления
|
||||
if (typeFilter) {
|
||||
result = result.filter(ad => ad.type === typeFilter);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}, [activeNav, myPosts.myAds, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
|
||||
|
||||
/**
|
||||
* Рендерит содержимое ленты в зависимости от текущего состояния:
|
||||
* — спиннер во время загрузки
|
||||
* — сообщение об ошибке
|
||||
* — заглушка при пустой ленте
|
||||
* — список карточек объявлений
|
||||
*/
|
||||
const renderContent = () => {
|
||||
if (loading || (selectedCities.length > 0 && cityLoading)) {
|
||||
return (
|
||||
<div className="f-center">
|
||||
<div className="f-spinner-wrap">
|
||||
<div className="f-spinner" />
|
||||
{t('feed.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="f-center">
|
||||
<div className="f-center-icon">⚠️</div>
|
||||
<div className="f-center-title">{t('feed.loadError')}</div>
|
||||
<div className="f-center-sub">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!filteredAds.length) {
|
||||
const emptyMsg = activeNav === "myads"
|
||||
? { title: t('feed.emptyMyAdsTitle'), sub: t('feed.emptyMyAdsSub') }
|
||||
: selectedCities.length > 0
|
||||
? { title: t('feed.emptyCityTitle'), sub: t('feed.emptyCitySub') }
|
||||
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
|
||||
|
||||
return (
|
||||
<div className="f-center">
|
||||
<div className="f-center-icon">🔍</div>
|
||||
<div className="f-center-title">{emptyMsg.title}</div>
|
||||
<div className="f-center-sub"
|
||||
dangerouslySetInnerHTML={{ __html: emptyMsg.sub }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return filteredAds.map(ad => (
|
||||
<PostOnFeed
|
||||
key={ad.id}
|
||||
ad={ad}
|
||||
liked={liked.has(ad.id)}
|
||||
onLike={toggleLike}
|
||||
onEdit={activeNav === "myads" ? handleEdit : undefined}
|
||||
onDelete={activeNav === "myads" ? handleDelete : undefined}
|
||||
onMarkFound={activeNav === "myads" ? handleMarkFound : undefined}
|
||||
isOwner={activeNav === "myads"}
|
||||
onImageClick={handleImageClick}
|
||||
onAuthRequired={() => setAuthModalOpen(true)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
const listLoading = activeNav === "myads" ? myPosts.loading : feed.loading;
|
||||
const listError = activeNav === "myads" ? myPosts.error : feed.error;
|
||||
|
||||
return (
|
||||
<div className="f-app">
|
||||
{/* Левый сайдбар с навигацией */}
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
activeNav={activeNav}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onNavClick={setActiveNav}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLoginClick={() => setAuthModalOpen(true)}
|
||||
onRulesClick={() => setRulesModalOpen(true)}
|
||||
onLoginClick={() => modals.open("auth")}
|
||||
onRulesClick={() => modals.open("rules")}
|
||||
onReviewsClick={() => navigate("/reviews")}
|
||||
/>
|
||||
|
||||
<div className="f-main">
|
||||
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
|
||||
<div className="f-topbar">
|
||||
<button className="f-menu-btn"
|
||||
onClick={() => setSidebarOpen(v => !v)}>
|
||||
<MenuIcon/>
|
||||
</button>
|
||||
<div className="f-topbar-center">
|
||||
<button className="f-post-btn" onClick={handleCreatePost}>
|
||||
<span style={{
|
||||
fontSize: "1.15rem",
|
||||
fontWeight: 900,
|
||||
lineHeight: 1
|
||||
}}>+</span>
|
||||
{t('feed.createPost')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Иконка профиля в правом углу */}
|
||||
{isAuthenticated ? (
|
||||
<div className="f-topbar-profile"
|
||||
onClick={() => setProfileModalOpen(true)}>
|
||||
<ProfileAvatar avatar={profileData.avatar}
|
||||
name={`${profileData.firstName
|
||||
|| ""} ${profileData.lastName || ""}`}/>
|
||||
</div>
|
||||
) : (
|
||||
<button className="f-login-btn"
|
||||
onClick={() => setAuthModalOpen(true)}>
|
||||
{t('feed.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FeedTopBar
|
||||
onMenuClick={() => setSidebarOpen(v => !v)}
|
||||
onCreatePost={handleCreatePost}
|
||||
isAuthenticated={isAuthenticated}
|
||||
profileData={profileData}
|
||||
onProfileClick={() => modals.open("profile")}
|
||||
onLoginClick={() => modals.open("auth")}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Строка поиска */}
|
||||
<div className="f-search-bar">
|
||||
<svg className="f-search-icon" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
||||
strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
className="f-search-input"
|
||||
type="text"
|
||||
placeholder={t('feed.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button className="f-search-clear"
|
||||
onClick={() => setSearchQuery("")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round"
|
||||
strokeLinejoin="round" width="18" height="18">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FeedSearchBar value={searchQuery} onChange={setSearchQuery} t={t} />
|
||||
|
||||
{/* Фильтр по городам */}
|
||||
{activeNav === "home" && (
|
||||
<div className="f-city-filter-wrap">
|
||||
<CityFilter
|
||||
cities={cities}
|
||||
value={selectedCities}
|
||||
onChange={setSelectedCities}
|
||||
/>
|
||||
<CityFilter cities={cities} value={selectedCities} onChange={setSelectedCities} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Фильтр по типу объявления */}
|
||||
<div className="f-filter-bar">
|
||||
<button
|
||||
className={`f-filter-btn${typeFilter === "loss" ? " active"
|
||||
: ""}`}
|
||||
onClick={() => setTypeFilter("loss")}
|
||||
>
|
||||
{t('feed.filterLoss')}
|
||||
</button>
|
||||
<button
|
||||
className={`f-filter-btn${typeFilter === null ? " active"
|
||||
: ""}`}
|
||||
onClick={() => setTypeFilter(null)}
|
||||
>
|
||||
{t('feed.filterAll')}
|
||||
</button>
|
||||
<button
|
||||
className={`f-filter-btn${typeFilter === "found" ? " active"
|
||||
: ""}`}
|
||||
onClick={() => setTypeFilter("found")}
|
||||
>
|
||||
{t('feed.filterFound')}
|
||||
</button>
|
||||
</div>
|
||||
<TypeFilterBar value={typeFilter} onChange={setTypeFilter} t={t} />
|
||||
|
||||
{/* Основная область: лента + правый сайдбар */}
|
||||
<div className="f-wrap">
|
||||
<div className="f-feed">
|
||||
{renderContent()}
|
||||
{activeNav === "home" && selectedCities.length === 0 && !loading && (
|
||||
<div ref={sentinelRef} style={{height: "1px"}}/>
|
||||
<FeedContent
|
||||
loading={listLoading}
|
||||
cityLoading={cityFeed.cityLoading}
|
||||
citySearchActive={selectedCities.length > 0}
|
||||
error={listError}
|
||||
filteredAds={filteredAds}
|
||||
activeNav={activeNav}
|
||||
liked={liked}
|
||||
onLike={actions.toggleLike}
|
||||
onEdit={handleEdit}
|
||||
onDelete={actions.handleDelete}
|
||||
onMarkFound={actions.handleMarkFound}
|
||||
onImageClick={handleImageClick}
|
||||
onAuthRequired={() => modals.open("auth")}
|
||||
t={t}
|
||||
/>
|
||||
{activeNav === "home" && selectedCities.length === 0 && !feed.loading && (
|
||||
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||
)}
|
||||
{loadingMore && (
|
||||
<div className="f-center" style={{padding: "1rem"}}>
|
||||
<div className="f-spinner"/>
|
||||
{feed.loadingMore && (
|
||||
<div className="f-center" style={{ padding: "1rem" }}>
|
||||
<div className="f-spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Правый сайдбар: промо-блоки / фильтры */}
|
||||
<RightSidebar/>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка создания нового объявления (только для авторизованных) */}
|
||||
{modalOpen && isAuthenticated && (
|
||||
{modals.isOpen("create") && isAuthenticated && (
|
||||
<CreateUpdatePost onClose={modals.close} onSuccess={actions.handleNewAd} />
|
||||
)}
|
||||
|
||||
{modals.isOpen("edit") && isAuthenticated && (
|
||||
<CreateUpdatePost
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={handleNewAd}
|
||||
initialData={modals.modal.payload}
|
||||
onClose={modals.close}
|
||||
onSuccess={actions.handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модалка редактирования — открывается когда editAd !== null */}
|
||||
{editAd && isAuthenticated && (
|
||||
<CreateUpdatePost
|
||||
initialData={editAd}
|
||||
onClose={() => setEditAd(null)}
|
||||
onSuccess={handleEditSave}
|
||||
/>
|
||||
{modals.isOpen("profile") && isAuthenticated && (
|
||||
<ProfileModal onClose={modals.close} initialData={profileData} />
|
||||
)}
|
||||
|
||||
{/* Модальное окно редактирования профиля */}
|
||||
{profileModalOpen && isAuthenticated && (
|
||||
<ProfileModal
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
initialData={profileData}
|
||||
/>
|
||||
)}
|
||||
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
|
||||
|
||||
{/* Модальное окно входа / регистрации */}
|
||||
{authModalOpen && (
|
||||
<AuthModal
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
|
||||
|
||||
{/* Модальное окно правил площадки */}
|
||||
{rulesModalOpen && (
|
||||
<RulesModal onClose={() => setRulesModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Просмотр изображений */}
|
||||
{viewerData && (
|
||||
{modals.isOpen("viewer") && (
|
||||
<ImageViewer
|
||||
images={viewerData.images}
|
||||
initialIndex={viewerData.index}
|
||||
onClose={() => setViewerData(null)}
|
||||
postData={viewerData.ad}
|
||||
images={modals.modal.payload.images}
|
||||
initialIndex={modals.modal.payload.index}
|
||||
onClose={modals.close}
|
||||
postData={modals.modal.payload.ad}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
146
src/pages/PostOnFeed.jsx
Normal file
146
src/pages/PostOnFeed.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// src/components/PostOnFeed.jsx
|
||||
import PinIcon from "../components/icons/PinIcon";
|
||||
import CalendarIcon from "../components/icons/CalendarIcon";
|
||||
import PhoneIcon from "../components/icons/PhoneIcon";
|
||||
import MapIcon from "../components/icons/MapIcon";
|
||||
import HeartIcon from "../components/icons/HeartIcon";
|
||||
import TrashIcon from "../components/icons/TrashIcon";
|
||||
import { useState } from "react";
|
||||
import { useContactSeller } from "../hooks/useContactSeller";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
import DateCalendarPopup from "../components/DateCalendarPopup";
|
||||
import Toast from "../components/Toast";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onAuthRequired }) {
|
||||
const { t } = useTranslation();
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const { contactSeller, error: contactError, clearError } = useContactSeller(ad);
|
||||
|
||||
const handleContact = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!isAuthenticated) {
|
||||
if (onAuthRequired) onAuthRequired();
|
||||
return;
|
||||
}
|
||||
contactSeller();
|
||||
};
|
||||
|
||||
const imageUrl = getImageUrl(ad.images?.[0]);
|
||||
const address = ad.city;
|
||||
const createdAt = ad.createdAt
|
||||
? new Date(ad.createdAt).toLocaleDateString("ru-RU")
|
||||
: null;
|
||||
|
||||
const isClosed = ad.status === 'CLOSED';
|
||||
|
||||
const roleClass = ad.userRole === 'LOST_AND_FOUND' ? 'f-card-role-lost-found'
|
||||
: ad.userRole === 'METRO' ? 'f-card-role-metro' : '';
|
||||
|
||||
const roleBadge = ad.userRole === 'LOST_AND_FOUND' ? (
|
||||
<div className="f-role-badge f-role-lost-found" title={t('postCard.lostFoundBureau')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
{t('postCard.lostFoundBureau')}
|
||||
</div>
|
||||
) : ad.userRole === 'METRO' ? (
|
||||
<div className="f-role-badge f-role-metro" title={t('postCard.metro')}>
|
||||
<span className="f-role-metro-letter">М</span>
|
||||
{t('postCard.metro')}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
|
||||
|
||||
<div className="f-card-inner">
|
||||
<div className="f-card-img" onClick={e => { e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0, ad); }}>
|
||||
{ad.reward && (
|
||||
<div className="f-card-reward">
|
||||
💰 {t("postCard.reward")}
|
||||
</div>
|
||||
)}
|
||||
{imageUrl
|
||||
? <img src={imageUrl} alt={ad.title} />
|
||||
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="f-card-body">
|
||||
<div className="f-card-badges">
|
||||
{ad.category && <div className="f-cat-badge">{ad.category}</div>}
|
||||
<div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss" : "f-type-found"}`}>
|
||||
{ad.type === "loss" ? t('postCard.loss') : t('postCard.found')}
|
||||
</div>
|
||||
{roleBadge}
|
||||
</div>
|
||||
<div className="f-card-title">
|
||||
{ad.title}
|
||||
{isClosed && <span className="f-status-badge f-status-closed">{t('postCard.foundStatus')}</span>}
|
||||
</div>
|
||||
<div className="f-card-desc">{ad.description}</div>
|
||||
<div className="f-card-meta">
|
||||
{address && <div className="f-meta-pill"><PinIcon /> {address}</div>}
|
||||
{createdAt && (
|
||||
<div className="f-meta-pill f-date-pill" onClick={e => { e.stopPropagation(); setShowCalendar(s => !s); }}>
|
||||
<CalendarIcon /> {createdAt}
|
||||
{showCalendar && <DateCalendarPopup date={ad.createdAt} onClose={() => setShowCalendar(false)} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="f-card-foot">
|
||||
<div className="f-card-foot-left">
|
||||
{ad.phone && (
|
||||
<a className="f-card-phone" href={`tel:${ad.phone}`}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<PhoneIcon /> {ad.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="f-card-foot-right">
|
||||
{!isOwner && !isClosed && (
|
||||
<button type="button" className="f-card-contact" onClick={handleContact}>
|
||||
{t('postCard.write')}
|
||||
</button>
|
||||
)}
|
||||
{onEdit && isOwner && !isClosed && (
|
||||
<button type="button" className="f-card-edit-btn"
|
||||
onClick={e => { e.stopPropagation(); onEdit(ad); }}>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{onDelete && isOwner && (
|
||||
<button type="button" className="f-card-delete-btn"
|
||||
onClick={e => { e.stopPropagation(); onDelete(ad.id); }}>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
{onMarkFound && isOwner && !isClosed && (
|
||||
<button type="button" className="f-card-found-btn"
|
||||
onClick={e => { e.stopPropagation(); onMarkFound(ad.id); }}>
|
||||
{t('postCard.itemFound')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={`f-like-btn${liked ? " liked" : ""}`}
|
||||
onClick={e => { e.stopPropagation(); onLike(ad.id); }}>
|
||||
<HeartIcon filled={liked} />
|
||||
{ad.likeCount || 0}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contactError && (
|
||||
<Toast
|
||||
message={contactError}
|
||||
type="error"
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,347 +1,128 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { API_URL, getImageUrl } from "../config/authConstants";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import MenuIcon from "../components/icons/MenuIcon";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import ProfileAvatar from "../components/profile/ProfileAvatar";
|
||||
import AppTopBar from "../components/layout/AppTopBar";
|
||||
import { ProfileModal } from "../components/profile";
|
||||
import AuthModal from "../components/AuthModal";
|
||||
import ReviewCard from "../components/reviews/ReviewCard";
|
||||
import ReviewForm from "../components/reviews/ReviewForm";
|
||||
import RatingSummary from "../components/reviews/RatingSummary";
|
||||
import { useReviews } from "../hooks/useReviews";
|
||||
import "../styles/reviews.css";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/* ── Star rating sub-component ── */
|
||||
function StarRating({ value, onChange, size = 28, readOnly = false }) {
|
||||
const [hovered, setHovered] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="rv-stars">
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = star <= (hovered || value);
|
||||
return (
|
||||
<svg
|
||||
key={star}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
className={`rv-star${filled ? " filled" : ""}${!readOnly ? " interactive" : ""}`}
|
||||
onClick={() => !readOnly && onChange(star)}
|
||||
onMouseEnter={() => !readOnly && setHovered(star)}
|
||||
onMouseLeave={() => !readOnly && setHovered(0)}
|
||||
>
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||
fill={filled ? "#f5a623" : "none"}
|
||||
stroke={filled ? "#f5a623" : "currentColor"}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Single review card ── */
|
||||
function ReviewCard({ review }) {
|
||||
const { t } = useTranslation();
|
||||
const date = review.createdAt
|
||||
? new Date(review.createdAt).toLocaleDateString("ru-RU", {
|
||||
day: "numeric", month: "long", year: "numeric",
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="rv-card">
|
||||
<div className="rv-card-head">
|
||||
<div className="rv-card-avatar">
|
||||
{review.authorName ? review.authorName.charAt(0).toUpperCase() : "?"}
|
||||
</div>
|
||||
<div className="rv-card-meta">
|
||||
<span className="rv-card-name">{review.authorName || t('reviews.anonymous')}</span>
|
||||
{date && <span className="rv-card-date">{date}</span>}
|
||||
</div>
|
||||
<div className="rv-card-rating">
|
||||
<StarRating value={review.rating} readOnly size={18} />
|
||||
<span className="rv-card-rating-num">{review.rating}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
{review.text && <p className="rv-card-text">{review.text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Review form ── */
|
||||
function ReviewForm({ onSubmit, submitting }) {
|
||||
const { t } = useTranslation();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (rating === 0) {
|
||||
setError(t('reviews.ratePrompt'));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onSubmit({ rating, text: text.trim() });
|
||||
setRating(0);
|
||||
setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="rv-form" onSubmit={handleSubmit}>
|
||||
<h3 className="rv-form-title">{t('reviews.leaveReview')}</h3>
|
||||
<div className="rv-form-rating">
|
||||
<span className="rv-form-label">{t('reviews.rating')}</span>
|
||||
<StarRating value={rating} onChange={setRating} size={32} />
|
||||
</div>
|
||||
{error && <div className="rv-form-error">{error}</div>}
|
||||
<textarea
|
||||
className="rv-form-textarea"
|
||||
placeholder={t('reviews.reviewPlaceholder')}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="rv-form-footer">
|
||||
<span className="rv-form-counter">{text.length}/500</span>
|
||||
<button className="rv-form-submit" type="submit" disabled={submitting}>
|
||||
{submitting ? t('reviews.sending') : t('reviews.send')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Reviews page ── */
|
||||
export default function ReviewsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated } = useAuthContext();
|
||||
const { user, token, isAuthenticated } = useAuthContext();
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [activeNav] = useState("reviews");
|
||||
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const profileData = useMemo(() => {
|
||||
if (!user) return {};
|
||||
const nameParts = (user.name || "").trim().split(/\s+/);
|
||||
return {
|
||||
login: user.login || "",
|
||||
firstName: user.firstName || nameParts[0] || "",
|
||||
lastName: user.lastName || nameParts.slice(1).join(" ") || "",
|
||||
email: user.email || "",
|
||||
phone: user.phone || "",
|
||||
avatar: getImageUrl(user.avatar),
|
||||
id: user.id,
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
/* Profile data for sidebar */
|
||||
const profileData = user
|
||||
? {
|
||||
login: user.login || "",
|
||||
firstName: user.firstName || (user.name || "").trim().split(/\s+/)[0] || "",
|
||||
lastName: user.lastName || (user.name || "").trim().split(/\s+/).slice(1).join(" ") || "",
|
||||
email: user.email || "",
|
||||
phone: user.phone || "",
|
||||
avatar: getImageUrl(user.avatar),
|
||||
id: user.id,
|
||||
}
|
||||
: {};
|
||||
|
||||
/** Load reviews from backend */
|
||||
const loadReviews = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
console.log('[Reviews] GET request to:', `${API_URL}/api/v1/reviews`);
|
||||
const res = await fetch(`${API_URL}/api/v1/reviews`);
|
||||
console.log('[Reviews] Response status:', res.status, res.ok);
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const data = await res.json();
|
||||
console.log('[Reviews] Response data:', data);
|
||||
const list = Array.isArray(data) ? data : data.items ?? data.reviews ?? [];
|
||||
console.log('[Reviews] Parsed list:', list);
|
||||
setReviews(list);
|
||||
} catch (err) {
|
||||
console.error('[Reviews] Error:', err);
|
||||
setError(err.message || "Failed to load reviews");
|
||||
// Show empty state with mock data hint
|
||||
setReviews([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const {
|
||||
reviews, loading, error, submitting, submitError,
|
||||
loadReviews, submitReview, avgRating, distribution,
|
||||
} = useReviews({
|
||||
token,
|
||||
isAuthenticated,
|
||||
onAuthRequired: () => setAuthModalOpen(true),
|
||||
authorFallbackName: profileData.firstName || t('reviews.you'),
|
||||
});
|
||||
|
||||
useEffect(() => { loadReviews(); }, [loadReviews]);
|
||||
|
||||
/** Submit a new review */
|
||||
const handleSubmitReview = useCallback(async ({ rating, text }) => {
|
||||
if (!isAuthenticated) {
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
console.log('[Reviews] POST request body:', { rating, text });
|
||||
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 }),
|
||||
});
|
||||
console.log('[Reviews] POST response status:', res.status, res.ok);
|
||||
if (res.ok) {
|
||||
const responseData = await res.json();
|
||||
console.log('[Reviews] POST response data:', responseData);
|
||||
}
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
// Reload reviews after successful submit
|
||||
await loadReviews();
|
||||
} catch (err) {
|
||||
// Fallback: add review locally if API is unavailable
|
||||
setReviews((prev) => [
|
||||
{
|
||||
id: Date.now(),
|
||||
authorName: profileData.firstName || t('reviews.you'),
|
||||
rating,
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [t, isAuthenticated, loadReviews, profileData.firstName]);
|
||||
|
||||
/** Average rating */
|
||||
const avgRating = reviews.length
|
||||
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
|
||||
: null;
|
||||
|
||||
/** Rating distribution */
|
||||
const distribution = [5, 4, 3, 2, 1].map((star) => ({
|
||||
star,
|
||||
count: reviews.filter((r) => r.rating === star).length,
|
||||
pct: reviews.length
|
||||
? Math.round((reviews.filter((r) => r.rating === star).length / reviews.length) * 100)
|
||||
: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="f-app">
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
activeNav={activeNav}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onNavClick={(label) => {
|
||||
if (label === "Главная") navigate("/feed");
|
||||
}}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLoginClick={() => setAuthModalOpen(true)}
|
||||
onRulesClick={() => {}}
|
||||
/>
|
||||
<div className="f-app">
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
activeNav="reviews"
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onNavClick={(label) => {
|
||||
if (label === "Главная") navigate("/feed");
|
||||
}}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLoginClick={() => setAuthModalOpen(true)}
|
||||
onRulesClick={() => {}}
|
||||
/>
|
||||
|
||||
<div className="f-main">
|
||||
{/* Top bar */}
|
||||
<div className="f-topbar">
|
||||
<button className="f-menu-btn" onClick={() => setSidebarOpen((v) => !v)}>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
<div className="f-topbar-center">
|
||||
<h1 className="rv-page-title">{t('reviews.title')}</h1>
|
||||
</div>
|
||||
{/* Иконка профиля в правом углу */}
|
||||
{isAuthenticated ? (
|
||||
<div className="f-topbar-profile" onClick={() => setProfileModalOpen(true)}>
|
||||
<ProfileAvatar avatar={profileData.avatar} name={`${profileData.firstName || ""} ${profileData.lastName || ""}`} />
|
||||
</div>
|
||||
) : (
|
||||
<button className="f-login-btn" onClick={() => setAuthModalOpen(true)}>
|
||||
{t('reviews.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="f-main">
|
||||
<AppTopBar
|
||||
onMenuClick={() => setSidebarOpen((v) => !v)}
|
||||
isAuthenticated={isAuthenticated}
|
||||
profileData={profileData}
|
||||
onProfileClick={() => setProfileModalOpen(true)}
|
||||
onLoginClick={() => setAuthModalOpen(true)}
|
||||
loginLabel={t('reviews.login')}
|
||||
centerContent={<h1 className="rv-page-title">{t('reviews.title')}</h1>}
|
||||
/>
|
||||
|
||||
<div className="rv-layout">
|
||||
{/* Left: Summary + form */}
|
||||
<div className="rv-sidebar">
|
||||
{/* Summary */}
|
||||
<div className="rv-summary">
|
||||
<div className="rv-summary-score">
|
||||
{avgRating ? (
|
||||
<div className="rv-layout">
|
||||
<div className="rv-sidebar">
|
||||
<RatingSummary
|
||||
avgRating={avgRating}
|
||||
totalCount={reviews.length}
|
||||
distribution={distribution}
|
||||
/>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<span className="rv-summary-num">{avgRating}</span>
|
||||
<StarRating value={Math.round(Number(avgRating))} readOnly size={22} />
|
||||
<ReviewForm onSubmit={submitReview} submitting={submitting} />
|
||||
{submitError && (
|
||||
<div className="rv-form-error">{t('reviews.submitError')}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="rv-summary-none">{t('reviews.noReviews')}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="rv-summary-total">
|
||||
{t('reviews.title')} ({reviews.length})
|
||||
</span>
|
||||
|
||||
{/* Distribution bars */}
|
||||
{reviews.length > 0 && (
|
||||
<div className="rv-distribution">
|
||||
{distribution.map(({ star, count, pct }) => (
|
||||
<div key={star} className="rv-dist-row">
|
||||
<span className="rv-dist-star">{star} ★</span>
|
||||
<div className="rv-dist-bar">
|
||||
<div className="rv-dist-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="rv-dist-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rv-login-prompt">
|
||||
<p>{t('reviews.loginPrompt')}</p>
|
||||
<button className="rv-login-btn" onClick={() => setAuthModalOpen(true)}>
|
||||
{t('reviews.login')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add review form */}
|
||||
{isAuthenticated ? (
|
||||
<ReviewForm onSubmit={handleSubmitReview} submitting={submitting} />
|
||||
) : (
|
||||
<div className="rv-login-prompt">
|
||||
<p>{t('reviews.loginPrompt')}</p>
|
||||
<button className="rv-login-btn" onClick={() => setAuthModalOpen(true)}>
|
||||
{t('reviews.login')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Reviews list */}
|
||||
<div className="rv-list">
|
||||
{loading ? (
|
||||
<div className="f-center">
|
||||
<div className="f-spinner-wrap">
|
||||
<div className="f-spinner" />
|
||||
{t('reviews.loading')}
|
||||
</div>
|
||||
</div>
|
||||
) : error && reviews.length === 0 ? (
|
||||
<div className="f-center">
|
||||
<div className="f-center-icon">💬</div>
|
||||
<div className="f-center-title">{t('reviews.emptyTitle')}</div>
|
||||
<div className="f-center-sub">{t('reviews.emptySub')}</div>
|
||||
</div>
|
||||
) : (
|
||||
reviews.map((review) => <ReviewCard key={review.id} review={review} />)
|
||||
)}
|
||||
<div className="rv-list">
|
||||
{loading ? (
|
||||
<div className="f-center">
|
||||
<div className="f-spinner-wrap">
|
||||
<div className="f-spinner" />
|
||||
{t('reviews.loading')}
|
||||
</div>
|
||||
</div>
|
||||
) : error && reviews.length === 0 ? (
|
||||
<div className="f-center">
|
||||
<div className="f-center-icon">💬</div>
|
||||
<div className="f-center-title">{t('reviews.emptyTitle')}</div>
|
||||
<div className="f-center-sub">{t('reviews.emptySub')}</div>
|
||||
</div>
|
||||
) : (
|
||||
reviews.map((review) => <ReviewCard key={review.id} review={review} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileModalOpen && isAuthenticated && (
|
||||
<ProfileModal onClose={() => setProfileModalOpen(false)} initialData={profileData} />
|
||||
)}
|
||||
{authModalOpen && <AuthModal onClose={() => setAuthModalOpen(false)} />}
|
||||
</div>
|
||||
|
||||
{/* Profile modal */}
|
||||
{profileModalOpen && isAuthenticated && (
|
||||
<ProfileModal onClose={() => setProfileModalOpen(false)} initialData={profileData} />
|
||||
)}
|
||||
|
||||
{/* Auth modal */}
|
||||
{authModalOpen && (
|
||||
<AuthModal onClose={() => setAuthModalOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user