Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion frontend/src/app/(learn)/(catalog)/catalog/[courseSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,31 @@ 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";
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<CatalogCourseDetail>(`/api/v1/catalog/${encodeURIComponent(courseSlug)}`);
}

async function loadReviews(courseSlug: string): Promise<CourseReviewsView | null> {
try {
return await serverApiFetch<CourseReviewsView>(
`/api/v1/catalog/${encodeURIComponent(courseSlug)}/reviews?page=1&pageSize=5`
);
} catch {
return null;
}
}

export async function generateMetadata({ params }: { params: Promise<{ courseSlug: string }> }): Promise<Metadata> {
const { courseSlug } = await params;
const course = await loadCourse(courseSlug);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -117,6 +130,15 @@ export default async function CatalogCoursePage({
{course.instructorName ? `Instructor · ${course.instructorName}` : "Instructor-led course"}
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{course.title}</h1>
{course.ratingCount > 0 && course.ratingAverage != null ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<StarRating value={course.ratingAverage} size={16} />
<span className="font-medium text-foreground">{course.ratingAverage.toFixed(1)}</span>
<span>
({course.ratingCount} rating{course.ratingCount === 1 ? "" : "s"})
</span>
</div>
) : null}
{course.description ? (
<p className="mt-3 text-sm leading-6 text-muted-foreground">{course.description}</p>
) : null}
Expand Down Expand Up @@ -146,6 +168,8 @@ export default async function CatalogCoursePage({
</header>
</div>

{reviews ? <CourseReviews reviews={reviews} /> : null}

<div className="mt-6">
<CourseProjectShowcase courseSlug={course.slug} />
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/(learn)/(catalog)/catalog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ function CourseGrid({ courses }: { courses: CatalogCourseSummary[] }) {
tags={course.tags}
instructorName={course.instructorName}
enrolledLabel={enrolledLabel}
ratingAverage={course.ratingAverage}
ratingCount={course.ratingCount}
/>
</li>
);
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/app/(learn)/(main)/courses/[courseSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CourseOverview>(`/api/v1/courses/${encodeURIComponent(courseSlug)}/overview`)
Expand Down Expand Up @@ -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 (
<WebCourseNavigation>
<CourseOverviewContent
Expand All @@ -115,6 +122,21 @@ export default async function CourseOverviewPage({
"use server";
await upgradeCourseCurriculumAction(course.slug);
}}
ratingLink={
canRateCourse && !overview.rating && overview.ratingDismissed ? (
<CourseRatingLink courseSlug={course.slug} courseTitle={course.title} />
) : null
}
ratingBanner={
canRateCourse && (overview.rating || !overview.ratingDismissed) ? (
<CourseRatingCard
courseSlug={course.slug}
courseTitle={course.title}
initialRating={overview.rating}
initialDismissed={overview.ratingDismissed}
/>
) : null
}
/>
</WebCourseNavigation>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
58 changes: 57 additions & 1 deletion frontend/src/app/(teach)/courses/[courseSlug]/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,7 +35,7 @@ export default async function CourseReportsPage({
const report = await serverApiFetchOrNotFound<CourseReportView>(
`/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();

Expand Down Expand Up @@ -87,6 +89,60 @@ export default async function CourseReportsPage({
<SummaryCard icon={CircleCheckBig} label="Completed" value={summary.completed} sub={`${pct(summary.completed, summary.enrolled)}% completion rate`} />
</div>

<section className="rounded-2xl border bg-card p-5 shadow-sm sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">Course rating</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">What learners said</h2>
</div>
<div className="text-right">
<p className="flex items-center justify-end gap-1.5 text-2xl font-semibold tabular-nums">
<Star className="size-4 fill-current text-amber-500" aria-hidden />
{ratings.average != null ? ratings.average.toFixed(1) : "—"}
</p>
<p className="text-xs text-muted-foreground">
{ratings.count} rating{ratings.count === 1 ? "" : "s"}
</p>
</div>
</div>

{ratings.count === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">
No ratings yet. Learners can rate the course after completing it.
</p>
) : (
<div className="mt-4 grid gap-5 sm:grid-cols-2">
<div className="space-y-1">
{ratings.distribution.map((row) => {
const percent = ratings.count > 0 ? Math.round((row.count / ratings.count) * 100) : 0;
return (
<div key={row.stars} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-8 tabular-nums">{row.stars}★</span>
<span className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<span className="block h-full rounded-full bg-amber-500" style={{ width: `${percent}%` }} />
</span>
<span className="w-10 text-right tabular-nums">{percent}%</span>
</div>
);
})}
</div>
<ul className="space-y-2">
{ratings.recent.map((row) => (
<li key={row.id} className="rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
<StarRating value={row.rating} size={12} />
<span className="font-medium text-foreground">{row.reviewer}</span>
</div>
{row.review ? (
<p className="mt-1 text-sm text-muted-foreground">{row.review}</p>
) : null}
</li>
))}
</ul>
</div>
)}
</section>

{atRisk.length > 0 && (
<section className="rounded-2xl border border-amber-300/60 bg-amber-50/70 p-4 dark:border-amber-500/30 dark:bg-amber-500/10">
<p className="flex items-center gap-2 text-sm font-semibold text-amber-950 dark:text-amber-200">
Expand Down
29 changes: 28 additions & 1 deletion frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/learn/catalog-course-card.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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. */
Expand All @@ -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)
Expand All @@ -48,6 +53,13 @@ export function CatalogCourseCard({
{meta ? <p className="text-sm capitalize text-muted-foreground">{meta}</p> : null}
{subcategory ? <p className="mt-1 text-xs font-semibold uppercase tracking-wide text-primary">{subcategory}</p> : null}
<h2 className="mt-2 text-lg leading-snug tracking-[-0.02em]">{title}</h2>
{ratingCount > 0 && ratingAverage != null ? (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<StarRating value={ratingAverage} size={12} />
<span className="font-medium text-foreground">{ratingAverage.toFixed(1)}</span>
<span>({ratingCount})</span>
</div>
) : null}
{tags.length ? <p className="mt-2 line-clamp-1 text-xs text-muted-foreground">{tags.slice(0, 3).join(" · ")}</p> : null}
{instructorName ? <p className="mt-1 text-sm text-muted-foreground">With {instructorName}</p> : null}
{description ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function ChapterCompleteCelebrationModal({
<p className="mt-2 text-xs text-muted-foreground">{percent}% complete</p>
</div>
) : null}

<div className="mt-5 flex flex-col gap-2">
{courseComplete ? (
<Link href="/my-courses" className={buttonVariants({ className: "min-h-11" })}>View my courses <ArrowRight aria-hidden /></Link>
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/learn/chapter-reader-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -85,6 +86,7 @@ export function ChapterReaderShell({
headingNotes = {},
predictGate = false,
predictRevealed = true,
promptCourseRating = false,
courseId,
helpThread,
narration = null,
Expand Down Expand Up @@ -117,6 +119,7 @@ export function ChapterReaderShell({
headingNotes?: Record<string, string>;
predictGate?: boolean;
predictRevealed?: boolean;
promptCourseRating?: boolean;
courseId: string;
helpThread: HelpThreadView | null;
/** Listen-along audio for this chapter, when its course has it. */
Expand Down Expand Up @@ -541,6 +544,11 @@ export function ChapterReaderShell({

{focusMode && <ReaderFocusDock onExitFocus={() => void exitFocusMode()} />}
<AskAiFloating focusMode={focusMode} />
<CourseRatingPrompt
courseSlug={courseSlug}
courseTitle={courseTitle}
shouldPrompt={promptCourseRating}
/>
</ReaderShellFade>
</div>
</AskChapterProvider>
Expand Down
Loading
Loading