diff --git a/backend/controllers/courseReviewController.js b/backend/controllers/courseReviewController.js new file mode 100644 index 0000000..b7ad3a1 --- /dev/null +++ b/backend/controllers/courseReviewController.js @@ -0,0 +1,372 @@ +const mongoose = require("mongoose"); +const CourseReview = require("../schemas/courseReviewModel"); +const Course = require("../schemas/courseModel"); +const EnrolledCourse = require("../schemas/enrolledCourseModel"); + +const ALLOWED_SORTS = new Set(["newest", "oldest", "highest", "lowest"]); + +const parsePositiveInteger = (value, fallback, max) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.min(parsed, max); +}; + +const validateCourseId = (courseId) => mongoose.Types.ObjectId.isValid(courseId); + +const getAuthenticatedUserId = (req) => + req.user?._id?.toString() || req.body?.userId || null; + +const serializeReview = (review, currentUserId = null) => ({ + id: review._id.toString(), + rating: review.rating, + reviewText: review.reviewText || "", + createdAt: review.createdAt, + updatedAt: review.updatedAt, + verifiedEnrollment: true, + user: { + id: review.userId?._id?.toString() || review.userId?.toString() || null, + name: review.userId?.name || "LearnHub student", + }, + isOwner: + Boolean(currentUserId) && + String(review.userId?._id || review.userId) === String(currentUserId), +}); + +const getSummary = async (courseId) => { + const objectId = new mongoose.Types.ObjectId(courseId); + const [summary] = await CourseReview.aggregate([ + { $match: { courseId: objectId } }, + { + $group: { + _id: "$courseId", + averageRating: { $avg: "$rating" }, + totalReviews: { $sum: 1 }, + oneStar: { $sum: { $cond: [{ $eq: ["$rating", 1] }, 1, 0] } }, + twoStar: { $sum: { $cond: [{ $eq: ["$rating", 2] }, 1, 0] } }, + threeStar: { $sum: { $cond: [{ $eq: ["$rating", 3] }, 1, 0] } }, + fourStar: { $sum: { $cond: [{ $eq: ["$rating", 4] }, 1, 0] } }, + fiveStar: { $sum: { $cond: [{ $eq: ["$rating", 5] }, 1, 0] } }, + }, + }, + ]); + + return { + averageRating: summary + ? Number(summary.averageRating.toFixed(1)) + : 0, + totalReviews: summary?.totalReviews || 0, + distribution: { + 1: summary?.oneStar || 0, + 2: summary?.twoStar || 0, + 3: summary?.threeStar || 0, + 4: summary?.fourStar || 0, + 5: summary?.fiveStar || 0, + }, + }; +}; + +const ensureCourseExists = async (courseId) => { + const course = await Course.findById(courseId).select("_id C_title").lean(); + return course; +}; + +const ensureEnrollment = async (userId, courseId) => { + return EnrolledCourse.findOne({ userId, courseId }).select("_id").lean(); +}; + +const createReview = async (req, res) => { + try { + const { courseId } = req.params; + const userId = getAuthenticatedUserId(req); + const rating = Number(req.body.rating); + const reviewText = String(req.body.reviewText || "").trim(); + + if (!userId || !validateCourseId(courseId)) { + return res.status(400).send({ + success: false, + message: "A valid course and authenticated user are required.", + }); + } + + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + return res.status(400).send({ + success: false, + message: "Rating must be an integer from 1 to 5.", + }); + } + + if (reviewText.length > 1000) { + return res.status(400).send({ + success: false, + message: "Review text cannot exceed 1000 characters.", + }); + } + + const course = await ensureCourseExists(courseId); + if (!course) { + return res.status(404).send({ + success: false, + message: "Course not found.", + }); + } + + const enrollment = await ensureEnrollment(userId, courseId); + if (!enrollment) { + return res.status(403).send({ + success: false, + message: "Only enrolled students can review this course.", + }); + } + + const review = await CourseReview.create({ + userId, + courseId, + rating, + reviewText, + }); + + await review.populate("userId", "name"); + + return res.status(201).send({ + success: true, + message: "Review submitted successfully.", + data: serializeReview(review, userId), + summary: await getSummary(courseId), + }); + } catch (error) { + if (error?.code === 11000) { + return res.status(409).send({ + success: false, + message: "You have already reviewed this course.", + }); + } + + console.error("Unable to create course review:", error); + return res.status(500).send({ + success: false, + message: "Unable to submit the review.", + }); + } +}; + +const listReviews = async (req, res) => { + try { + const { courseId } = req.params; + if (!validateCourseId(courseId)) { + return res.status(400).send({ + success: false, + message: "Invalid course ID.", + }); + } + + const page = parsePositiveInteger(req.query.page, 1, 100000); + const limit = parsePositiveInteger(req.query.limit, 5, 25); + const sort = String(req.query.sort || "newest").toLowerCase(); + + if (!ALLOWED_SORTS.has(sort)) { + return res.status(400).send({ + success: false, + message: "Invalid review sort option.", + }); + } + + const sortMap = { + newest: { createdAt: -1 }, + oldest: { createdAt: 1 }, + highest: { rating: -1, createdAt: -1 }, + lowest: { rating: 1, createdAt: -1 }, + }; + + const totalItems = await CourseReview.countDocuments({ courseId }); + const totalPages = Math.max(1, Math.ceil(totalItems / limit)); + const safePage = Math.min(page, totalPages); + const currentUserId = req.user?._id?.toString() || null; + + const reviews = await CourseReview.find({ courseId }) + .populate("userId", "name") + .sort(sortMap[sort]) + .skip((safePage - 1) * limit) + .limit(limit) + .lean(); + + return res.status(200).send({ + success: true, + data: reviews.map((review) => serializeReview(review, currentUserId)), + summary: await getSummary(courseId), + pagination: { + page: safePage, + limit, + totalItems, + totalPages, + hasPreviousPage: safePage > 1, + hasNextPage: safePage < totalPages, + }, + sort, + }); + } catch (error) { + console.error("Unable to retrieve course reviews:", error); + return res.status(500).send({ + success: false, + message: "Unable to retrieve course reviews.", + }); + } +}; + +const getRatingSummary = async (req, res) => { + try { + const { courseId } = req.params; + if (!validateCourseId(courseId)) { + return res.status(400).send({ + success: false, + message: "Invalid course ID.", + }); + } + + return res.status(200).send({ + success: true, + data: await getSummary(courseId), + }); + } catch (error) { + console.error("Unable to retrieve rating summary:", error); + return res.status(500).send({ + success: false, + message: "Unable to retrieve rating summary.", + }); + } +}; + +const updateReview = async (req, res) => { + try { + const { reviewId } = req.params; + const userId = getAuthenticatedUserId(req); + const rating = Number(req.body.rating); + const reviewText = String(req.body.reviewText || "").trim(); + + if (!mongoose.Types.ObjectId.isValid(reviewId)) { + return res.status(400).send({ + success: false, + message: "Invalid review ID.", + }); + } + + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + return res.status(400).send({ + success: false, + message: "Rating must be an integer from 1 to 5.", + }); + } + + if (reviewText.length > 1000) { + return res.status(400).send({ + success: false, + message: "Review text cannot exceed 1000 characters.", + }); + } + + const review = await CourseReview.findOneAndUpdate( + { _id: reviewId, userId }, + { rating, reviewText }, + { new: true, runValidators: true }, + ).populate("userId", "name"); + + if (!review) { + return res.status(404).send({ + success: false, + message: "Review not found or you do not own it.", + }); + } + + return res.status(200).send({ + success: true, + message: "Review updated successfully.", + data: serializeReview(review, userId), + summary: await getSummary(review.courseId.toString()), + }); + } catch (error) { + console.error("Unable to update course review:", error); + return res.status(500).send({ + success: false, + message: "Unable to update the review.", + }); + } +}; + +const deleteReview = async (req, res) => { + try { + const { reviewId } = req.params; + const userId = getAuthenticatedUserId(req); + + if (!mongoose.Types.ObjectId.isValid(reviewId)) { + return res.status(400).send({ + success: false, + message: "Invalid review ID.", + }); + } + + const review = await CourseReview.findOneAndDelete({ + _id: reviewId, + userId, + }); + + if (!review) { + return res.status(404).send({ + success: false, + message: "Review not found or you do not own it.", + }); + } + + return res.status(200).send({ + success: true, + message: "Review deleted successfully.", + summary: await getSummary(review.courseId.toString()), + }); + } catch (error) { + console.error("Unable to delete course review:", error); + return res.status(500).send({ + success: false, + message: "Unable to delete the review.", + }); + } +}; + +const getMyReview = async (req, res) => { + try { + const { courseId } = req.params; + const userId = getAuthenticatedUserId(req); + + if (!validateCourseId(courseId)) { + return res.status(400).send({ + success: false, + message: "Invalid course ID.", + }); + } + + const [review, enrollment] = await Promise.all([ + CourseReview.findOne({ courseId, userId }) + .populate("userId", "name") + .lean(), + ensureEnrollment(userId, courseId), + ]); + + return res.status(200).send({ + success: true, + data: review ? serializeReview(review, userId) : null, + canReview: Boolean(enrollment), + }); + } catch (error) { + console.error("Unable to retrieve current review:", error); + return res.status(500).send({ + success: false, + message: "Unable to retrieve your review.", + }); + } +}; + +module.exports = { + createReview, + listReviews, + getRatingSummary, + updateReview, + deleteReview, + getMyReview, +}; diff --git a/backend/index.js b/backend/index.js index 703d4f7..6b5f0d0 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,4 +1,4 @@ -// Serve admin dashboard at /api/admin/dashboard +// Serve admin dashboard at /api/admin/dashboard // (Moved below app initialization) const express = require('express') @@ -23,7 +23,7 @@ app.use(cors()) const uploadsDir = path.join(__dirname, "uploads"); -// Create uploads folder if it doesn’t exist +// Create uploads folder if it doesn’t exist if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir); } @@ -49,7 +49,8 @@ app.get('/api/admin', (req, res) => { ///ROUTES/// app.use('/api/admin', require('./routers/adminRoutes')) app.use('/api/user', require('./routers/userRoutes')) +app.use('/api/reviews', require('./routers/courseReviewRoutes')) -app.listen(PORT, () => console.log(`running on ${PORT}`)) \ No newline at end of file +app.listen(PORT, () => console.log(`running on ${PORT}`)) diff --git a/backend/routers/courseReviewRoutes.js b/backend/routers/courseReviewRoutes.js new file mode 100644 index 0000000..c797258 --- /dev/null +++ b/backend/routers/courseReviewRoutes.js @@ -0,0 +1,21 @@ +const express = require("express"); +const authMiddleware = require("../middlewares/authMiddleware"); +const { + createReview, + listReviews, + getRatingSummary, + updateReview, + deleteReview, + getMyReview, +} = require("../controllers/courseReviewController"); + +const router = express.Router(); + +router.get("/:courseId", listReviews); +router.get("/:courseId/summary", getRatingSummary); +router.get("/:courseId/mine", authMiddleware, getMyReview); +router.post("/:courseId", authMiddleware, createReview); +router.put("/review/:reviewId", authMiddleware, updateReview); +router.delete("/review/:reviewId", authMiddleware, deleteReview); + +module.exports = router; diff --git a/backend/schemas/courseReviewModel.js b/backend/schemas/courseReviewModel.js new file mode 100644 index 0000000..bc0ac78 --- /dev/null +++ b/backend/schemas/courseReviewModel.js @@ -0,0 +1,36 @@ +const mongoose = require("mongoose"); + +const courseReviewSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "user", + required: true, + index: true, + }, + courseId: { + type: mongoose.Schema.Types.ObjectId, + ref: "course", + required: true, + index: true, + }, + rating: { + type: Number, + required: true, + min: 1, + max: 5, + }, + reviewText: { + type: String, + trim: true, + maxlength: 1000, + default: "", + }, + }, + { timestamps: true, versionKey: false }, +); + +courseReviewSchema.index({ userId: 1, courseId: 1 }, { unique: true }); +courseReviewSchema.index({ courseId: 1, createdAt: -1 }); + +module.exports = mongoose.model("courseReview", courseReviewSchema); diff --git a/docs/course-reviews.md b/docs/course-reviews.md new file mode 100644 index 0000000..742a3db --- /dev/null +++ b/docs/course-reviews.md @@ -0,0 +1,78 @@ +# Course ratings and reviews + +## API + +Public: + +```http +GET /api/reviews/:courseId +GET /api/reviews/:courseId/summary +``` + +Authenticated: + +```http +GET /api/reviews/:courseId/mine +POST /api/reviews/:courseId +PUT /api/reviews/review/:reviewId +DELETE /api/reviews/review/:reviewId +``` + +Authenticated requests require: + +```http +Authorization: Bearer +``` + +## Rules + +- Only enrolled users can create reviews. +- One review is allowed per user/course combination. +- Rating must be an integer from 1 through 5. +- Review text is optional and limited to 1000 characters. +- A user may update or delete only their own review. +- Public responses expose only reviewer names, not emails or credentials. +- A compound unique MongoDB index enforces one review per course per student. + +## Frontend integration + +In `CourseContent.jsx`: + +```jsx +import CourseReviews from "../../reviews/CourseReviews"; +``` + +Render near the bottom of the page: + +```jsx + +``` + +In `AllCourses.jsx`: + +```jsx +import CourseRatingBadge from "../reviews/CourseRatingBadge"; +``` + +Render within each course card: + +```jsx + +``` + +Adjust the relative import if your UI-redesign branch has moved either component. + +## Manual testing + +1. Seed the database. +2. Sign in as a student who is not enrolled. +3. Confirm the review form is not available. +4. Enroll in a course. +5. Submit a 1–5 star review. +6. Confirm a second review returns `409`. +7. Edit the existing review. +8. Delete the review. +9. Test all four sort modes. +10. Create at least six reviews and test pagination. +11. Verify the course card average and review count. +12. Verify no email, password, or token appears in public review responses. diff --git a/frontend/src/components/common/AllCourses.jsx b/frontend/src/components/common/AllCourses.jsx index ee3e757..844382a 100644 --- a/frontend/src/components/common/AllCourses.jsx +++ b/frontend/src/components/common/AllCourses.jsx @@ -3,6 +3,7 @@ import { Button, Form, Modal } from "react-bootstrap"; import { MDBCol, MDBInput, MDBRow } from "mdb-react-ui-kit"; import { Link, useNavigate } from "react-router-dom"; import { UserContext } from "../../App"; +import CourseRatingBadge from "../reviews/CourseRatingBadge"; import axiosInstance from "./AxiosInstance"; const paletteByCategory = [ @@ -244,6 +245,10 @@ const AllCourses = () => {

