diff --git a/package-lock.json b/package-lock.json index f4b70cc..d8673ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "react_front", "version": "0.1.0", "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@hookform/resolvers": "^5.2.2", "@pbe/react-yandex-maps": "^1.2.5", "@stomp/stompjs": "^7.3.0", @@ -2431,6 +2433,22 @@ "tslib": "^2.4.0" } }, + "node_modules/@emoji-mart/data": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", + "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", + "license": "MIT" + }, + "node_modules/@emoji-mart/react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz", + "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==", + "license": "MIT", + "peerDependencies": { + "emoji-mart": "^5.2", + "react": "^16.8 || ^17 || ^18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -7534,6 +7552,13 @@ "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, + "node_modules/emoji-mart": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", + "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", + "license": "MIT", + "peer": true + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/package.json b/package.json index 8cf36cf..7835568 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@hookform/resolvers": "^5.2.2", "@pbe/react-yandex-maps": "^1.2.5", "@stomp/stompjs": "^7.3.0", diff --git a/react_front/index.html b/react_front/index.html new file mode 100644 index 0000000..0ba6111 --- /dev/null +++ b/react_front/index.html @@ -0,0 +1,13 @@ + + + + + + + react_front + + +
+ + + diff --git a/react_front/public/favicon.svg b/react_front/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/react_front/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react_front/src/App.css b/react_front/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/react_front/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/react_front/src/index.css b/react_front/src/index.css new file mode 100644 index 0000000..5fb3313 --- /dev/null +++ b/react_front/src/index.css @@ -0,0 +1,111 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/src/App.js b/src/App.js index 38c4aca..e570c32 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider } from './context/AuthContext'; import { ThemeProvider } from './context/ThemeContext'; +import { useAuthContext } from './context/AuthContext'; +import { useUnreadSocket } from './hooks/useUnreadSocket'; import RegisterPage from './components/Authentication'; import MainFeed from './components/MainFeed'; import ChatPage from './components/ChatPage'; @@ -8,10 +10,17 @@ import ChatListPage from './components/ChatListPage'; import ReviewsPage from './components/ReviewsPage'; import './styles/chat.css'; +function UnreadListener() { + const { user } = useAuthContext(); + useUnreadSocket(user?.id); + return null; +} + function App() { return ( + } /> diff --git a/src/components/AuthModal.jsx b/src/components/AuthModal.jsx new file mode 100644 index 0000000..b538ed3 --- /dev/null +++ b/src/components/AuthModal.jsx @@ -0,0 +1,288 @@ +import { useState, useCallback } from "react"; +import CloseIcon from "./icons/CloseIcon"; +import { useAuthContext } from "../context/AuthContext"; +import { API_URL } from "../config/authConstants"; + +/** + * AuthModal — модальное окно входа и регистрации прямо на странице ленты. + * Открывается когда неавторизованный пользователь пытается создать объявление. + */ +export default function AuthModal({ onClose }) { + const [mode, setMode] = useState("login"); // "login" | "register" + const { login: saveAuth } = useAuthContext(); + + return ( +
e.target === e.currentTarget && onClose()}> +
+ {/* Header */} +
+ + {mode === "login" ? "Вход в аккаунт" : "Регистрация"} + + +
+ + {mode === "login" + ? setMode("register")} + /> + : setMode("login")} + /> + } +
+
+ ); +} + +// ─── Login form (inline modal) ────────────────────────────────────────────── +function LoginFormInline({ saveAuth, onClose, onSwitch }) { + const [login, setLogin] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async () => { + if (!login || !password) { setError("Введите логин и пароль"); 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 || "Неверный логин или пароль"); + } + + const { token, user } = await res.json(); + saveAuth(user, token); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+ + { setLogin(e.target.value); setError(""); }} + autoComplete="off" + /> +
+ +
+ + { setPassword(e.target.value); setError(""); }} + onKeyDown={e => e.key === "Enter" && handleSubmit()} + /> +
+ + {error &&

{error}

} + + + +

+ Нет аккаунта?{" "} + +

+ + ); +} + +// ─── Register form (inline modal) ────────────────────────────────────────── +function RegisterFormInline({ saveAuth, onClose, onSwitch }) { + const [form, setForm] = useState({ + name: "", login: "", password: "", password2: "", + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [errors, setErrors] = useState({}); + + const setField = useCallback((key, val) => { + setForm(p => ({ ...p, [key]: val })); + setErrors(p => ({ ...p, [key]: "" })); + }, []); + + const validate = () => { + const e = {}; + if (!form.name.trim()) e.name = "Введите имя"; + if (!form.login.trim()) e.login = "Введите логин"; + else if (form.login.length < 3) e.login = "Минимум 3 символа"; + if (form.password.length < 6) e.password = "Минимум 6 символов"; + if (form.password !== form.password2) e.password2 = "Пароли не совпадают"; + setErrors(e); + return !Object.keys(e).length; + }; + + const handleSubmit = async () => { + if (!validate()) return; + setLoading(true); setError(""); + + try { + // 1. Регистрация + const regRes = await fetch(`${API_URL}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: form.name, + login: form.login, + password: form.password, + }), + }); + + const regData = await regRes.json(); + if (!regRes.ok) throw new Error(regData.message || "Ошибка регистрации"); + + // 2. Автоматический вход после регистрации + const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ login: form.login, password: form.password }), + }); + + if (!loginRes.ok) throw new Error("Аккаунт создан. Войдите вручную."); + + const { token, user } = await loginRes.json(); + saveAuth(user, token); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+ + setField("name", e.target.value)} + /> + {errors.name &&

