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..70472f1e3 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`) @@ -106,6 +108,11 @@ export default async function CourseOverviewPage({ // Enroll, sign-up and the variant picker send ?start=1: open the lesson to read next, like a player. if (start === "1" && continueHref) redirect(continueHref); + const canRateCourse = + enrollment.status === "COMPLETED" || + enrollment.percentComplete > 50 || + (syllabus.chapterCount > 0 && enrollment.chaptersCompleted * 2 > syllabus.chapterCount); + return ( + ) : null + } + ratingBanner={ + canRateCourse && (overview.rating || !overview.ratingDismissed) ? ( + + ) : null + } /> ); diff --git a/frontend/src/app/(learn)/courses/[courseSlug]/[chapterSlug]/page.tsx b/frontend/src/app/(learn)/courses/[courseSlug]/[chapterSlug]/page.tsx index bbc9bde62..47e20e20d 100644 --- a/frontend/src/app/(learn)/courses/[courseSlug]/[chapterSlug]/page.tsx +++ b/frontend/src/app/(learn)/courses/[courseSlug]/[chapterSlug]/page.tsx @@ -115,6 +115,7 @@ export default async function ChapterReaderPage({ headingNotes={content.headingNotes} predictGate={content.predictGate} predictRevealed={content.predictRevealed} + promptCourseRating={Boolean(content.promptCourseRating)} helpThread={content.helpThread} narration={narration} user={user} 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/app/globals.css b/frontend/src/app/globals.css index b229a924a..b976f5870 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -718,6 +718,32 @@ animation: mascot-steam 1600ms ease-in-out infinite; } +@keyframes dot-yip-hop { + 0%, + 100% { + transform: translateY(0) scale(1, 1) rotate(0deg); + } + 18% { + transform: translateY(-12px) scale(0.96, 1.06) rotate(-4deg); + } + 36% { + transform: translateY(0) scale(1.06, 0.93) rotate(0deg); + } + 54% { + transform: translateY(-10px) scale(0.97, 1.05) rotate(4deg); + } + 72% { + transform: translateY(0) scale(1.04, 0.95) rotate(0deg); + } +} + +.dot-yip { + transform-origin: 50% 92%; + animation: + mascot-enter 340ms cubic-bezier(0.34, 1.4, 0.64, 1) both, + dot-yip-hop 1100ms ease-in-out 280ms infinite; +} + @media (prefers-reduced-motion: reduce) { .mascot-enter, .mascot-motion-jump, @@ -733,7 +759,8 @@ .mascot-part-twinkle, .mascot-part-glow, .mascot-part-confetti, - .mascot-part-steam { + .mascot-part-steam, + .dot-yip { animation: none !important; opacity: 1 !important; transform: none !important; 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..a70278d3b 100644 --- a/frontend/src/components/learn/chapter-complete-celebration-modal.tsx +++ b/frontend/src/components/learn/chapter-complete-celebration-modal.tsx @@ -98,6 +98,7 @@ export function ChapterCompleteCelebrationModal({

{percent}% complete

) : null} +
{courseComplete ? ( View my courses diff --git a/frontend/src/components/learn/chapter-reader-shell.tsx b/frontend/src/components/learn/chapter-reader-shell.tsx index 2254ee098..662ab21c8 100644 --- a/frontend/src/components/learn/chapter-reader-shell.tsx +++ b/frontend/src/components/learn/chapter-reader-shell.tsx @@ -28,6 +28,7 @@ import type { HelpThreadView } from "@/shared/api/help"; import type { StreakMilestone } from "@/shared/lib/streak"; import { HeadingNotesProvider } from "@/components/learn/heading-note"; import { ChapterAudio } from "@/components/learn/chapter-audio"; +import { CourseRatingPrompt } from "@/components/learn/course-rating-modal"; import { AskChapterPanel } from "@/components/learn/ask-chapter-panel"; import { AskChapterProvider } from "@/components/learn/ask-chapter-context"; import { AskAiFloating, AskAiHeaderButton } from "@/components/learn/ask-ai-floating"; @@ -85,6 +86,7 @@ export function ChapterReaderShell({ headingNotes = {}, predictGate = false, predictRevealed = true, + promptCourseRating = false, courseId, helpThread, narration = null, @@ -117,6 +119,7 @@ export function ChapterReaderShell({ headingNotes?: Record; predictGate?: boolean; predictRevealed?: boolean; + promptCourseRating?: boolean; courseId: string; helpThread: HelpThreadView | null; /** Listen-along audio for this chapter, when its course has it. */ @@ -541,6 +544,11 @@ export function ChapterReaderShell({ {focusMode && void exitFocusMode()} />} +
diff --git a/frontend/src/components/learn/course-rating-card.tsx b/frontend/src/components/learn/course-rating-card.tsx new file mode 100644 index 000000000..84695f9cc --- /dev/null +++ b/frontend/src/components/learn/course-rating-card.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useRef, useState } from "react"; +import { CheckCircle2, Pencil, Star } from "lucide-react"; +import { fireMiniConfetti } from "@buildment/learning-ui/components/learn/mini-confetti"; +import { + saveCourseRatingAction, + dismissCourseRatingAction, + type CourseRatingView, +} from "@/server/actions/course-rating"; +import { RatingHappyDot, StarRating, StarRatingInput } from "@/components/learn/star-rating"; +import { playRatingSubmitSound } from "@/lib/rating-sound"; +import { Button } from "@/components/ui/button"; + +/** + * Course overview rating bar, unlocked once the learner finishes more than half + * of the course. Skippable, editable, and kept simple at 0 stars with Happy Dot + * yipping once any star (1–5) is chosen. + */ +export function CourseRatingCard({ + courseSlug, + courseTitle, + initialRating, + initialDismissed = false, +}: { + courseSlug: string; + courseTitle: string; + initialRating: CourseRatingView | null; + initialDismissed?: boolean; +}) { + const cardRef = useRef(null); + const [rating, setRating] = useState(initialRating?.rating ?? 0); + const [review, setReview] = useState(initialRating?.review ?? ""); + const [saved, setSaved] = useState(initialRating); + const [editing, setEditing] = useState(!initialRating); + const [dismissed, setDismissed] = useState(initialDismissed); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function notNow() { + setPending(true); + await dismissCourseRatingAction({ courseSlug }); + setPending(false); + setDismissed(true); + setEditing(false); + } + + async function submit() { + if (rating < 1) { + setError("Choose a star rating first."); + return; + } + setPending(true); + setError(null); + const res = await saveCourseRatingAction({ courseSlug, rating, review: review.trim() || null }); + setPending(false); + if (res.ok) { + playRatingSubmitSound(); + if (cardRef.current) { + fireMiniConfetti(cardRef.current); + } + setSaved(res.data); + setEditing(false); + } else { + setError(res.error); + } + } + + if (dismissed && !saved && !editing) { + return ( +
+ Ready to share how this course is going? + +
+ ); + } + + return ( +
+
+ {saved && !editing ? ( +
+
+
+ Your rating + + + {saved.rating}.0 / 5 + + + Saved + +
+ {saved.review ? ( +

+ “{saved.review}” +

+ ) : null} +
+ +
+ ) : ( + <> +
+
+

+ How is {courseTitle} going? +

+

+ You're past the halfway mark — your rating helps other learners decide and helps us improve. +

+
+
+ +
+
+ + {rating > 0 ? ( +
+ +
+ ) : null} + +