From b1394f53306a34e66ec6c33ce38528104301d386 Mon Sep 17 00:00:00 2001 From: Faisal Misbah Date: Fri, 6 Nov 2026 22:59:38 +0500 Subject: [PATCH 1/4] feat(courses): course ratings and reviews --- .../(catalog)/catalog/[courseSlug]/page.tsx | 26 ++- .../app/(learn)/(catalog)/catalog/page.tsx | 2 + .../(main)/courses/[courseSlug]/page.tsx | 17 ++ .../courses/[courseSlug]/reports/page.tsx | 58 ++++- .../components/learn/catalog-course-card.tsx | 12 + .../chapter-complete-celebration-modal.tsx | 59 ++++- frontend/src/components/learn/chapter-nav.tsx | 1 + .../components/learn/course-rating-card.tsx | 151 +++++++++++++ .../components/learn/course-rating-link.tsx | 104 +++++++++ .../src/components/learn/course-reviews.tsx | 76 +++++++ .../learn/mark-chapter-complete.tsx | 4 + frontend/src/components/learn/star-rating.tsx | 65 ++++++ frontend/src/server/actions/course-rating.ts | 40 ++++ frontend/src/shared/api/catalog.ts | 11 +- frontend/src/shared/api/teach.ts | 6 + .../learn/course-overview-content.tsx | 7 +- .../components/learn/course-overview-hero.tsx | 5 + .../learning-ui/src/shared/api/catalog.ts | 11 +- .../migration.sql | 31 +++ .../migration.sql | 2 + server/prisma/schema.prisma | 27 +++ server/src/controllers/catalog.ts | 7 + server/src/controllers/courses.ts | 17 ++ server/src/routes/v1/catalog.ts | 5 +- server/src/routes/v1/courses.ts | 18 +- server/src/services/catalog/catalog.ts | 2 + server/src/services/courses/overview.ts | 17 +- server/src/services/courses/ratings.ts | 205 ++++++++++++++++++ server/src/services/teach/reports.ts | 5 +- server/src/shared/api/catalog.ts | 11 +- server/src/shared/api/teach.ts | 6 + server/src/validations/courses.ts | 13 ++ server/tests/api/course-ratings.test.ts | 92 ++++++++ 33 files changed, 1096 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/learn/course-rating-card.tsx create mode 100644 frontend/src/components/learn/course-rating-link.tsx create mode 100644 frontend/src/components/learn/course-reviews.tsx create mode 100644 frontend/src/components/learn/star-rating.tsx create mode 100644 frontend/src/server/actions/course-rating.ts create mode 100644 server/prisma/migrations/20261106180000_add_course_ratings/migration.sql create mode 100644 server/prisma/migrations/20261106190000_add_rating_dismissed_at/migration.sql create mode 100644 server/src/services/courses/ratings.ts create mode 100644 server/tests/api/course-ratings.test.ts diff --git a/frontend/src/app/(learn)/(catalog)/catalog/[courseSlug]/page.tsx b/frontend/src/app/(learn)/(catalog)/catalog/[courseSlug]/page.tsx index 41f41d746..90b8464ca 100644 --- a/frontend/src/app/(learn)/(catalog)/catalog/[courseSlug]/page.tsx +++ b/frontend/src/app/(learn)/(catalog)/catalog/[courseSlug]/page.tsx @@ -4,7 +4,7 @@ import type { CatalogCourseDetail } from "@/shared/api/catalog"; import { getSessionUser } from "@/server/auth/guards"; import { cn } from "@/lib/utils"; import { SITE_SHARE_IMAGE, SITE_URL } from "@/lib/site"; -import { serverApiFetchOrNotFound } from "@/lib/api-server"; +import { serverApiFetch, serverApiFetchOrNotFound } from "@/lib/api-server"; import { selfEnrollInCourseAction } from "@/server/actions/enroll"; import { CourseCover } from "@/components/learn/course-cover"; import { formatPriceCents } from "@/lib/format-price"; @@ -12,11 +12,23 @@ import { buttonVariants } from "@/components/ui/button"; import { PendingSubmitButton } from "@/components/ui/pending-submit-button"; import { CourseProjectShowcase } from "@/components/learn/course-project-showcase"; import { CourseRoadmap } from "@/components/learn/course-roadmap"; +import { CourseReviews, type CourseReviewsView } from "@/components/learn/course-reviews"; +import { StarRating } from "@/components/learn/star-rating"; async function loadCourse(courseSlug: string) { return serverApiFetchOrNotFound(`/api/v1/catalog/${encodeURIComponent(courseSlug)}`); } +async function loadReviews(courseSlug: string): Promise { + try { + return await serverApiFetch( + `/api/v1/catalog/${encodeURIComponent(courseSlug)}/reviews?page=1&pageSize=5` + ); + } catch { + return null; + } +} + export async function generateMetadata({ params }: { params: Promise<{ courseSlug: string }> }): Promise { const { courseSlug } = await params; const course = await loadCourse(courseSlug); @@ -59,6 +71,7 @@ export default async function CatalogCoursePage({ const { courseSlug } = await params; const user = await getSessionUser(); const course = await loadCourse(courseSlug); + const reviews = await loadReviews(course.slug); const hasAccess = course.access === "active"; const needsPayment = course.access === "payment_required"; // Visitors sign up first and land straight in the course (see app/enroll); learners enroll in one @@ -117,6 +130,15 @@ export default async function CatalogCoursePage({ {course.instructorName ? `Instructor · ${course.instructorName}` : "Instructor-led course"}

{course.title}

+ {course.ratingCount > 0 && course.ratingAverage != null ? ( +
+ + {course.ratingAverage.toFixed(1)} + + ({course.ratingCount} rating{course.ratingCount === 1 ? "" : "s"}) + +
+ ) : null} {course.description ? (

{course.description}

) : null} @@ -146,6 +168,8 @@ export default async function CatalogCoursePage({ + {reviews ? : null} +
diff --git a/frontend/src/app/(learn)/(catalog)/catalog/page.tsx b/frontend/src/app/(learn)/(catalog)/catalog/page.tsx index ffb21bb06..3e34d1d24 100644 --- a/frontend/src/app/(learn)/(catalog)/catalog/page.tsx +++ b/frontend/src/app/(learn)/(catalog)/catalog/page.tsx @@ -33,6 +33,8 @@ function CourseGrid({ courses }: { courses: CatalogCourseSummary[] }) { tags={course.tags} instructorName={course.instructorName} enrolledLabel={enrolledLabel} + ratingAverage={course.ratingAverage} + ratingCount={course.ratingCount} /> ); diff --git a/frontend/src/app/(learn)/(main)/courses/[courseSlug]/page.tsx b/frontend/src/app/(learn)/(main)/courses/[courseSlug]/page.tsx index 1e69dce2c..8dc432e76 100644 --- a/frontend/src/app/(learn)/(main)/courses/[courseSlug]/page.tsx +++ b/frontend/src/app/(learn)/(main)/courses/[courseSlug]/page.tsx @@ -11,6 +11,8 @@ import { WebCourseNavigation } from "@/components/learn/course-navigation"; import { chooseCourseSpecialisationAction, upgradeCourseCurriculumAction } from "@/server/actions/course-track"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { CourseRatingCard } from "@/components/learn/course-rating-card"; +import { CourseRatingLink } from "@/components/learn/course-rating-link"; const loadOverview = cache((courseSlug: string) => serverApiFetchOrNotFound(`/api/v1/courses/${encodeURIComponent(courseSlug)}/overview`) @@ -115,7 +117,22 @@ export default async function CourseOverviewPage({ "use server"; await upgradeCourseCurriculumAction(course.slug); }} + ratingLink={ + enrollment.status === "COMPLETED" && !overview.rating && overview.ratingDismissed ? ( + + ) : null + } /> + {/* The completion modal asks first; this card is the full form or the edit surface. */} + {enrollment.status === "COMPLETED" && + (overview.rating || !overview.ratingDismissed) ? ( + + ) : null} ); } diff --git a/frontend/src/app/(teach)/courses/[courseSlug]/reports/page.tsx b/frontend/src/app/(teach)/courses/[courseSlug]/reports/page.tsx index 76204eb9d..24bb6e972 100644 --- a/frontend/src/app/(teach)/courses/[courseSlug]/reports/page.tsx +++ b/frontend/src/app/(teach)/courses/[courseSlug]/reports/page.tsx @@ -4,10 +4,12 @@ import { BarChart3, CircleCheckBig, Download, + Star, TrendingUp, Users, type LucideIcon, } from "lucide-react"; +import { StarRating } from "@/components/learn/star-rating"; import type { ChapterReportView, CourseReportView } from "@/shared/api/teach"; import { nowMs } from "@/server/time"; import { requireTeachSurface } from "@/server/auth/guards"; @@ -33,7 +35,7 @@ export default async function CourseReportsPage({ const report = await serverApiFetchOrNotFound( `/api/v1/teach/courses/${encodeURIComponent(courseSlug)}/reports${query}` ); - const { course, summary, enrollments, chapterReport } = report; + const { course, summary, enrollments, chapterReport, ratings } = report; const atRisk = enrollments.filter((e) => e.atRisk); const now = nowMs(); @@ -87,6 +89,60 @@ export default async function CourseReportsPage({ +
+
+
+

Course rating

+

What learners said

+
+
+

+ + {ratings.average != null ? ratings.average.toFixed(1) : "—"} +

+

+ {ratings.count} rating{ratings.count === 1 ? "" : "s"} +

+
+
+ + {ratings.count === 0 ? ( +

+ No ratings yet. Learners can rate the course after completing it. +

+ ) : ( +
+
+ {ratings.distribution.map((row) => { + const percent = ratings.count > 0 ? Math.round((row.count / ratings.count) * 100) : 0; + return ( +
+ {row.stars}★ + + + + {percent}% +
+ ); + })} +
+
    + {ratings.recent.map((row) => ( +
  • +
    + + {row.reviewer} +
    + {row.review ? ( +

    {row.review}

    + ) : null} +
  • + ))} +
+
+ )} +
+ {atRisk.length > 0 && (

diff --git a/frontend/src/components/learn/catalog-course-card.tsx b/frontend/src/components/learn/catalog-course-card.tsx index 718c3f95d..6d4c6e538 100644 --- a/frontend/src/components/learn/catalog-course-card.tsx +++ b/frontend/src/components/learn/catalog-course-card.tsx @@ -1,6 +1,7 @@ import { EnrollmentBadge } from "@buildment/learning-ui/components/learn/enrollment-badge"; import Link from "next/link"; import { CourseCover } from "@/components/learn/course-cover"; +import { StarRating } from "@/components/learn/star-rating"; import { formatPriceCents } from "@/lib/format-price"; export type CatalogCourseCardProps = { @@ -17,6 +18,8 @@ export type CatalogCourseCardProps = { enrolledLabel?: string | null; subcategory?: string | null; tags?: string[]; + ratingAverage?: number | null; + ratingCount?: number; }; /** The whole card is the link: a big target on a phone, and one tab stop per course. */ @@ -34,6 +37,8 @@ export function CatalogCourseCard({ enrolledLabel, subcategory, tags = [], + ratingAverage = null, + ratingCount = 0, }: CatalogCourseCardProps) { const meta = [difficulty ? difficulty.toLowerCase() : null, estimatedHours ? `${estimatedHours} hours` : null] .filter(Boolean) @@ -48,6 +53,13 @@ export function CatalogCourseCard({ {meta ?

{meta}

: null} {subcategory ?

{subcategory}

: null}

{title}

+ {ratingCount > 0 && ratingAverage != null ? ( +
+ + {ratingAverage.toFixed(1)} + ({ratingCount}) +
+ ) : null} {tags.length ?

{tags.slice(0, 3).join(" · ")}

: null} {instructorName ?

With {instructorName}

: null} {description ? ( diff --git a/frontend/src/components/learn/chapter-complete-celebration-modal.tsx b/frontend/src/components/learn/chapter-complete-celebration-modal.tsx index d11fe28cb..1a9f6960d 100644 --- a/frontend/src/components/learn/chapter-complete-celebration-modal.tsx +++ b/frontend/src/components/learn/chapter-complete-celebration-modal.tsx @@ -1,11 +1,13 @@ "use client"; import { cn } from "@/lib/utils"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { ArrowRight, CheckCircle2, X } from "lucide-react"; import { Button, buttonVariants } from "@/components/ui/button"; import { Progress, ProgressLabel } from "@/components/ui/progress"; +import { StarRatingInput } from "@/components/learn/star-rating"; +import { saveCourseRatingAction } from "@/server/actions/course-rating"; export function ChapterCompleteCelebrationModal({ open, @@ -16,6 +18,7 @@ export function ChapterCompleteCelebrationModal({ courseTotal, nextHref, nextTitle, + courseSlug, }: { open: boolean; onClose: () => void; @@ -26,12 +29,29 @@ export function ChapterCompleteCelebrationModal({ courseTotal: number; nextHref?: string; nextTitle?: string; + /** Enables the one-time rating prompt when the course is complete. */ + courseSlug?: string; }) { + const [rating, setRating] = useState(0); + const [review, setReview] = useState(""); + const [ratingPending, setRatingPending] = useState(false); + const [ratingSaved, setRatingSaved] = useState(false); + const [ratingError, setRatingError] = useState(null); const dialogRef = useRef(null); const completed = Math.min(courseTotal, Math.max(0, courseCompleted)); const percent = courseTotal > 0 ? Math.round((completed / courseTotal) * 100) : 0; const courseComplete = courseTotal > 0 && completed === courseTotal; + async function submitRating() { + if (!courseSlug || rating < 1) return; + setRatingPending(true); + setRatingError(null); + const res = await saveCourseRatingAction({ courseSlug, rating, review: review.trim() || null }); + setRatingPending(false); + if (res.ok) setRatingSaved(true); + else setRatingError(res.error); + } + useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -98,6 +118,43 @@ export function ChapterCompleteCelebrationModal({

{percent}% complete

) : null} + {courseComplete && courseSlug ? ( +
+ {ratingSaved ? ( +

Thanks for rating the course.

+ ) : ( + <> +

How was the course?

+

+ Your rating helps other learners decide. +

+
+ +
+