{errors.name}

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

{errors.login}

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

{errors.password}

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

{errors.password2}

} +
+
+ + {error &&

{error}

} + + + +

+ Уже есть аккаунт?{" "} + +

+ + ); +} + +// ─── Shared submit button ────────────────────────────────────────────────── +function SubmitButton({ loading, label, onClick }) { + return ( + + ); +} diff --git a/src/components/ChatListPage.jsx b/src/components/ChatListPage.jsx new file mode 100644 index 0000000..ec4a9e3 --- /dev/null +++ b/src/components/ChatListPage.jsx @@ -0,0 +1,118 @@ +import { useEffect, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useChats } from "../hooks/useChats"; +import { useAuthContext } from "../context/AuthContext"; +import { useUnread } from "../context/UnreadContext"; +import { getPostForChat } from "../stores/chatPostStore"; +import { API_URL } from "../config/authConstants"; +import { useDeleteChat } from "../hooks/useDeleteChat"; +import TrashIcon from "./icons/TrashIcon"; + +export default function ChatListPage() { + const navigate = useNavigate(); + const { chats, loading, fetchChats } = useChats(); + const { isAuthenticated } = useAuthContext(); + const { updateFromChats, unreadMap } = useUnread(); + const { deleteChat } = useDeleteChat(); + + useEffect(() => { + if (isAuthenticated) { + fetchChats(); + } + }, [isAuthenticated, fetchChats]); + + useEffect(() => { + if (chats.length > 0) { + updateFromChats(chats); + } + }, [chats, updateFromChats]); + + const handleDeleteChat = useCallback(async (chatId) => { + if (!window.confirm("Вы уверены, что хотите удалить этот чат?")) return; + const ok = await deleteChat(chatId); + if (ok) fetchChats(); + }, [deleteChat, fetchChats]); + + if (!isAuthenticated) { + return ( +
+
+
+ +
Чаты
+
+
+
Войдите, чтобы видеть чаты
+
+
+ ); + } + + return ( +
+
+
+ +
Чаты
+
+
+ +
+ {loading && ( +
Загрузка...
+ )} + + {!loading && chats.length === 0 && ( +
У вас пока нет чатов
+ )} + + {!loading && chats.map((chat) => { + const post = getPostForChat(chat.id); + const title = post?.title || chat.otherUser?.name || "Пользователь"; + const imgSrc = post?.images?.[0] ? `${API_URL}${post.images[0]}` : null; + const emoji = post?.emoji; + const unreadCount = unreadMap[chat.id] ?? chat.unreadCount ?? chat.unreadMessages ?? 0; + return ( +
0 ? " unread" : ""}`} + onClick={() => navigate(`/chats/${chat.id}`)} + > +
+ {imgSrc ? ( + + ) : ( + emoji || chat.otherUser?.name?.[0] || "?" + )} +
+
+
{title}
+
+ {chat.lastMessage?.content || "Нет сообщений"} +
+
+ {unreadCount > 0 && ( +
{unreadCount > 99 ? "99+" : unreadCount}
+ )} + +
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/ChatPage.jsx b/src/components/ChatPage.jsx new file mode 100644 index 0000000..6b572aa --- /dev/null +++ b/src/components/ChatPage.jsx @@ -0,0 +1,148 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +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 { API_URL } from "../config/authConstants"; +import Picker from "@emoji-mart/react"; +import emojiData from "@emoji-mart/data"; + +export default function ChatPage() { + const { chatId } = useParams(); + const navigate = useNavigate(); + const { fetchMessages, markAsRead } = useChats(); + const { clearChatUnread } = useUnread(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [showEmoji, setShowEmoji] = useState(false); + 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 = (() => { + try { return JSON.parse(localStorage.getItem("user"))?.id; } + catch { return null; } + })(); + + const handleIncoming = useCallback((msg) => { + setMessages((prev) => [...prev, msg]); + }, []); + + const { sendMessage, connected } = useChatSocket(chatId, handleIncoming); + + useEffect(() => { + if (chatId) { + setActiveChatId(chatId); + fetchMessages(chatId).then(setMessages); + markAsRead(chatId); + clearChatUnread(chatId); + } + return () => setActiveChatId(null); + }, [chatId, fetchMessages, markAsRead, clearChatUnread]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const handleSend = (e) => { + e.preventDefault(); + if (!input.trim()) return; + sendMessage(input); + setInput(""); + }; + + const handleEmojiSelect = (emoji) => { + setInput((prev) => prev + emoji.native); + }; + + const post = getPostForChat(chatId); + const postImg = post?.images?.[0] ? `${API_URL}${post.images[0]}` : null; + + return ( +
+
+
+ +
+ {postImg ? ( + + ) : ( + post?.emoji || "📋" + )} +
+
{post?.title || "Чат"}
+
+ {connected ? "В сети" : "Отключено"} +
+
+ +
+ {messages.length === 0 && ( +
Нет сообщений
+ )} + {messages.map((m) => ( +
+ {m.content} +
+ ))} +
+
+ +
+ {showEmoji && ( +
+ +
+ )} +
+ setInput(e.target.value)} + placeholder="Сообщение..." + className="chat-input" + /> + + +
+
+
+
+ ); +} diff --git a/src/components/ImageViewer.jsx b/src/components/ImageViewer.jsx new file mode 100644 index 0000000..f7af9e7 --- /dev/null +++ b/src/components/ImageViewer.jsx @@ -0,0 +1,66 @@ +import { useState, useCallback, useEffect } from "react"; +import CloseIcon from "./icons/CloseIcon"; +import { API_URL } from "../config/authConstants"; + +export default function ImageViewer({ images, initialIndex = 0, onClose }) { + const [index, setIndex] = useState(initialIndex); + + const urls = images.map(u => (u.startsWith("http") ? u : `${API_URL}${u}`)); + + const next = useCallback(() => { + setIndex(i => (i + 1) % urls.length); + }, [urls.length]); + + const prev = useCallback(() => { + setIndex(i => (i - 1 + urls.length) % urls.length); + }, [urls.length]); + + const handleImgClick = useCallback(() => { + next(); + }, [next]); + + useEffect(() => { + const handleKey = (e) => { + if (e.key === "Escape") onClose(); + if (e.key === "ArrowRight") next(); + if (e.key === "ArrowLeft") prev(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose, next, prev]); + + useEffect(() => { + setIndex(initialIndex); + }, [initialIndex]); + + if (!urls.length) return null; + + return ( +
e.target === e.currentTarget && onClose()}> + + +
+ + + {urls.length > 1 && ( +
+ {urls.map((_, i) => ( +
+ )} +
+
+ ); +} diff --git a/src/components/PostOnFeed.jsx b/src/components/PostOnFeed.jsx index cc61ec0..97e8ef3 100644 --- a/src/components/PostOnFeed.jsx +++ b/src/components/PostOnFeed.jsx @@ -17,7 +17,11 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark const handleContact = async (e) => { e.stopPropagation(); try { - const chat = await createOrGetChat(ad.userId, ad.id); + const chat = await createOrGetChat(ad.userId, ad.id, { + title: ad.title, + images: ad.images, + emoji: ad.emoji, + }); navigate(`/chats/${chat.id}`); } catch (err) { console.error(err); diff --git a/src/components/ReviewsPage.jsx b/src/components/ReviewsPage.jsx new file mode 100644 index 0000000..2c5c0a7 --- /dev/null +++ b/src/components/ReviewsPage.jsx @@ -0,0 +1,343 @@ +import { useState, useCallback, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { API_URL } from "../config/authConstants"; +import { useAuthContext } from "../context/AuthContext"; +import MenuIcon from "./icons/MenuIcon"; +import Sidebar from "./Sidebar"; +import ProfileAvatar from "./profile/ProfileAvatar"; +import { ProfileModal } from "./profile"; +import AuthModal from "./AuthModal"; +import "../styles/reviews.css"; + +/* ── Star rating sub-component ── */ +function StarRating({ value, onChange, size = 28, readOnly = false }) { + const [hovered, setHovered] = useState(0); + + return ( +
+ {[1, 2, 3, 4, 5].map((star) => { + const filled = star <= (hovered || value); + return ( + !readOnly && onChange(star)} + onMouseEnter={() => !readOnly && setHovered(star)} + onMouseLeave={() => !readOnly && setHovered(0)} + > + + + ); + })} +
+ ); +} + +/* ── Single review card ── */ +function ReviewCard({ review }) { + const date = review.createdAt + ? new Date(review.createdAt).toLocaleDateString("ru-RU", { + day: "numeric", month: "long", year: "numeric", + }) + : ""; + + return ( +
+
+
+ {review.authorName ? review.authorName.charAt(0).toUpperCase() : "?"} +
+
+ {review.authorName || "Аноним"} + {date && {date}} +
+
+ + {review.rating}/5 +
+
+ {review.text &&

{review.text}

} +
+ ); +} + +/* ── Review form ── */ +function ReviewForm({ onSubmit, submitting }) { + const [rating, setRating] = useState(0); + const [text, setText] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = (e) => { + e.preventDefault(); + if (rating === 0) { + setError("Поставьте оценку"); + return; + } + setError(""); + onSubmit({ rating, text: text.trim() }); + setRating(0); + setText(""); + }; + + return ( +
+

Оставить отзыв

+
+ Оценка: + +
+ {error &&
{error}
} +