348 lines
12 KiB
JavaScript
348 lines
12 KiB
JavaScript
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 "../components/icons/MenuIcon";
|
|
import Sidebar from "../components/Sidebar";
|
|
import ProfileAvatar from "../components/profile/ProfileAvatar";
|
|
import { ProfileModal } from "../components/profile";
|
|
import AuthModal from "../components/AuthModal";
|
|
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 [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);
|
|
|
|
/* 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: user.avatar && user.avatar.startsWith("/") ? `${API_URL}${user.avatar}` : user.avatar || null,
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
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-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="rv-layout">
|
|
{/* Left: Summary + form */}
|
|
<div className="rv-sidebar">
|
|
{/* Summary */}
|
|
<div className="rv-summary">
|
|
<div className="rv-summary-score">
|
|
{avgRating ? (
|
|
<>
|
|
<span className="rv-summary-num">{avgRating}</span>
|
|
<StarRating value={Math.round(Number(avgRating))} readOnly size={22} />
|
|
</>
|
|
) : (
|
|
<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>
|
|
|
|
{/* 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>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile modal */}
|
|
{profileModalOpen && isAuthenticated && (
|
|
<ProfileModal onClose={() => setProfileModalOpen(false)} initialData={profileData} />
|
|
)}
|
|
|
|
{/* Auth modal */}
|
|
{authModalOpen && (
|
|
<AuthModal onClose={() => setAuthModalOpen(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|