{descriptionForCourse(course)}

+ diff --git a/frontend/src/components/reviews/CourseRatingBadge.jsx b/frontend/src/components/reviews/CourseRatingBadge.jsx new file mode 100644 index 0000000..9f3be3f --- /dev/null +++ b/frontend/src/components/reviews/CourseRatingBadge.jsx @@ -0,0 +1,47 @@ +import React, { useEffect, useState } from "react"; +import axiosInstance from "../common/AxiosInstance"; +import RatingStars from "./RatingStars"; +import "./CourseReviews.css"; + +const CourseRatingBadge = ({ courseId, compact = false }) => { + const [summary, setSummary] = useState({ + averageRating: 0, + totalReviews: 0, + }); + + useEffect(() => { + if (!courseId) return undefined; + + let active = true; + + axiosInstance + .get(`/api/reviews/${courseId}/summary`) + .then((response) => { + if (active && response.data.success) { + setSummary(response.data.data); + } + }) + .catch(() => { + if (active) { + setSummary({ averageRating: 0, totalReviews: 0 }); + } + }); + + return () => { + active = false; + }; + }, [courseId]); + + return ( +
+ + {summary.averageRating || "New"} + + {summary.totalReviews}{" "} + {summary.totalReviews === 1 ? "review" : "reviews"} + +
+ ); +}; + +export default CourseRatingBadge; diff --git a/frontend/src/components/reviews/CourseReviews.css b/frontend/src/components/reviews/CourseReviews.css new file mode 100644 index 0000000..e578da1 --- /dev/null +++ b/frontend/src/components/reviews/CourseReviews.css @@ -0,0 +1,349 @@ +.course-reviews { + --review-ink: #17201d; + --review-muted: #65706b; + --review-paper: #f5f1e8; + --review-surface: #fffdf8; + --review-line: rgba(23, 32, 29, 0.14); + --review-blue: #315bd6; + --review-green: #18865f; + --review-gold: #e0a72f; + margin: 48px auto; + padding: clamp(20px, 4vw, 40px); + max-width: 1100px; + color: var(--review-ink); + border: 1px solid var(--review-line); + border-radius: 26px; + background: var(--review-paper); +} + +.course-reviews-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; +} + +.reviews-eyebrow { + margin: 0 0 8px; + color: var(--review-blue); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.15em; +} + +.course-reviews h2 { + margin: 0; + font-size: clamp(1.8rem, 5vw, 3.2rem); + letter-spacing: -0.05em; +} + +.rating-overview { + display: flex; + align-items: center; + gap: 14px; +} + +.rating-overview > strong { + font-size: 3.2rem; + letter-spacing: -0.08em; +} + +.rating-overview span { + display: block; + color: var(--review-muted); + font-size: 0.8rem; +} + +.review-stars { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.review-star-active { + color: var(--review-gold); +} + +.review-star-inactive { + color: #c9c5bb; +} + +.review-star-button { + padding: 0 2px; + border: 0; + background: transparent; + font-size: 1.8rem; + cursor: pointer; +} + +.rating-distribution { + margin: 28px 0; + max-width: 520px; +} + +.rating-distribution-row { + display: grid; + grid-template-columns: 38px 1fr 30px; + align-items: center; + gap: 10px; + margin: 7px 0; + font-size: 0.77rem; +} + +.rating-distribution-row > div { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: #ddd7ca; +} + +.rating-distribution-row > div span { + height: 100%; + display: block; + border-radius: inherit; + background: var(--review-gold); +} + +.review-form-card, +.review-card, +.review-empty, +.review-loading, +.review-eligibility-message { + border: 1px solid var(--review-line); + border-radius: 18px; + background: var(--review-surface); +} + +.review-form-card { + padding: 22px; +} + +.review-form-heading, +.review-card header, +.reviews-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.review-form-heading h3 { + margin: 7px 0 0; +} + +.verified-review-badge { + display: inline-flex; + color: var(--review-green); + font-size: 0.7rem; + font-weight: 800; +} + +.review-owner-actions, +.review-form-actions { + display: flex; + gap: 8px; +} + +.review-form-card label { + margin-top: 18px; + display: block; +} + +.review-form-card label > span:first-child { + margin-bottom: 7px; + display: block; + font-size: 0.75rem; + font-weight: 800; +} + +.review-form-card textarea { + width: 100%; + padding: 12px; + resize: vertical; + border: 1px solid var(--review-line); + border-radius: 12px; + background: white; +} + +.review-form-card label small { + display: block; + margin-top: 5px; + text-align: right; + color: var(--review-muted); +} + +.review-form-actions { + justify-content: flex-end; + margin-top: 14px; +} + +.review-form-actions button, +.review-owner-actions button, +.review-pagination button { + min-height: 38px; + padding: 0 14px; + border: 1px solid var(--review-line); + border-radius: 999px; + background: white; + font-weight: 700; + cursor: pointer; +} + +.review-form-actions button:last-child { + color: white; + border-color: var(--review-ink); + background: var(--review-ink); +} + +.review-notice, +.review-error, +.review-eligibility-message, +.review-loading, +.review-empty { + margin: 16px 0; + padding: 16px; +} + +.review-notice { + color: #24614c; + border-radius: 12px; + background: #e8f6ef; +} + +.review-error { + color: #912e2e; + border-radius: 12px; + background: #fff0ee; +} + +.review-eligibility-message, +.review-loading, +.review-empty { + color: var(--review-muted); +} + +.reviews-toolbar { + margin: 32px 0 14px; +} + +.reviews-toolbar h3 { + margin: 0; +} + +.reviews-toolbar select { + min-height: 38px; + padding: 0 12px; + border: 1px solid var(--review-line); + border-radius: 999px; + background: white; +} + +.review-list { + display: grid; + gap: 12px; +} + +.review-card { + padding: 20px; +} + +.review-card header { + justify-content: flex-start; + margin-bottom: 12px; +} + +.review-card header time { + margin-left: auto; + color: var(--review-muted); + font-size: 0.75rem; +} + +.review-card header > div:nth-child(2) { + display: flex; + flex-direction: column; + gap: 3px; +} + +.review-avatar { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 50%; + color: white; + background: var(--review-blue); + font-weight: 800; +} + +.review-card p, +.my-review-preview p { + margin: 10px 0 0; + line-height: 1.65; +} + +.review-pagination { + margin-top: 18px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; +} + +.review-pagination button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.course-rating-badge { + margin: 9px 0; + display: flex; + align-items: center; + gap: 7px; + color: var(--review-muted); + font-size: 0.76rem; +} + +.course-rating-badge strong { + color: var(--review-ink); +} + +.course-rating-badge.is-compact span:last-child { + display: none; +} + +.sr-only { + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + position: absolute; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 680px) { + .course-reviews-header { + align-items: flex-start; + flex-direction: column; + } + + .review-card header { + align-items: flex-start; + flex-wrap: wrap; + } + + .review-card header time { + width: 100%; + margin-left: 54px; + } + + .review-form-heading { + align-items: flex-start; + } + + .review-pagination { + justify-content: space-between; + } +} diff --git a/frontend/src/components/reviews/CourseReviews.jsx b/frontend/src/components/reviews/CourseReviews.jsx new file mode 100644 index 0000000..3e098c3 --- /dev/null +++ b/frontend/src/components/reviews/CourseReviews.jsx @@ -0,0 +1,390 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import axiosInstance from "../common/AxiosInstance"; +import RatingStars from "./RatingStars"; +import "./CourseReviews.css"; + +const initialPagination = { + page: 1, + totalPages: 1, + totalItems: 0, + hasPreviousPage: false, + hasNextPage: false, +}; + +const emptySummary = { + averageRating: 0, + totalReviews: 0, + distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, +}; + +const formatDate = (value) => + new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(new Date(value)); + +const CourseReviews = ({ courseId, courseTitle }) => { + const token = localStorage.getItem("token"); + const [reviews, setReviews] = useState([]); + const [summary, setSummary] = useState(emptySummary); + const [pagination, setPagination] = useState(initialPagination); + const [sort, setSort] = useState("newest"); + const [page, setPage] = useState(1); + const [myReview, setMyReview] = useState(null); + const [canReview, setCanReview] = useState(false); + const [rating, setRating] = useState(0); + const [reviewText, setReviewText] = useState(""); + const [editing, setEditing] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + const authHeaders = useMemo( + () => + token + ? { headers: { Authorization: `Bearer ${token}` } } + : undefined, + [token], + ); + + const loadReviews = useCallback(async () => { + if (!courseId) return; + + setLoading(true); + setError(""); + + try { + const response = await axiosInstance.get( + `/api/reviews/${courseId}?page=${page}&limit=5&sort=${sort}`, + ); + + setReviews(response.data.data || []); + setSummary(response.data.summary || emptySummary); + setPagination(response.data.pagination || initialPagination); + } catch (requestError) { + setError( + requestError.response?.data?.message || + "Reviews could not be loaded.", + ); + } finally { + setLoading(false); + } + }, [courseId, page, sort]); + + const loadMyReview = useCallback(async () => { + if (!courseId || !token) return; + + try { + const response = await axiosInstance.get( + `/api/reviews/${courseId}/mine`, + authHeaders, + ); + + setMyReview(response.data.data); + setCanReview(Boolean(response.data.canReview)); + + if (response.data.data) { + setRating(response.data.data.rating); + setReviewText(response.data.data.reviewText || ""); + } + } catch { + setCanReview(false); + } + }, [authHeaders, courseId, token]); + + useEffect(() => { + loadReviews(); + }, [loadReviews]); + + useEffect(() => { + loadMyReview(); + }, [loadMyReview]); + + const resetForm = () => { + if (myReview) { + setRating(myReview.rating); + setReviewText(myReview.reviewText || ""); + } else { + setRating(0); + setReviewText(""); + } + setEditing(false); + }; + + const submitReview = async (event) => { + event.preventDefault(); + setError(""); + setNotice(""); + + if (!rating) { + setError("Choose a rating before submitting."); + return; + } + + setSaving(true); + + try { + const payload = { rating, reviewText }; + const response = myReview + ? await axiosInstance.put( + `/api/reviews/review/${myReview.id}`, + payload, + authHeaders, + ) + : await axiosInstance.post( + `/api/reviews/${courseId}`, + payload, + authHeaders, + ); + + setMyReview(response.data.data); + setSummary(response.data.summary); + setNotice(response.data.message); + setEditing(false); + setPage(1); + await loadReviews(); + } catch (requestError) { + setError( + requestError.response?.data?.message || + "Your review could not be saved.", + ); + } finally { + setSaving(false); + } + }; + + const removeReview = async () => { + if (!myReview) return; + if (!window.confirm("Delete your review permanently?")) return; + + setSaving(true); + setError(""); + setNotice(""); + + try { + const response = await axiosInstance.delete( + `/api/reviews/review/${myReview.id}`, + authHeaders, + ); + + setMyReview(null); + setRating(0); + setReviewText(""); + setSummary(response.data.summary); + setNotice(response.data.message); + setPage(1); + await loadReviews(); + } catch (requestError) { + setError( + requestError.response?.data?.message || + "Your review could not be deleted.", + ); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

STUDENT FEEDBACK

+

+ Reviews for {courseTitle || "this course"} +

+
+ +
+ {summary.averageRating.toFixed(1)} +
+ + + {summary.totalReviews}{" "} + {summary.totalReviews === 1 ? "review" : "reviews"} + +
+
+
+ +
+ {[5, 4, 3, 2, 1].map((star) => { + const count = summary.distribution?.[star] || 0; + const percent = summary.totalReviews + ? Math.round((count / summary.totalReviews) * 100) + : 0; + + return ( +
+ {star} ★ +
+ +
+ {count} +
+ ); + })} +
+ + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {token && canReview ? ( +
+
+
+ ✓ Verified enrollment +

{myReview ? "Your review" : "Share your experience"}

+
+ + {myReview && !editing ? ( +
+ + +
+ ) : null} +
+ + {!myReview || editing ? ( +
+ + +