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}
+
+
+
+ {error ?
{error}
: null}
+
+
+ {!saved ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/learn/course-rating-link.tsx b/frontend/src/components/learn/course-rating-link.tsx
new file mode 100644
index 000000000..0a6a76098
--- /dev/null
+++ b/frontend/src/components/learn/course-rating-link.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Star } from "lucide-react";
+import { CourseRatingModal } from "@/components/learn/course-rating-modal";
+
+/**
+ * The on-demand path left after the learner dismisses the full prompt.
+ * Clicking it opens the rating modal; it never expands on its own.
+ */
+export function CourseRatingLink({
+ courseSlug,
+ courseTitle,
+}: {
+ courseSlug: string;
+ courseTitle?: string;
+}) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+ setOpen(false)}
+ courseSlug={courseSlug}
+ courseTitle={courseTitle}
+ dismissLabel="Cancel"
+ onSubmitted={() => router.refresh()}
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/learn/course-rating-modal.tsx b/frontend/src/components/learn/course-rating-modal.tsx
new file mode 100644
index 000000000..ce083e443
--- /dev/null
+++ b/frontend/src/components/learn/course-rating-modal.tsx
@@ -0,0 +1,313 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Sparkles, Volume2, VolumeX, X } from "lucide-react";
+import { fireMiniConfetti } from "@buildment/learning-ui/components/learn/mini-confetti";
+import { dismissCourseRatingAction, saveCourseRatingAction } from "@/server/actions/course-rating";
+import { RatingHappyDot, StarRatingInput } from "@/components/learn/star-rating";
+import { playRatingPromptSound, playRatingSubmitSound, useRatingSound } from "@/lib/rating-sound";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+const PROMPT_OPEN_DELAY_MS = 320;
+
+function promptStorageKey(courseSlug: string) {
+ return `buildment:rating-prompt-handled:${courseSlug}`;
+}
+
+export function CourseRatingModal({
+ open,
+ onClose,
+ courseSlug,
+ courseTitle,
+ badgeLabel = "Course review",
+ dismissLabel = "Cancel",
+ onSubmitted,
+}: {
+ open: boolean;
+ onClose: () => void;
+ courseSlug: string;
+ courseTitle?: string;
+ badgeLabel?: string;
+ dismissLabel?: string;
+ onSubmitted?: () => void;
+}) {
+ const dialogRef = useRef(null);
+ const confettiAnchorRef = useRef(null);
+ const [soundOn, setSoundOn] = useRatingSound();
+ const [rating, setRating] = useState(0);
+ const [review, setReview] = useState("");
+ const [pending, setPending] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ if (open && !dialog.open) dialog.showModal();
+ if (!open && dialog.open) dialog.close();
+ return () => {
+ if (dialog.open) dialog.close();
+ };
+ }, [open]);
+
+ function handleClose() {
+ if (pending) return;
+ setRating(0);
+ setReview("");
+ setSaved(false);
+ setError(null);
+ onClose();
+ }
+
+ 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 (confettiAnchorRef.current) {
+ fireMiniConfetti(confettiAnchorRef.current);
+ }
+ setSaved(true);
+ onSubmitted?.();
+ } else {
+ setError(res.error);
+ }
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * One-time pop-up shown once the learner has finished more than half of the
+ * course and moves on to the next chapter. Dismissing or submitting remembers
+ * the choice so it never interrupts subsequent lessons.
+ */
+export function CourseRatingPrompt({
+ courseSlug,
+ courseTitle,
+ shouldPrompt,
+}: {
+ courseSlug: string;
+ courseTitle: string;
+ shouldPrompt: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const handledRef = useRef(false);
+ const submittedRef = useRef(false);
+
+ useEffect(() => {
+ if (!shouldPrompt || handledRef.current) return;
+ try {
+ if (window.sessionStorage.getItem(promptStorageKey(courseSlug)) === "1") {
+ handledRef.current = true;
+ return;
+ }
+ } catch {
+ // sessionStorage unavailable
+ }
+ const timer = window.setTimeout(() => {
+ playRatingPromptSound();
+ setOpen(true);
+ }, PROMPT_OPEN_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ }, [courseSlug, shouldPrompt]);
+
+ function markHandled() {
+ handledRef.current = true;
+ try {
+ window.sessionStorage.setItem(promptStorageKey(courseSlug), "1");
+ } catch {
+ // sessionStorage unavailable
+ }
+ }
+
+ function handleClose() {
+ setOpen(false);
+ markHandled();
+ if (!submittedRef.current) {
+ void dismissCourseRatingAction({ courseSlug });
+ }
+ }
+
+ return (
+ {
+ submittedRef.current = true;
+ markHandled();
+ }}
+ />
+ );
+}
diff --git a/frontend/src/components/learn/course-reviews.tsx b/frontend/src/components/learn/course-reviews.tsx
new file mode 100644
index 000000000..24d26a813
--- /dev/null
+++ b/frontend/src/components/learn/course-reviews.tsx
@@ -0,0 +1,76 @@
+import { StarRating } from "@/components/learn/star-rating";
+
+export type CourseReviewsView = {
+ average: number | null;
+ count: number;
+ distribution: Array<{ stars: number; count: number }>;
+ items: Array<{
+ id: string;
+ rating: number;
+ review: string;
+ reviewer: string;
+ createdAt: string;
+ }>;
+ total: number;
+ page: number;
+ pageSize: number;
+};
+
+function when(iso: string) {
+ return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+}
+
+/** Public rating summary and written reviews on the catalog course page. */
+export function CourseReviews({ reviews }: { reviews: CourseReviewsView }) {
+ if (reviews.count === 0) return null;
+ const average = reviews.average ?? 0;
+ return (
+
+
+
Learner reviews
+
What learners say
+
+
+
+
{average.toFixed(1)}
+
+
+
+ {reviews.count} rating{reviews.count === 1 ? "" : "s"}
+
+
+
+
+ {reviews.distribution.map((row) => {
+ const percent = reviews.count > 0 ? Math.round((row.count / reviews.count) * 100) : 0;
+ return (
+
+ {row.stars}★
+
+
+
+ {percent}%
+
+ );
+ })}
+
+
+
+
+ {reviews.items.length > 0 ? (
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/learn/star-rating.tsx b/frontend/src/components/learn/star-rating.tsx
new file mode 100644
index 000000000..a88d5b88a
--- /dev/null
+++ b/frontend/src/components/learn/star-rating.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useState } from "react";
+import { Star } from "lucide-react";
+import { Dot } from "@/components/brand/dot";
+import { playRatingStarSound } from "@/lib/rating-sound";
+import { cn } from "@/lib/utils";
+
+export const RATING_LABELS: Record = {
+ 1: "Needs work",
+ 2: "Fair",
+ 3: "Good",
+ 4: "Really good",
+ 5: "Loved it!",
+};
+
+/** Read-only 1–5 stars with fractional fill for averages like 4.7. */
+export function StarRating({
+ value,
+ size = 16,
+ className,
+}: {
+ value: number;
+ size?: number;
+ className?: string;
+}) {
+ const percent = Math.max(0, Math.min(100, (value / 5) * 100));
+ const stars = Array.from({ length: 5 }, (_, index) => (
+
+ ));
+ return (
+
+ {stars}
+
+ {stars}
+
+
+ );
+}
+
+/** Interactive 1–5 stars for the rating form with hover preview and tactile sound effects. */
+export function StarRatingInput({
+ value,
+ onChange,
+ disabled,
+ size = "md",
+}: {
+ value: number;
+ onChange: (value: number) => void;
+ disabled?: boolean;
+ size?: "md" | "lg";
+}) {
+ const [hovered, setHovered] = useState(0);
+ const activeCount = hovered > 0 ? hovered : value;
+
+ return (
+ setHovered(0)}
+ >
+ {[1, 2, 3, 4, 5].map((star) => {
+ const filled = star <= activeCount;
+ const selected = star <= value;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+/** For 1–5 stars, show Happy Dot yipping; for 0 stars, keep it simple. */
+export function RatingHappyDot({
+ rating,
+ size = 76,
+ compact = false,
+}: {
+ rating: number;
+ size?: number;
+ compact?: boolean;
+}) {
+ if (rating < 1) return null;
+ const label = RATING_LABELS[rating] ?? "Great";
+ return (
+
+
+
+
+
+
+ {rating}★
+ ·
+ {label}
+
+
+ Yippee! Thanks for sharing how the course feels.
+
+
+
+ );
+}
diff --git a/frontend/src/lib/rating-sound.ts b/frontend/src/lib/rating-sound.ts
new file mode 100644
index 000000000..1214a5ff1
--- /dev/null
+++ b/frontend/src/lib/rating-sound.ts
@@ -0,0 +1,108 @@
+"use client";
+
+import { useBrowserStorage } from "@/lib/use-browser-storage";
+import { isSoundEnabled, playFeedback } from "@/lib/sound-feedback";
+
+const SOUND_STORAGE_KEY = "buildment:sound";
+
+/** Reactive hook synced with the platform-wide `buildment:sound` preference. */
+export function useRatingSound(): [enabled: boolean, setEnabled: (enabled: boolean) => void] {
+ const [stored, setStored] = useBrowserStorage(SOUND_STORAGE_KEY, "on");
+ return [stored !== "off", (enabled) => setStored(enabled ? "on" : "off")];
+}
+
+/** Soft chime when the mid-course review pop-up opens. */
+export function playRatingPromptSound() {
+ playFeedback("notification");
+}
+
+/** Celebratory cue when the learner submits their review. */
+export function playRatingSubmitSound() {
+ playFeedback("celebration");
+}
+
+const STAR_NOTES_HZ = [523.25, 587.33, 659.25, 783.99, 880.0] as const; // C5, D5, E5, G5, A5
+
+/**
+ * Tactile star chime + cheerful two-hop "yip-yip!" chirp synced with Happy Dot
+ * when 1–5 stars are selected, or a quiet tick when cleared back to 0 stars.
+ */
+export function playRatingStarSound(star: number) {
+ if (typeof window === "undefined" || !isSoundEnabled()) return;
+
+ if (star < 1) {
+ playFeedback("tick");
+ return;
+ }
+
+ try {
+ const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
+ if (!AudioContextClass) {
+ playFeedback("check");
+ return;
+ }
+
+ const context = new AudioContextClass();
+ void context.resume();
+ const now = context.currentTime;
+ const master = context.createGain();
+ master.gain.value = 0.16;
+ master.connect(context.destination);
+
+ // 1. Warm star bell rising with 1..5 stars
+ const clampedIndex = Math.max(0, Math.min(4, star - 1));
+ const noteHz = STAR_NOTES_HZ[clampedIndex]!;
+ bellNote(context, master, now, noteHz, 0.14, 0.22);
+ bellNote(context, master, now + 0.03, noteHz * 1.5, 0.06, 0.18);
+
+ // 2. Happy Dot two-hop "yip-yip!" chirp matching the bounce animation
+ chirpYip(context, master, now + 0.05, 560, 880, 0.085, 0.11);
+ chirpYip(context, master, now + 0.18, 650, 1040, 0.095, 0.12);
+
+ window.setTimeout(() => void context.close(), 900);
+ } catch {
+ playFeedback("check");
+ }
+}
+
+function bellNote(
+ context: AudioContext,
+ master: GainNode,
+ start: number,
+ hz: number,
+ peak: number,
+ duration: number,
+) {
+ const osc = context.createOscillator();
+ const gain = context.createGain();
+ osc.type = "sine";
+ osc.frequency.setValueAtTime(hz, start);
+ gain.gain.setValueAtTime(0.0001, start);
+ gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
+ osc.connect(gain).connect(master);
+ osc.start(start);
+ osc.stop(start + duration + 0.02);
+}
+
+function chirpYip(
+ context: AudioContext,
+ master: GainNode,
+ start: number,
+ fromHz: number,
+ toHz: number,
+ duration: number,
+ peak: number,
+) {
+ const osc = context.createOscillator();
+ const gain = context.createGain();
+ osc.type = "triangle";
+ osc.frequency.setValueAtTime(fromHz, start);
+ osc.frequency.exponentialRampToValueAtTime(toHz, start + duration * 0.72);
+ gain.gain.setValueAtTime(0.0001, start);
+ gain.gain.exponentialRampToValueAtTime(peak, start + 0.015);
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
+ osc.connect(gain).connect(master);
+ osc.start(start);
+ osc.stop(start + duration + 0.02);
+}
diff --git a/frontend/src/server/actions/course-rating.ts b/frontend/src/server/actions/course-rating.ts
new file mode 100644
index 000000000..16027c576
--- /dev/null
+++ b/frontend/src/server/actions/course-rating.ts
@@ -0,0 +1,40 @@
+"use server";
+
+import { ApiError } from "@/lib/api-client";
+import { serverApiFetch } from "@/lib/api-server";
+
+export type CourseRatingView = {
+ rating: number;
+ review: string | null;
+ updatedAt: string;
+};
+
+export async function saveCourseRatingAction(raw: {
+ courseSlug: string;
+ rating: number;
+ review?: string | null;
+}) {
+ try {
+ const data = await serverApiFetch(
+ `/api/v1/courses/${encodeURIComponent(raw.courseSlug)}/rating`,
+ { method: "PUT", json: { rating: raw.rating, review: raw.review } }
+ );
+ return { ok: true as const, data };
+ } catch (error) {
+ if (error instanceof ApiError) return { ok: false as const, error: error.message };
+ throw error;
+ }
+}
+
+/** Remember "Not now" so the full prompt does not come back on the next visit. */
+export async function dismissCourseRatingAction(raw: { courseSlug: string }) {
+ try {
+ await serverApiFetch(`/api/v1/courses/${encodeURIComponent(raw.courseSlug)}/rating/dismiss`, {
+ method: "POST",
+ });
+ return { ok: true as const };
+ } catch (error) {
+ if (error instanceof ApiError) return { ok: false as const, error: error.message };
+ throw error;
+ }
+}
diff --git a/frontend/src/shared/api/catalog.ts b/frontend/src/shared/api/catalog.ts
index fd5488aa5..9979acbb8 100644
--- a/frontend/src/shared/api/catalog.ts
+++ b/frontend/src/shared/api/catalog.ts
@@ -1,5 +1,5 @@
// GENERATED from server/src/shared — do not edit here. Run `pnpm sync-shared` in server/.
-import type { CourseCategory, CoursePricingType, CourseStatus, EnrollmentLifecycle } from "../domain/enums";
+import type { CourseCategory, CoursePricingType, CourseStatus, EnrollmentLifecycle, EnrollStatus } from "../domain/enums";
import type { CourseTrack } from "../lib/course-tracks";
import type { SyllabusModule } from "./syllabus";
@@ -24,6 +24,9 @@ export type CatalogCourseSummary = {
instructorName: string | null;
moduleCount: number;
chapterCount: number;
+ /** Cached course rating; null until the first review lands. */
+ ratingAverage: number | null;
+ ratingCount: number;
/** The caller's enrollment lifecycle in this course; null for anonymous visitors. */
enrollmentLifecycle: EnrollmentLifecycle | null;
};
@@ -61,7 +64,11 @@ export type CourseOverview = {
instructorName: string | null;
};
access: "active" | "payment_required" | "none";
- enrollment: { percentComplete: number; chaptersCompleted: number; pendingReviews: number } | null;
+ enrollment: { percentComplete: number; chaptersCompleted: number; pendingReviews: number; status: EnrollStatus } | null;
+ /** The learner's own rating, once submitted. Drives the overview card and edit state. */
+ rating: { rating: number; review: string | null; updatedAt: string } | null;
+ /** The learner dismissed the rating prompt; keep only the small on-demand link. */
+ ratingDismissed: boolean;
/** Present when an org coupon unlocked or discounted this enrollment. */
coupon: CourseCouponEntitlement | null;
/** Variant and specialisation for a course in a family; null otherwise or without access. */
diff --git a/frontend/src/shared/api/reader.ts b/frontend/src/shared/api/reader.ts
index 1b14ddb12..22d8f1d18 100644
--- a/frontend/src/shared/api/reader.ts
+++ b/frontend/src/shared/api/reader.ts
@@ -27,6 +27,8 @@ export type ChapterReaderContent = {
predictGate: boolean;
predictRevealed: boolean;
helpThread: HelpThreadView | null;
+ /** True when the learner finished more than half of the course, moved on, and has neither rated nor dismissed the prompt. */
+ promptCourseRating?: boolean;
};
export type ChapterReaderView = {
diff --git a/frontend/src/shared/api/teach.ts b/frontend/src/shared/api/teach.ts
index ed816d7f9..711789205 100644
--- a/frontend/src/shared/api/teach.ts
+++ b/frontend/src/shared/api/teach.ts
@@ -500,6 +500,12 @@ export type CourseReportView = {
atRiskDays: number;
enrollments: ReportEnrollmentRow[];
chapterReport: ChapterReportView | null;
+ ratings: {
+ average: number | null;
+ count: number;
+ distribution: Array<{ stars: number; count: number }>;
+ recent: Array<{ id: string; rating: number; review: string | null; reviewer: string; createdAt: string }>;
+ };
};
export type CourseActivityView = {
diff --git a/frontend/tests/unit/learn/course-rating.test.ts b/frontend/tests/unit/learn/course-rating.test.ts
new file mode 100644
index 000000000..e03e0c300
--- /dev/null
+++ b/frontend/tests/unit/learn/course-rating.test.ts
@@ -0,0 +1,86 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import { RatingHappyDot, RATING_LABELS, StarRatingInput } from "@/components/learn/star-rating";
+import { CourseRatingModal } from "@/components/learn/course-rating-modal";
+import { CourseRatingCard } from "@/components/learn/course-rating-card";
+import { ChapterCompleteCelebrationModal } from "@/components/learn/chapter-complete-celebration-modal";
+import { playRatingPromptSound, playRatingStarSound, playRatingSubmitSound } from "@/lib/rating-sound";
+
+vi.mock("@/server/actions/course-rating", () => ({
+ saveCourseRatingAction: vi.fn(),
+ dismissCourseRatingAction: vi.fn(),
+}));
+
+describe("course rating UI & sound effects", () => {
+ it("keeps zero stars simple without rendering Dot", () => {
+ const dotHtml = renderToStaticMarkup(createElement(RatingHappyDot, { rating: 0 }));
+ expect(dotHtml).toBe("");
+
+ const modalHtml = renderToStaticMarkup(
+ createElement(CourseRatingModal, {
+ open: true,
+ onClose: () => undefined,
+ courseSlug: "system-design",
+ courseTitle: "System Design",
+ badgeLabel: "Halfway milestone",
+ dismissLabel: "Not now",
+ }),
+ );
+ expect(modalHtml).toContain("How is System Design going?");
+ expect(modalHtml).toContain("Halfway milestone");
+ expect(modalHtml).toContain("Tap a star to rate");
+ expect(modalHtml).toContain("Not now");
+ expect(modalHtml).not.toContain("rating-happy-dot");
+ expect(modalHtml).not.toContain("Dot, cheer");
+
+ const cardHtml = renderToStaticMarkup(
+ createElement(CourseRatingCard, {
+ courseSlug: "system-design",
+ courseTitle: "System Design",
+ initialRating: null,
+ }),
+ );
+ expect(cardHtml).not.toContain("rating-happy-dot");
+ });
+
+ it("renders Happy Dot yipping for every star rating (1 through 5)", () => {
+ for (const stars of [1, 2, 3, 4, 5]) {
+ const html = renderToStaticMarkup(createElement(RatingHappyDot, { rating: stars }));
+ expect(html).toContain('data-testid="rating-happy-dot"');
+ expect(html).toContain("dot-yip");
+ expect(html).toContain('aria-label="Dot, cheer"');
+ expect(html).toContain(RATING_LABELS[stars]!);
+ }
+ });
+
+ it("renders StarRatingInput radio buttons and sound toggle cleanly", () => {
+ const html = renderToStaticMarkup(
+ createElement(StarRatingInput, { value: 4, onChange: () => undefined, size: "lg" }),
+ );
+ expect(html).toContain('aria-label="4 stars — Really good"');
+ expect(html).toContain('aria-checked="true"');
+
+ // Sound helpers safely no-op in non-browser/SSR environments without throwing.
+ expect(() => {
+ playRatingPromptSound();
+ playRatingStarSound(0);
+ playRatingStarSound(5);
+ playRatingSubmitSound();
+ }).not.toThrow();
+ });
+
+ it("does not ask for a course rating inside the chapter-end celebration modal", () => {
+ const html = renderToStaticMarkup(
+ createElement(ChapterCompleteCelebrationModal, {
+ open: true,
+ onClose: () => undefined,
+ courseCompleted: 5,
+ courseTotal: 5,
+ }),
+ );
+ expect(html).toContain("You completed the course");
+ expect(html).not.toContain("Submit review");
+ expect(html).not.toContain("How was the course?");
+ });
+});
diff --git a/packages/learning-ui/src/components/learn/course-overview-content.tsx b/packages/learning-ui/src/components/learn/course-overview-content.tsx
index 81c31afda..2063a03cf 100644
--- a/packages/learning-ui/src/components/learn/course-overview-content.tsx
+++ b/packages/learning-ui/src/components/learn/course-overview-content.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from 'react';
import type { CourseOverview } from '../../shared/api/catalog';
import { CourseCouponBanner } from './course-coupon-banner';
import { CourseCurriculumUpgradeBanner } from './course-curriculum-upgrade-banner';
@@ -9,10 +10,14 @@ import { VariantThemeScope, variantForTrack } from './variant-theme';
import { LearnerBreadcrumb } from './learner-breadcrumb';
/** The enrolled course page, shared by web and desktop. Hosts own access and navigation. */
-export function CourseOverviewContent({ overview, onChooseSpecialisation, onUpgradeCurriculum }: {
+export function CourseOverviewContent({ overview, onChooseSpecialisation, onUpgradeCurriculum, ratingLink, ratingBanner }: {
overview: CourseOverview;
onChooseSpecialisation: (data: FormData) => Promise;
onUpgradeCurriculum?: () => Promise | void;
+ /** Host-provided content under the hero's continue button (e.g. the rating link). */
+ ratingLink?: ReactNode;
+ /** Host-provided rating bar shown directly beneath the course hero once unlocked. */
+ ratingBanner?: ReactNode;
}) {
const { course, enrollment, syllabus, track } = overview;
if (!enrollment || !syllabus || overview.access !== 'active') return null;
@@ -21,7 +26,8 @@ export function CourseOverviewContent({ overview, onChooseSpecialisation, onUpgr
return
-
+
+ {ratingBanner}
{overview.curriculumUpgradeAvailable ? (
) : null}
diff --git a/packages/learning-ui/src/components/learn/course-overview-hero.tsx b/packages/learning-ui/src/components/learn/course-overview-hero.tsx
index b0b6e8137..96d1ee976 100644
--- a/packages/learning-ui/src/components/learn/course-overview-hero.tsx
+++ b/packages/learning-ui/src/components/learn/course-overview-hero.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from "react";
import { CourseLink as Link } from "./course-navigation";
import { ArrowRight, BookOpen, Clock3, Layers3, MessageSquareText, Target } from "lucide-react";
import { buttonVariants } from "../ui/button";
@@ -21,6 +22,8 @@ type CourseOverviewHeroProps = {
moduleCount: number;
chapterCount: number;
estimatedHours: number | null;
+ /** Host-provided content under the continue button, e.g. the on-demand rating link. */
+ ratingLink?: ReactNode;
};
function splitTitle(title: string) {
@@ -45,6 +48,7 @@ export function CourseOverviewHero({
moduleCount,
chapterCount,
estimatedHours,
+ ratingLink,
}: CourseOverviewHeroProps) {
const { lead, rest } = splitTitle(title);
@@ -146,6 +150,7 @@ export function CourseOverviewHero({
) : null}
+ {ratingLink}
diff --git a/packages/learning-ui/src/shared/api/catalog.ts b/packages/learning-ui/src/shared/api/catalog.ts
index fd5488aa5..9979acbb8 100644
--- a/packages/learning-ui/src/shared/api/catalog.ts
+++ b/packages/learning-ui/src/shared/api/catalog.ts
@@ -1,5 +1,5 @@
// GENERATED from server/src/shared — do not edit here. Run `pnpm sync-shared` in server/.
-import type { CourseCategory, CoursePricingType, CourseStatus, EnrollmentLifecycle } from "../domain/enums";
+import type { CourseCategory, CoursePricingType, CourseStatus, EnrollmentLifecycle, EnrollStatus } from "../domain/enums";
import type { CourseTrack } from "../lib/course-tracks";
import type { SyllabusModule } from "./syllabus";
@@ -24,6 +24,9 @@ export type CatalogCourseSummary = {
instructorName: string | null;
moduleCount: number;
chapterCount: number;
+ /** Cached course rating; null until the first review lands. */
+ ratingAverage: number | null;
+ ratingCount: number;
/** The caller's enrollment lifecycle in this course; null for anonymous visitors. */
enrollmentLifecycle: EnrollmentLifecycle | null;
};
@@ -61,7 +64,11 @@ export type CourseOverview = {
instructorName: string | null;
};
access: "active" | "payment_required" | "none";
- enrollment: { percentComplete: number; chaptersCompleted: number; pendingReviews: number } | null;
+ enrollment: { percentComplete: number; chaptersCompleted: number; pendingReviews: number; status: EnrollStatus } | null;
+ /** The learner's own rating, once submitted. Drives the overview card and edit state. */
+ rating: { rating: number; review: string | null; updatedAt: string } | null;
+ /** The learner dismissed the rating prompt; keep only the small on-demand link. */
+ ratingDismissed: boolean;
/** Present when an org coupon unlocked or discounted this enrollment. */
coupon: CourseCouponEntitlement | null;
/** Variant and specialisation for a course in a family; null otherwise or without access. */
diff --git a/server/prisma/migrations/20261106180000_add_course_ratings/migration.sql b/server/prisma/migrations/20261106180000_add_course_ratings/migration.sql
new file mode 100644
index 000000000..158cb2df1
--- /dev/null
+++ b/server/prisma/migrations/20261106180000_add_course_ratings/migration.sql
@@ -0,0 +1,31 @@
+-- AlterTable
+ALTER TABLE "Course" ADD COLUMN "ratingAverage" DOUBLE PRECISION,
+ADD COLUMN "ratingCount" INTEGER NOT NULL DEFAULT 0;
+
+-- CreateTable
+CREATE TABLE "CourseRating" (
+ "id" TEXT NOT NULL,
+ "courseId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "rating" INTEGER NOT NULL,
+ "review" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "CourseRating_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CourseRating_courseId_userId_key" ON "CourseRating"("courseId", "userId");
+
+-- CreateIndex
+CREATE INDEX "CourseRating_courseId_createdAt_idx" ON "CourseRating"("courseId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "CourseRating_userId_idx" ON "CourseRating"("userId");
+
+-- AddForeignKey
+ALTER TABLE "CourseRating" ADD CONSTRAINT "CourseRating_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CourseRating" ADD CONSTRAINT "CourseRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/server/prisma/migrations/20261106200000_add_rating_dismissed_at/migration.sql b/server/prisma/migrations/20261106200000_add_rating_dismissed_at/migration.sql
new file mode 100644
index 000000000..5e88e0987
--- /dev/null
+++ b/server/prisma/migrations/20261106200000_add_rating_dismissed_at/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Enrollment" ADD COLUMN "ratingDismissedAt" TIMESTAMP(3);
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index a9cc195ad..0a45de4a3 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -295,6 +295,7 @@ model User {
courseMemberships CourseInstructor[]
courseInstructorInvitesSent CourseInstructorInvite[] @relation("CourseInstructorInviteSender")
enrollments Enrollment[]
+ courseRatings CourseRating[]
responses Response[]
reviewsGiven Review[] @relation("Reviewer")
invitesSent Invite[] @relation("InviteSender")
@@ -454,6 +455,10 @@ model Course {
instructorCoupons InstructorCoupon[]
invites Invite[]
helpThreads HelpThread[]
+ ratings CourseRating[]
+ /// Cached aggregate of CourseRating, maintained on every write so catalog cards stay one query.
+ ratingAverage Float?
+ ratingCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -463,6 +468,25 @@ model Course {
leaderboardPlacements LeaderboardPlacement[]
}
+/// One learner's rating of a completed course. Editable, never duplicated.
+model CourseRating {
+ id String @id @default(cuid())
+ courseId String
+ course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ /// 1–5 stars.
+ rating Int
+ /// Optional written review. Public when present.
+ review String? @db.Text
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([courseId, userId])
+ @@index([courseId, createdAt])
+ @@index([userId])
+}
+
model CreditEntry {
id String @id @default(cuid())
userId String
@@ -662,6 +686,9 @@ model Enrollment {
lastNudgeSentAt DateTime?
startedAt DateTime?
completedAt DateTime?
+ /// Set when the learner dismisses the post-completion rating prompt. Keeps
+ /// the one-time ask from reappearing; the course page keeps a small link.
+ ratingDismissedAt DateTime?
variantSlug String? // learner's variant in the course family; null = not picked yet
variantChosenAt DateTime?
variantLockedAt DateTime? // first chapter start fixes the learner's variant
diff --git a/server/src/controllers/catalog.ts b/server/src/controllers/catalog.ts
index bdb4cb7f7..3358479f1 100644
--- a/server/src/controllers/catalog.ts
+++ b/server/src/controllers/catalog.ts
@@ -1,6 +1,7 @@
import type { Request, Response } from "express";
import { ok } from "../utils/responses.js";
import { getCatalogCourse, listCatalog } from "../services/catalog/catalog.js";
+import { listCourseReviews } from "../services/courses/ratings.js";
// GET /api/v1/catalog
export async function list(req: Request, res: Response) {
@@ -14,3 +15,9 @@ export async function detail(req: Request, res: Response) {
signedIn: Boolean(req.user),
});
}
+
+// GET /api/v1/catalog/:courseSlug/reviews
+export async function reviews(req: Request, res: Response) {
+ const { page, pageSize } = req.query as unknown as { page: number; pageSize: number };
+ ok(res, await listCourseReviews(req.params.courseSlug as string, { page, pageSize }));
+}
diff --git a/server/src/controllers/courses.ts b/server/src/controllers/courses.ts
index 633e02935..f9dffb186 100644
--- a/server/src/controllers/courses.ts
+++ b/server/src/controllers/courses.ts
@@ -7,6 +7,7 @@ import { prisma } from "../db/prisma.js";
import { loadBlockState } from "../services/blocks/state.js";
import { BadRequestError } from "../utils/errors.js";
import { chooseCourseSpecialisation, chooseCourseVariant } from "../services/courses/track.js";
+import { saveCourseRating, getMyCourseRating, dismissCourseRatingPrompt } from "../services/courses/ratings.js";
// GET /api/v1/courses/:courseSlug/continue
export async function continueChapter(req: Request, res: Response) {
@@ -67,3 +68,19 @@ export async function chooseSpecialisation(req: Request, res: Response) {
export async function upgradeCurriculum(req: Request, res: Response) {
ok(res, await upgradeEnrollmentCurriculum(req.params.courseSlug as string, req.user!));
}
+
+// PUT /api/v1/courses/:courseSlug/rating
+export async function putRating(req: Request, res: Response) {
+ const { rating, review } = req.body as { rating: number; review?: string | null };
+ ok(res, await saveCourseRating(req.user!, req.params.courseSlug as string, { rating, review }));
+}
+
+// GET /api/v1/courses/:courseSlug/rating
+export async function getRating(req: Request, res: Response) {
+ ok(res, { rating: await getMyCourseRating(req.user!, req.params.courseSlug as string) });
+}
+
+// POST /api/v1/courses/:courseSlug/rating/dismiss
+export async function dismissRating(req: Request, res: Response) {
+ ok(res, await dismissCourseRatingPrompt(req.user!, req.params.courseSlug as string));
+}
diff --git a/server/src/routes/v1/catalog.ts b/server/src/routes/v1/catalog.ts
index 4a293461c..785ebdab2 100644
--- a/server/src/routes/v1/catalog.ts
+++ b/server/src/routes/v1/catalog.ts
@@ -1,13 +1,14 @@
import { Router } from "express";
import * as catalog from "../../controllers/catalog.js";
import { optionalAuth } from "../../middlewares/authenticate.js";
-import { validateParams } from "../../middlewares/validate.js";
-import { courseSlugParams } from "../../validations/courses.js";
+import { validateParams, validateQuery } from "../../middlewares/validate.js";
+import { courseReviewsQuery, courseSlugParams } from "../../validations/courses.js";
const router = Router();
// Published catalog summaries and curriculum outlines are public.
router.get("/", optionalAuth, catalog.list);
router.get("/:courseSlug", optionalAuth, validateParams(courseSlugParams), catalog.detail);
+router.get("/:courseSlug/reviews", optionalAuth, validateParams(courseSlugParams), validateQuery(courseReviewsQuery), catalog.reviews);
export default router;
diff --git a/server/src/routes/v1/courses.ts b/server/src/routes/v1/courses.ts
index 68b5ebba6..8c51ad533 100644
--- a/server/src/routes/v1/courses.ts
+++ b/server/src/routes/v1/courses.ts
@@ -6,7 +6,7 @@ import { certificateClaimBody } from "../../validations/capstone.js";
import { authenticate, optionalAuth } from "../../middlewares/authenticate.js";
import { requireVerified } from "../../middlewares/require-verified.js";
import { validateBody, validateParams } from "../../middlewares/validate.js";
-import { chapterPathParams, chooseSpecialisationBody, chooseVariantBody, courseSlugParams } from "../../validations/courses.js";
+import { chapterPathParams, chooseSpecialisationBody, chooseVariantBody, courseRatingBody, courseSlugParams } from "../../validations/courses.js";
const router = Router();
@@ -14,6 +14,22 @@ router.get("/:courseSlug/continue", optionalAuth, validateParams(courseSlugParam
router.get("/:courseSlug/certificate", authenticate, requireVerified, validateParams(courseSlugParams), certificates.mine);
router.post("/:courseSlug/certificate", authenticate, requireVerified, validateParams(courseSlugParams), validateBody(certificateClaimBody), certificates.claim);
router.get("/:courseSlug/overview", authenticate, requireVerified, validateParams(courseSlugParams), courses.overview);
+router.get("/:courseSlug/rating", authenticate, requireVerified, validateParams(courseSlugParams), courses.getRating);
+router.put(
+ "/:courseSlug/rating",
+ authenticate,
+ requireVerified,
+ validateParams(courseSlugParams),
+ validateBody(courseRatingBody),
+ courses.putRating
+);
+router.post(
+ "/:courseSlug/rating/dismiss",
+ authenticate,
+ requireVerified,
+ validateParams(courseSlugParams),
+ courses.dismissRating
+);
router.get("/:courseSlug/chapters/:chapterSlug", authenticate, requireVerified, validateParams(chapterPathParams), courses.chapter);
router.get("/:courseSlug/chapters/:chapterSlug/download", authenticate, requireVerified, validateParams(chapterPathParams), courses.downloadChapter);
router.put(
diff --git a/server/src/services/catalog/catalog.ts b/server/src/services/catalog/catalog.ts
index 9c5c98f8c..89346a08d 100644
--- a/server/src/services/catalog/catalog.ts
+++ b/server/src/services/catalog/catalog.ts
@@ -19,6 +19,8 @@ const summarySelect = {
priceCents: true,
currency: true,
mentor: { select: { name: true } },
+ ratingAverage: true,
+ ratingCount: true,
_count: { select: { modules: { where: { archivedAt: null } }, chapters: { where: { archivedAt: null } } } },
} as const;
diff --git a/server/src/services/chapters/reader.ts b/server/src/services/chapters/reader.ts
index 2e19837eb..954aa6fd6 100644
--- a/server/src/services/chapters/reader.ts
+++ b/server/src/services/chapters/reader.ts
@@ -144,7 +144,7 @@ export async function loadChapterReader(courseSlug: string, chapterSlug: string,
return loadChapterReader(courseSlug, chapterSlug, user, 1, forDownload);
}
- const [content, currentProgress, helpThreadRow, habit, chapterResponses, predictBlocks, headingNotes] = await Promise.all([
+ const [content, currentProgress, helpThreadRow, habit, chapterResponses, predictBlocks, headingNotes, existingRating] = await Promise.all([
prisma.chapter.findUnique({ where: { id: chapter.id }, select: { compiled: true, source: true } }),
prisma.chapterProgress.findUnique({
where: { enrollmentId_chapterId: { enrollmentId: enrollment.id, chapterId: chapter.id } },
@@ -162,6 +162,10 @@ export async function loadChapterReader(courseSlug: string, chapterSlug: string,
select: { id: true },
}),
prisma.headingNote.findMany({ where: { userId: user.id, chapterId: chapter.id }, select: { headingId: true, body: true } }),
+ prisma.courseRating.findUnique({
+ where: { courseId_userId: { courseId: course.id, userId: user.id } },
+ select: { id: true },
+ }),
]);
if (!content) throw new NotFoundError("Chapter not found");
@@ -189,6 +193,16 @@ export async function loadChapterReader(courseSlug: string, chapterSlug: string,
const latest = latestChapterResponses(chapterResponses);
const answeredIds = new Set(latest.map((response) => response.blockId));
const predictGate = predictBlocks.length > 0;
+ const moreThanHalfComplete =
+ startedEnrollment.status === "COMPLETED" ||
+ startedEnrollment.percentComplete > 50 ||
+ (flatChapters.length > 0 && startedEnrollment.chaptersCompleted * 2 > flatChapters.length);
+ const promptCourseRating =
+ !forDownload &&
+ moreThanHalfComplete &&
+ !chapterComplete &&
+ !existingRating &&
+ !startedEnrollment.ratingDismissedAt;
return {
...base,
@@ -215,6 +229,7 @@ export async function loadChapterReader(courseSlug: string, chapterSlug: string,
predictGate,
predictRevealed: !predictGate || predictBlocks.every((block) => answeredIds.has(block.id)),
helpThread: serializeHelpThread(helpThreadRow),
+ promptCourseRating,
},
};
}
diff --git a/server/src/services/courses/overview.ts b/server/src/services/courses/overview.ts
index 271a1af89..12c37e05d 100644
--- a/server/src/services/courses/overview.ts
+++ b/server/src/services/courses/overview.ts
@@ -60,7 +60,17 @@ export async function getCourseOverview(courseSlug: string, user: SessionUser):
const enrollment = await prisma.enrollment.findUnique({
where: { courseId_userId: { courseId: course.id, userId: user.id } },
});
- if (!enrollment) return { ...base, access: "none", enrollment: null, coupon: null, track: null, syllabus: null };
+ const myRatingRow = await prisma.courseRating.findUnique({
+ where: { courseId_userId: { courseId: course.id, userId: user.id } },
+ select: { rating: true, review: true, updatedAt: true },
+ });
+ const rating = myRatingRow
+ ? { rating: myRatingRow.rating, review: myRatingRow.review, updatedAt: myRatingRow.updatedAt.toISOString() }
+ : null;
+ const ratingDismissed = Boolean(enrollment?.ratingDismissedAt);
+ if (!enrollment) {
+ return { ...base, access: "none", enrollment: null, rating, ratingDismissed, coupon: null, track: null, syllabus: null };
+ }
let curriculumUpgradeAvailable = false;
if (activeReleaseId && enrollment.releaseId !== activeReleaseId) {
@@ -87,9 +97,10 @@ export async function getCourseOverview(courseSlug: string, user: SessionUser):
percentComplete: enrollment.percentComplete,
chaptersCompleted: enrollment.chaptersCompleted,
pendingReviews: enrollment.pendingReviews,
+ status: enrollment.status,
};
if (!enrollmentGrantsContentAccess(enrollment.lifecycle)) {
- return { ...base, access: "payment_required", enrollment: summary, coupon: null, track: null, syllabus: null };
+ return { ...base, access: "payment_required", enrollment: summary, rating, ratingDismissed, coupon: null, track: null, syllabus: null };
}
const coupon = await redeemCouponOnCourseOpen(enrollment.id);
@@ -106,6 +117,8 @@ export async function getCourseOverview(courseSlug: string, user: SessionUser):
...base,
access: "active",
enrollment: summary,
+ rating,
+ ratingDismissed,
coupon,
track: buildCourseTrack(variantFamily ? resolveCourseFamily(familySlug, variantFamily) : familySlug, enrollment, availableVariantSlugs),
curriculumUpgradeAvailable,
diff --git a/server/src/services/courses/ratings.ts b/server/src/services/courses/ratings.ts
new file mode 100644
index 000000000..3bf755078
--- /dev/null
+++ b/server/src/services/courses/ratings.ts
@@ -0,0 +1,228 @@
+import { prisma } from "../../db/prisma.js";
+import { ForbiddenError, NotFoundError } from "../../utils/errors.js";
+import type { SessionUser } from "../../types/auth.js";
+import { releaseForEnrollment } from "./releases.js";
+
+// Course ratings are the macro signal: one per learner, unlocked once the learner
+// has finished more than half of the course, editable, and prompted once when
+// moving to the next chapter. Lesson-level problems go through help/report instead.
+
+export type CourseRatingView = {
+ rating: number;
+ review: string | null;
+ updatedAt: string;
+};
+
+export type CourseReviewItem = {
+ id: string;
+ rating: number;
+ review: string;
+ reviewer: string;
+ createdAt: string;
+};
+
+export type CourseReviewsView = {
+ average: number | null;
+ count: number;
+ distribution: Array<{ stars: number; count: number }>;
+ items: CourseReviewItem[];
+ total: number;
+ page: number;
+ pageSize: number;
+};
+
+async function publishedCourse(courseSlug: string) {
+ const course = await prisma.course.findUnique({
+ where: { slug: courseSlug },
+ select: { id: true, status: true, familySlug: true },
+ });
+ if (!course || course.status !== "PUBLISHED") throw new NotFoundError("Course not found");
+ return course;
+}
+
+/** First name plus last initial, or the email prefix. Keeps reviews readable without exposing full identities. */
+function reviewerLabel(user: { name: string | null; email: string }): string {
+ const name = user.name?.trim();
+ if (!name) return user.email.split("@")[0] ?? "Learner";
+ const parts = name.split(/\s+/).filter(Boolean);
+ if (parts.length === 1) return parts[0]!;
+ return `${parts[0]} ${parts[parts.length - 1]![0]!.toUpperCase()}.`;
+}
+
+export async function saveCourseRating(
+ user: SessionUser,
+ courseSlug: string,
+ input: { rating: number; review?: string | null },
+): Promise {
+ const course = await publishedCourse(courseSlug);
+ const enrollment = await prisma.enrollment.findUnique({
+ where: { courseId_userId: { courseId: course.id, userId: user.id } },
+ select: {
+ status: true,
+ chaptersCompleted: true,
+ percentComplete: true,
+ releaseId: true,
+ variantSlug: true,
+ },
+ });
+ if (!enrollment) {
+ throw new ForbiddenError("Complete more than half of the course before rating it.");
+ }
+ if (enrollment.status !== "COMPLETED" && enrollment.percentComplete <= 50) {
+ const selected = await releaseForEnrollment(course.id, enrollment, course.familySlug);
+ const totalChapters = selected
+ ? selected.modules.reduce((sum, mod) => sum + mod.chapters.length, 0)
+ : await prisma.chapter.count({
+ where: {
+ courseId: course.id,
+ archivedAt: null,
+ publishedAt: { not: null },
+ module: { archivedAt: null },
+ },
+ });
+ if (totalChapters <= 0 || enrollment.chaptersCompleted * 2 <= totalChapters) {
+ throw new ForbiddenError("Complete more than half of the course before rating it.");
+ }
+ }
+
+ const review = input.review?.trim() || null;
+ const saved = await prisma.$transaction(async (tx) => {
+ const row = await tx.courseRating.upsert({
+ where: { courseId_userId: { courseId: course.id, userId: user.id } },
+ create: { courseId: course.id, userId: user.id, rating: input.rating, review },
+ update: { rating: input.rating, review },
+ select: { rating: true, review: true, updatedAt: true },
+ });
+ // Keep the catalog aggregate in the same transaction as the write.
+ const aggregate = await tx.courseRating.aggregate({
+ where: { courseId: course.id },
+ _avg: { rating: true },
+ _count: { _all: true },
+ });
+ await tx.course.update({
+ where: { id: course.id },
+ data: { ratingAverage: aggregate._avg.rating, ratingCount: aggregate._count._all },
+ });
+ return row;
+ });
+
+ return { rating: saved.rating, review: saved.review, updatedAt: saved.updatedAt.toISOString() };
+}
+
+export async function getMyCourseRating(
+ user: SessionUser,
+ courseSlug: string,
+): Promise {
+ const course = await prisma.course.findUnique({
+ where: { slug: courseSlug },
+ select: { id: true },
+ });
+ if (!course) throw new NotFoundError("Course not found");
+ const row = await prisma.courseRating.findUnique({
+ where: { courseId_userId: { courseId: course.id, userId: user.id } },
+ select: { rating: true, review: true, updatedAt: true },
+ });
+ return row ? { rating: row.rating, review: row.review, updatedAt: row.updatedAt.toISOString() } : null;
+}
+
+export async function listCourseReviews(
+ courseSlug: string,
+ options: { page: number; pageSize: number },
+): Promise {
+ const course = await publishedCourse(courseSlug);
+ const where = { courseId: course.id, review: { not: null } };
+
+ const [rows, total, aggregate, groups] = await Promise.all([
+ prisma.courseRating.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ skip: (options.page - 1) * options.pageSize,
+ take: options.pageSize,
+ select: {
+ id: true,
+ rating: true,
+ review: true,
+ createdAt: true,
+ user: { select: { name: true, email: true } },
+ },
+ }),
+ prisma.courseRating.count({ where }),
+ prisma.courseRating.aggregate({
+ where: { courseId: course.id },
+ _avg: { rating: true },
+ _count: { _all: true },
+ }),
+ prisma.courseRating.groupBy({
+ by: ["rating"],
+ where: { courseId: course.id },
+ _count: { _all: true },
+ }),
+ ]);
+
+ const byStars = new Map(groups.map((group) => [group.rating, group._count._all]));
+ return {
+ average: aggregate._avg.rating,
+ count: aggregate._count._all,
+ distribution: [5, 4, 3, 2, 1].map((stars) => ({ stars, count: byStars.get(stars) ?? 0 })),
+ items: rows
+ .filter((row): row is typeof row & { review: string } => Boolean(row.review))
+ .map((row) => ({
+ id: row.id,
+ rating: row.rating,
+ review: row.review,
+ reviewer: reviewerLabel(row.user),
+ createdAt: row.createdAt.toISOString(),
+ })),
+ total,
+ page: options.page,
+ pageSize: options.pageSize,
+ };
+}
+
+export type CourseRatingReport = {
+ average: number | null;
+ count: number;
+ distribution: Array<{ stars: number; count: number }>;
+ recent: Array<{ id: string; rating: number; review: string | null; reviewer: string; createdAt: string }>;
+};
+
+/** Instructor-facing summary: aggregate plus the latest written reviews. */
+export async function loadCourseRatingReport(courseId: string): Promise {
+ const [aggregate, groups, recent] = await Promise.all([
+ prisma.courseRating.aggregate({ where: { courseId }, _avg: { rating: true }, _count: { _all: true } }),
+ prisma.courseRating.groupBy({ by: ["rating"], where: { courseId }, _count: { _all: true } }),
+ prisma.courseRating.findMany({
+ where: { courseId },
+ orderBy: { createdAt: "desc" },
+ take: 5,
+ select: { id: true, rating: true, review: true, createdAt: true, user: { select: { name: true, email: true } } },
+ }),
+ ]);
+ const byStars = new Map(groups.map((group) => [group.rating, group._count._all]));
+ return {
+ average: aggregate._avg.rating,
+ count: aggregate._count._all,
+ distribution: [5, 4, 3, 2, 1].map((stars) => ({ stars, count: byStars.get(stars) ?? 0 })),
+ recent: recent.map((row) => ({
+ id: row.id,
+ rating: row.rating,
+ review: row.review,
+ reviewer: reviewerLabel(row.user),
+ createdAt: row.createdAt.toISOString(),
+ })),
+ };
+}
+
+/** The learner said "Not now": remember it so the full prompt never returns. */
+export async function dismissCourseRatingPrompt(
+ user: SessionUser,
+ courseSlug: string,
+): Promise<{ dismissed: true }> {
+ const course = await prisma.course.findUnique({ where: { slug: courseSlug }, select: { id: true } });
+ if (!course) throw new NotFoundError("Course not found");
+ await prisma.enrollment.updateMany({
+ where: { courseId: course.id, userId: user.id },
+ data: { ratingDismissedAt: new Date() },
+ });
+ return { dismissed: true };
+}
diff --git a/server/src/services/teach/reports.ts b/server/src/services/teach/reports.ts
index bbf4a0104..fb9805a22 100644
--- a/server/src/services/teach/reports.ts
+++ b/server/src/services/teach/reports.ts
@@ -5,6 +5,7 @@ import { resolveCourseFamily } from "../../shared/lib/course-tracks.js";
import { toCsv } from "../../utils/csv.js";
import type { SessionUser } from "../../types/auth.js";
import { requireCourseBySlug } from "./access.js";
+import { loadCourseRatingReport } from "../courses/ratings.js";
// Course insights (docs/06-reports.mdx). Cohort numbers come from the
// denormalised Enrollment rollups; the chapter report reads BlockStats and
@@ -24,7 +25,7 @@ export async function loadCourseReport(user: SessionUser, courseSlug: string, ch
const owned = await requireCourseBySlug(user, courseSlug);
const now = Date.now();
- const [course, enrollments, chapterProgress] = await Promise.all([
+ const [course, enrollments, chapterProgress, ratings] = await Promise.all([
prisma.course.findUniqueOrThrow({
where: { id: owned.id },
select: {
@@ -38,6 +39,7 @@ export async function loadCourseReport(user: SessionUser, courseSlug: string, ch
}),
prisma.enrollment.findMany({ where: { courseId: owned.id }, orderBy: { createdAt: "asc" }, include: learnerInclude }),
prisma.chapterProgress.findMany({ where: { enrollment: { courseId: owned.id } }, select: { enrollmentId: true, timeSpentSeconds: true } }),
+ loadCourseRatingReport(owned.id),
]);
const studySeconds = new Map();
@@ -86,6 +88,7 @@ export async function loadCourseReport(user: SessionUser, courseSlug: string, ch
atRisk: isAtRisk(e),
})),
chapterReport: chapterId && chapterIds.has(chapterId) ? await loadChapterReport(owned.id, chapterId) : null,
+ ratings,
};
}
diff --git a/server/src/shared/api/catalog.ts b/server/src/shared/api/catalog.ts
index cc54281f9..2e0a19c39 100644
--- a/server/src/shared/api/catalog.ts
+++ b/server/src/shared/api/catalog.ts
@@ -1,4 +1,4 @@
-import type { CourseCategory, CoursePricingType, CourseStatus, EnrollmentLifecycle } from "../domain/enums.js";
+import type { CourseCategory, CoursePricingType, CourseStatus, EnrollmentLifecycle, EnrollStatus } from "../domain/enums.js";
import type { CourseTrack } from "../lib/course-tracks.js";
import type { SyllabusModule } from "./syllabus.js";
@@ -23,6 +23,9 @@ export type CatalogCourseSummary = {
instructorName: string | null;
moduleCount: number;
chapterCount: number;
+ /** Cached course rating; null until the first review lands. */
+ ratingAverage: number | null;
+ ratingCount: number;
/** The caller's enrollment lifecycle in this course; null for anonymous visitors. */
enrollmentLifecycle: EnrollmentLifecycle | null;
};
@@ -60,7 +63,11 @@ export type CourseOverview = {
instructorName: string | null;
};
access: "active" | "payment_required" | "none";
- enrollment: { percentComplete: number; chaptersCompleted: number; pendingReviews: number } | null;
+ enrollment: { percentComplete: number; chaptersCompleted: number; pendingReviews: number; status: EnrollStatus } | null;
+ /** The learner's own rating, once submitted. Drives the overview card and edit state. */
+ rating: { rating: number; review: string | null; updatedAt: string } | null;
+ /** The learner dismissed the rating prompt; keep only the small on-demand link. */
+ ratingDismissed: boolean;
/** Present when an org coupon unlocked or discounted this enrollment. */
coupon: CourseCouponEntitlement | null;
/** Variant and specialisation for a course in a family; null otherwise or without access. */
diff --git a/server/src/shared/api/reader.ts b/server/src/shared/api/reader.ts
index fcc4a959e..73d8b6cbf 100644
--- a/server/src/shared/api/reader.ts
+++ b/server/src/shared/api/reader.ts
@@ -26,6 +26,8 @@ export type ChapterReaderContent = {
predictGate: boolean;
predictRevealed: boolean;
helpThread: HelpThreadView | null;
+ /** True when the learner finished more than half of the course, moved on, and has neither rated nor dismissed the prompt. */
+ promptCourseRating?: boolean;
};
export type ChapterReaderView = {
diff --git a/server/src/shared/api/teach.ts b/server/src/shared/api/teach.ts
index c251ca606..49c45737e 100644
--- a/server/src/shared/api/teach.ts
+++ b/server/src/shared/api/teach.ts
@@ -499,6 +499,12 @@ export type CourseReportView = {
atRiskDays: number;
enrollments: ReportEnrollmentRow[];
chapterReport: ChapterReportView | null;
+ ratings: {
+ average: number | null;
+ count: number;
+ distribution: Array<{ stars: number; count: number }>;
+ recent: Array<{ id: string; rating: number; review: string | null; reviewer: string; createdAt: string }>;
+ };
};
export type CourseActivityView = {
diff --git a/server/src/validations/courses.ts b/server/src/validations/courses.ts
index e04e551b3..ae5fcf7e2 100644
--- a/server/src/validations/courses.ts
+++ b/server/src/validations/courses.ts
@@ -8,3 +8,16 @@ export const chapterPathParams = z.object({ courseSlug: id, chapterSlug: id });
export const chooseVariantBody = z.object({ variantSlug: id });
export const chooseSpecialisationBody = z.object({ specialisationSlug: id });
+
+export const courseRatingBody = z.object({
+ /** 1–5 stars. */
+ rating: z.number().int().min(1).max(5),
+ /** Optional written review, shown publicly on the course page. */
+ review: z.string().trim().max(1000).nullable().optional(),
+});
+export type CourseRatingBody = z.infer;
+
+export const courseReviewsQuery = z.object({
+ page: z.coerce.number().int().min(1).default(1),
+ pageSize: z.coerce.number().int().min(1).max(50).default(5),
+});
diff --git a/server/tests/api/course-ratings.test.ts b/server/tests/api/course-ratings.test.ts
new file mode 100644
index 000000000..9b79a3353
--- /dev/null
+++ b/server/tests/api/course-ratings.test.ts
@@ -0,0 +1,159 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import request from "supertest";
+import { createApp } from "../../src/app.js";
+import { prisma } from "../../src/db/prisma.js";
+import { completeChapter, createCourseFixture } from "../helpers/fixtures.js";
+
+const app = createApp();
+let fx: Awaited>;
+let chapterCSlug = "chapter-c";
+
+beforeAll(async () => {
+ fx = await createCourseFixture();
+ const mod = await prisma.module.findFirstOrThrow({ where: { courseId: fx.course.id } });
+ const chC = await prisma.chapter.create({
+ data: {
+ courseId: fx.course.id,
+ moduleId: mod.id,
+ slug: "chapter-c",
+ title: "Chapter C",
+ order: 3,
+ source: "# C",
+ publishedAt: new Date(),
+ },
+ });
+ chapterCSlug = chC.slug;
+});
+afterAll(async () => {
+ await prisma.courseRating.deleteMany({ where: { courseId: fx.course.id } });
+ await fx.cleanup();
+});
+
+const learner = () => ({ Authorization: `Bearer ${fx.learnerToken}` });
+
+describe("/api/v1/courses/:courseSlug/rating", () => {
+ it("refuses ratings and does not prompt until the learner has finished more than half of the course", async () => {
+ const firstChapter = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/chapters/${fx.chapterA.slug}`)
+ .set(learner());
+ expect(firstChapter.status).toBe(200);
+ expect(firstChapter.body.data.content.promptCourseRating).toBe(false);
+
+ // 1 of 3 chapters complete (33% <= 50%): still under half, so do not prompt or accept a rating yet.
+ await completeChapter(fx.enrollment.id, fx.chapterA.id);
+ await prisma.enrollment.update({
+ where: { id: fx.enrollment.id },
+ data: { chaptersCompleted: 1, percentComplete: 33, status: "IN_PROGRESS" },
+ });
+
+ const secondChapter = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/chapters/${fx.chapterB.slug}`)
+ .set(learner());
+ expect(secondChapter.status).toBe(200);
+ expect(secondChapter.body.data.content.promptCourseRating).toBe(false);
+
+ const earlyRes = await request(app)
+ .put(`/api/v1/courses/${fx.course.slug}/rating`)
+ .set(learner())
+ .send({ rating: 5, review: "Great start." });
+ expect(earlyRes.status).toBe(403);
+ });
+
+ it("prompts on the next chapter once more than half of the course is finished and accepts ratings", async () => {
+ // 2 of 3 chapters complete (67% > 50%): more than half of the course is finished.
+ await completeChapter(fx.enrollment.id, fx.chapterB.id);
+ await prisma.enrollment.update({
+ where: { id: fx.enrollment.id },
+ data: { chaptersCompleted: 2, percentComplete: 67, status: "IN_PROGRESS" },
+ });
+
+ // Does not prompt on the completed chapter itself (when ending the chapter).
+ const completedChapter = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/chapters/${fx.chapterB.slug}`)
+ .set(learner());
+ expect(completedChapter.status).toBe(200);
+ expect(completedChapter.body.data.content.promptCourseRating).toBe(false);
+
+ // Prompts once the learner moves on to the next chapter.
+ const nextChapter = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/chapters/${chapterCSlug}`)
+ .set(learner());
+ expect(nextChapter.status).toBe(200);
+ expect(nextChapter.body.data.content.promptCourseRating).toBe(true);
+
+ const invalid = await request(app)
+ .put(`/api/v1/courses/${fx.course.slug}/rating`)
+ .set(learner())
+ .send({ rating: 6 });
+ expect(invalid.status).toBe(400);
+
+ const created = await request(app)
+ .put(`/api/v1/courses/${fx.course.slug}/rating`)
+ .set(learner())
+ .send({ rating: 5, review: "Clear, practical, and worth the time." });
+ expect(created.status).toBe(200);
+ expect(created.body.data).toMatchObject({ rating: 5, review: "Clear, practical, and worth the time." });
+
+ // Once rated, the chapter reader no longer prompts.
+ const nextAfterRating = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/chapters/${chapterCSlug}`)
+ .set(learner());
+ expect(nextAfterRating.body.data.content.promptCourseRating).toBe(false);
+
+ const mine = await request(app).get(`/api/v1/courses/${fx.course.slug}/rating`).set(learner());
+ expect(mine.body.data.rating).toMatchObject({ rating: 5 });
+
+ const catalog = await request(app).get(`/api/v1/catalog/${fx.course.slug}`);
+ expect(catalog.body.data).toMatchObject({ ratingAverage: 5, ratingCount: 1 });
+
+ const reviews = await request(app).get(`/api/v1/catalog/${fx.course.slug}/reviews`);
+ expect(reviews.status).toBe(200);
+ expect(reviews.body.data).toMatchObject({ count: 1, average: 5 });
+ expect(reviews.body.data.items[0]).toMatchObject({ rating: 5, reviewer: "Test L." });
+
+ // Editing must replace, not add a second rating.
+ const updated = await request(app)
+ .put(`/api/v1/courses/${fx.course.slug}/rating`)
+ .set(learner())
+ .send({ rating: 3, review: null });
+ expect(updated.status).toBe(200);
+
+ const after = await request(app).get(`/api/v1/courses/${fx.course.slug}/rating`).set(learner());
+ expect(after.body.data.rating).toMatchObject({ rating: 3, review: null });
+
+ const catalogAfter = await request(app).get(`/api/v1/catalog/${fx.course.slug}`);
+ expect(catalogAfter.body.data).toMatchObject({ ratingAverage: 3, ratingCount: 1 });
+ });
+
+ it("remembers a dismissed prompt so neither the chapter pop-up nor overview card returns", async () => {
+ await prisma.courseRating.deleteMany({ where: { courseId: fx.course.id, userId: fx.learner.id } });
+
+ const beforeDismiss = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/chapters/${chapterCSlug}`)
+ .set(learner());
+ expect(beforeDismiss.body.data.content.promptCourseRating).toBe(true);
+
+ const res = await request(app)
+ .post(`/api/v1/courses/${fx.course.slug}/rating/dismiss`)
+ .set(learner());
+ expect(res.status).toBe(200);
+
+ const overview = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/overview`)
+ .set(learner());
+ expect(overview.body.data.ratingDismissed).toBe(true);
+
+ const afterDismiss = await request(app)
+ .get(`/api/v1/courses/${fx.course.slug}/chapters/${chapterCSlug}`)
+ .set(learner());
+ expect(afterDismiss.body.data.content.promptCourseRating).toBe(false);
+ });
+
+ it("rejects an unverified learner", async () => {
+ const res = await request(app)
+ .put(`/api/v1/courses/${fx.course.slug}/rating`)
+ .set({ Authorization: `Bearer ${fx.unverifiedToken}` })
+ .send({ rating: 4 });
+ expect(res.status).toBe(403);
+ });
+});