feat: course landing page

feat/course
Arik Chakma 3 weeks ago
parent 38860d35e5
commit a366abaf44
  1. 3
      .env.example
  2. 164
      src/api/course.ts
  3. 36
      src/components/CourseLanding/CourseChapterItem.tsx
  4. 175
      src/components/CourseLanding/CourseFloatingSidebar.tsx
  5. 317
      src/components/CourseLanding/CourseLanding.tsx
  6. 12
      src/lib/number.ts
  7. 29
      src/pages/learn/[courseId]/index.astro
  8. 40
      src/queries/course-progress.ts

@ -1,3 +1,4 @@
PUBLIC_API_URL=https://api.roadmap.sh
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
PUBLIC_COURSE_APP_URL=http://localhost:5173

@ -0,0 +1,164 @@
import { type APIContext } from 'astro';
import { api } from './api.ts';
export const allowedCourseDifficulties = [
'beginner',
'intermediate',
'advanced',
] as const;
export type AllowedCourseDifficulty =
(typeof allowedCourseDifficulties)[number];
export interface CourseDocument {
_id: string;
title: string;
slug: string;
description?: string;
difficulty?: AllowedCourseDifficulty;
briefTitle?: string;
briefDescription?: string;
creatorId: string;
willLearn?: string[];
prerequisites?: string[];
// AI Configurations
setting: {
prompt?: string;
};
createdAt: Date;
updatedAt: Date;
}
export interface CourseChapterDocument {
_id: string;
courseId: string;
creatorId: string;
title: string;
slug: string;
// AI Configurations
setting: {
prompt?: string;
};
sort: number;
createdAt: Date;
updatedAt: Date;
}
export const allowedLessonType = ['lesson', 'quiz', 'challenge'] as const;
export type AllowedLessonType = (typeof allowedLessonType)[number];
export const allowedSQLChallengeType = [
'DDL',
'DML',
'DQL',
'DCL',
'TCL',
] as const;
export type AllowedSQLChallengeType = (typeof allowedSQLChallengeType)[number];
export type SQLChallenge = {
editor: 'sql';
type: AllowedSQLChallengeType;
setupQuery: string;
defaultQuery?: string;
expectedQuery?: string;
};
export type PythonChallenge = {
editor: 'python';
setupCode: string;
defaultCode: string;
expectedCode: string;
};
export type LessonChallenge = SQLChallenge | PythonChallenge;
export type LessonQuestionOption = {
id: string;
text: string;
isCorrect: boolean;
};
export type LessonQuestion = {
id: string;
question: string;
options: LessonQuestionOption[];
};
export interface CourseLessonDocument {
_id: string;
courseId: string;
chapterId: string;
creatorId: string;
title: string;
slug: string;
type: AllowedLessonType;
// lesson
content?: string;
quiz?: {
questions: LessonQuestion[];
};
challenge?: LessonChallenge & {
shouldVerifyResult?: boolean;
};
setting: {
prompt?: string;
};
isLocked: boolean;
sort: number;
createdAt: Date;
updatedAt: Date;
}
export type CourseDetailsResponse = Omit<CourseDocument, 'setting'> & {
chapters: (Pick<
CourseChapterDocument,
'_id' | 'title' | 'slug' | 'sort' | 'courseId'
> & {
lessons: Pick<
CourseLessonDocument,
| '_id'
| 'title'
| 'slug'
| 'type'
| 'sort'
| 'chapterId'
| 'courseId'
| 'isLocked'
>[];
})[];
enrolled: number;
rating: {
average: number;
count: number;
};
};
export function courseApi(context: APIContext) {
return {
getCourse: (courseSlug: string) => {
return api(context).get<CourseDetailsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-course-details/${courseSlug}`,
);
},
};
}

@ -70,20 +70,28 @@ export function CourseChapterItem(props: CourseChapterItemProps) {
{isOpen && (
<div className="border-t">
{lessons.map((lesson, index) => {
return (
<div key={index} className="flex items-center gap-2 p-2">
<span className="text-gray-500">
{lesson.type === 'lesson' ? (
<BookIcon className="size-4 stroke-[2.5]" />
) : (
<CodeXmlIcon className="size-4 stroke-[2.5]" />
)}
</span>
<span>{lesson.title}</span>
</div>
);
})}
{lessons.length === 0 && (
<div className="p-2 text-gray-500">No lessons</div>
)}
{lessons.length > 0 && (
<>
{[...textualLessons, ...excercises].map((lesson, index) => {
return (
<div key={index} className="flex items-center gap-2 p-2">
<span className="text-gray-500">
{lesson.type === 'lesson' ? (
<BookIcon className="size-4 stroke-[2.5]" />
) : (
<CodeXmlIcon className="size-4 stroke-[2.5]" />
)}
</span>
<span>{lesson.title}</span>
</div>
);
})}
</>
)}
</div>
)}
</div>

@ -1,13 +1,64 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import type { CourseDetailsResponse } from '../../api/course';
import { cn } from '../../lib/classname';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { courseProgressOptions } from '../../queries/course-progress';
import { queryClient } from '../../stores/query-client';
import { useEffect, useState } from 'react';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { CheckCircle2Icon, Loader2Icon, LockIcon } from 'lucide-react';
type CourseFloatingSidebarProps = {
isSticky: boolean;
course: CourseDetailsResponse;
};
export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) {
const { isSticky } = props;
const { isSticky, course } = props;
const { slug } = course;
const courseUrl = `${import.meta.env.PUBLIC_COURSE_APP_URL}/${slug}`;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const { data: courseProgress, status } = useQuery(
{
...courseProgressOptions(slug),
enabled: !!isLoggedIn(),
},
queryClient,
);
const { mutate: enroll, isPending: isEnrolling } = useMutation(
{
mutationFn: () => {
return httpPost(`/v1-enroll-course/${slug}`, {});
},
onSuccess: () => {
window.location.href = courseUrl;
},
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Failed to enroll');
},
},
queryClient,
);
useEffect(() => {
if (!isLoggedIn()) {
setIsLoading(false);
return;
}
if (status === 'pending') {
return;
}
setIsLoading(false);
}, [courseProgress, status]);
const whatYouGet = [
'Full access to all the courses',
@ -17,6 +68,8 @@ export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) {
'Challenges / Quizes',
];
const hasEnrolled = courseProgress?.startedAt ? true : false;
return (
<div
className={cn(
@ -34,31 +87,60 @@ export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) {
<div className="p-2">
<button
className="flex w-full items-center justify-between gap-1 rounded-lg bg-gradient-to-r from-purple-500 to-purple-700 p-2 px-3 text-slate-50"
className={cn(
'relative flex w-full items-center justify-between gap-1 overflow-hidden rounded-lg bg-gradient-to-r from-purple-500 to-purple-700 p-2 px-3 text-slate-50 disabled:cursor-not-allowed disabled:opacity-50',
(hasEnrolled || isEnrolling) && 'justify-center',
)}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!hasEnrolled) {
enroll();
return;
}
window.location.href = courseUrl;
}}
disabled={isLoading || isEnrolling}
>
<span>Enroll now</span>
<span>5$ / month</span>
{!isEnrolling && (
<>
{hasEnrolled ? (
<>
<span>Resume Learning</span>
</>
) : (
<>
<span>Enroll now</span>
<span>5$ / month</span>
</>
)}
</>
)}
{isEnrolling && (
<>
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
<span>Enrolling...</span>
</>
)}
{isLoading && (
<div className="striped-loader-darker absolute inset-0 z-10 h-full w-full bg-purple-500" />
)}
</button>
</div>
<div className="border-b p-2 pb-4">
<h4 className="text-lg font-medium">Certificate of Completion</h4>
<p className="text-xs text-gray-500">
Certificate will be issued on completion
</p>
<figure className="mt-4">
<img
src="https://images.unsplash.com/photo-1732465286852-a0b95393a90d?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHwxN3x8fGVufDB8fHx8fA%3D%3D"
alt="SQL 101"
className="aspect-video w-full rounded-lg object-cover"
/>
</figure>
<Certificate
isLoading={isLoading}
hasEnrolled={hasEnrolled}
isCourseComplete={courseProgress?.completedAt ? true : false}
courseSlug={slug}
/>
</div>
<div className="p-2">
@ -75,3 +157,66 @@ export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) {
</div>
);
}
type CertificateProps = {
isLoading: boolean;
hasEnrolled: boolean;
isCourseComplete: boolean;
courseSlug: string;
};
export function Certificate(props: CertificateProps) {
const { isLoading, hasEnrolled, isCourseComplete, courseSlug } = props;
return (
<>
<h4 className="text-lg font-medium">Certificate of Completion</h4>
<p className="text-xs text-gray-500">
Certificate will be issued on completion
</p>
<button
className="relative mt-4 flex min-h-40 w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-md border text-sm text-gray-500"
onClick={(e) => {
if (!isLoggedIn()) {
return showLoginPopup();
}
if (hasEnrolled || isLoading || !isCourseComplete) {
return;
}
window.location.href = `${import.meta.env.PUBLIC_COURSE_APP_URL}/${courseSlug}/certificate`;
}}
disabled={isLoading}
>
{!hasEnrolled && !isLoading && (
<>
<LockIcon className="size-8 stroke-[2.5] text-gray-300" />
<p className="text-balance">Enroll to unlock the certificate</p>
</>
)}
{hasEnrolled && !isLoading && (
<>
<LockIcon className="size-8 stroke-[2.5] text-gray-300" />
<p className="text-balance">
Complete the course to unlock the certificate
</p>
</>
)}
{hasEnrolled && isCourseComplete && !isLoading && (
<>
<CheckCircle2Icon className="size-8 stroke-[2.5] text-gray-300" />
<p className="text-balance">Download Certificate</p>
</>
)}
{isLoading && (
<div className="striped-loader absolute inset-0 z-10 h-full w-full bg-white" />
)}
</button>
</>
);
}

@ -8,159 +8,25 @@ import {
} from 'lucide-react';
import { Rating } from '../Rating/Rating';
import { CourseStatPill } from './CourseStatPill';
import { useRef, useState, useEffect } from 'react';
import { useRef, useState, useEffect, useMemo } from 'react';
import { cn } from '../../lib/classname';
import { CourseInfoCard } from './CourseInfoCard';
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
import { CourseChapterItem } from './CourseChapterItem';
import { CourseFloatingSidebar } from './CourseFloatingSidebar';
import type { CourseDetailsResponse } from '../../api/course';
import { sanitizeHtml } from '../../lib/sanitize-html';
import { markdownToHtml } from '../../lib/markdown';
import { getRelativeTimeString } from '../../lib/date';
import { humanizeNumber } from '../../lib/number';
const DUMMY_COURSE_CONTENT = [
{
title: 'Introduction to SQL',
lessons: [
{
type: 'lesson',
title: 'What is SQL?',
},
{
type: 'lesson',
title: 'Why use SQL?',
},
{
type: 'lesson',
title: 'SQL Syntax',
},
{
type: 'quiz',
title: 'Quiz 1',
},
{
type: 'challenge',
title: 'Challenge 1',
},
],
},
{
title: 'Basic SQL Queries',
lessons: [
{
type: 'lesson',
title: 'SELECT Statement',
},
{
type: 'lesson',
title: 'WHERE Clause',
},
{
type: 'lesson',
title: 'ORDER BY Clause',
},
{
type: 'quiz',
title: 'Quiz 2',
},
{
type: 'challenge',
title: 'Challenge 2',
},
],
},
{
title: 'Advanced SQL Queries',
lessons: [
{
type: 'lesson',
title: 'JOIN Clause',
},
{
type: 'lesson',
title: 'GROUP BY Clause',
},
{
type: 'lesson',
title: 'HAVING Clause',
},
{
type: 'quiz',
title: 'Quiz 3',
},
{
type: 'challenge',
title: 'Challenge 3',
},
],
},
{
title: 'SQL Functions',
lessons: [
{
type: 'lesson',
title: 'COUNT() Function',
},
{
type: 'lesson',
title: 'SUM() Function',
},
{
type: 'lesson',
title: 'AVG() Function',
},
{
type: 'quiz',
title: 'Quiz 4',
},
{
type: 'challenge',
title: 'Challenge 4',
},
],
},
{
title: 'Database Design',
lessons: [
{
type: 'lesson',
title: 'Normalization',
},
{
type: 'lesson',
title: 'Denormalization',
},
{
type: 'lesson',
title: 'Indexes',
},
{
type: 'quiz',
title: 'Quiz 5',
},
{
type: 'challenge',
title: 'Challenge 5',
},
],
},
{
title: 'Optimizing Queries',
lessons: [
{
type: 'lesson',
title: 'Query Optimization',
},
{
type: 'lesson',
title: 'Indexing',
},
{
type: 'lesson',
title: 'Query Caching',
},
],
},
];
type CourseLandingProps = {
course: CourseDetailsResponse;
};
export function CourseLanding(props: CourseLandingProps) {
const { course } = props;
export function CourseLanding() {
const containerRef = useRef<HTMLDivElement>(null);
const [isSticky, setIsSticky] = useState(false);
@ -177,46 +43,90 @@ export function CourseLanding() {
return () => window.removeEventListener('scroll', handleScroll);
}, [containerRef]);
const {
title,
chapters,
description,
briefDescription,
briefTitle,
difficulty,
updatedAt,
rating,
willLearn = [],
enrolled,
prerequisites,
} = course;
const updatedTime = getRelativeTimeString(updatedAt);
const averageRating = rating ? rating.average : 0;
const sortedChapters = chapters.sort((a, b) => a.sort - b.sort);
const [lessonCount, challengeCount] = sortedChapters.reduce(
(acc, chapter) => {
const lessonCount = chapter.lessons.filter(
(lesson) => lesson.type === 'lesson',
).length;
const challengeCount = chapter.lessons.filter(
(lesson) => lesson.type === 'challenge' || lesson.type === 'quiz',
).length;
return [acc[0] + lessonCount, acc[1] + challengeCount];
},
[0, 0],
);
const enrolledLabel = `${humanizeNumber(enrolled)} user${
enrolled > 1 ? 's' : ''
} enrolled`;
return (
<>
<div className="bg-slate-900 py-5 text-white sm:py-8">
<div className="container grid grid-cols-5 gap-6">
<div className="col-start-1 col-end-4 space-y-4">
<p className="flex items-center gap-1 text-sm text-slate-400">
<a>Home</a> / <a>Courses</a> / <a>Learn SQL</a>
<a href="/">Home</a> / <a href="/courses">Courses</a> /{' '}
<a href={`/learn/${course.slug}`}>{title}</a>
</p>
<h1 className="mt-8 text-5xl font-bold">SQL 101</h1>
<h1 className="mt-8 text-5xl font-bold">{briefTitle}</h1>
<div className="flex items-center gap-2">
<CourseStatPill
icon={ShapesIcon}
label="Difficulty Beginner"
className="border-none p-0 text-slate-400"
label={`Difficulty ${difficulty}`}
className="border-none p-0 capitalize text-slate-400"
/>
<CourseStatPill
icon={CalendarIcon}
label="Updated 5 days ago"
label={`Updated ${updatedTime}`}
className="border-none p-0 text-slate-400"
/>
</div>
<p className="text-sm">
Learn everything you need to know about SQL with an interactive
playground. It comes with a built-in editor and a database to
practice your queries.
</p>
{briefDescription && (
<Description
description={briefDescription}
className="prose-invert"
/>
)}
<div className="flex items-center gap-2 text-sm">
<span>4.5</span>
<Rating rating={4.5} />
<span>(559 ratings)</span>
<span>{averageRating}</span>
<Rating rating={averageRating} />
<span>({rating.count} ratings)</span>
</div>
<div className="flex items-center gap-2">
<CourseStatPill icon={UsersIcon} label="4.5k users enrolled" />
<CourseStatPill icon={LetterTextIcon} label="20 Lessons" />
<CourseStatPill icon={CodeXmlIcon} label="35 Challenges" />
<CourseStatPill icon={UsersIcon} label={enrolledLabel} />
<CourseStatPill
icon={LetterTextIcon}
label={`${lessonCount} Lessons`}
/>
<CourseStatPill
icon={CodeXmlIcon}
label={`${challengeCount} Challenges`}
/>
</div>
</div>
</div>
@ -228,37 +138,39 @@ export function CourseLanding() {
ref={containerRef}
>
<div className="col-start-1 col-end-4 space-y-4">
<CourseInfoCard title="What you'll learn">
<ul className="flex list-inside list-disc flex-col gap-1 text-sm text-gray-700 marker:text-gray-400">
<li>Understand SQL syntax</li>
<li>Write complex queries</li>
<li>Use SQL in real-world scenarios</li>
<li>Optimize your queries</li>
<li>Understand database design</li>
<li>Write complex queries</li>
</ul>
</CourseInfoCard>
<CourseInfoCard title="About this Course">
<div className="prose-sm mt-4">
<p>
SQL 101 is a beginner-friendly course that will teach you
everything you need to know about SQL. It comes with an
interactive playground where you can practice your queries.
</p>
<p>
The course is divided into multiple sections, each covering a
different aspect of SQL. You'll learn how to write complex
queries, use SQL in real-world scenarios, optimize your
queries, and understand database design.
</p>
</div>
</CourseInfoCard>
{willLearn.length > 0 && (
<CourseInfoCard title="What you'll learn">
<ul className="flex list-inside list-disc flex-col gap-1 text-sm text-gray-700 marker:text-gray-400">
{willLearn.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</CourseInfoCard>
)}
{description && (
<CourseInfoCard title="About this Course">
<div className="mt-4">
<Description description={description} />
</div>
</CourseInfoCard>
)}
{prerequisites && prerequisites.length > 0 && (
<CourseInfoCard title="Prerequisites">
<ul className="flex list-inside list-disc flex-col gap-1 text-sm text-gray-700 marker:text-gray-400">
{prerequisites.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</CourseInfoCard>
)}
<CourseInfoCard title="Course Content">
{DUMMY_COURSE_CONTENT.map((section, index) => {
const { title, lessons } = section;
{sortedChapters.map((chapter, index) => {
const { title, lessons } = chapter;
const isFirst = index === 0;
const isLast = index === DUMMY_COURSE_CONTENT.length - 1;
const isLast = index === sortedChapters.length - 1;
return (
<CourseChapterItem
@ -275,10 +187,33 @@ export function CourseLanding() {
</CourseInfoCard>
</div>
<div className="col-start-4 col-end-6">
<CourseFloatingSidebar isSticky={isSticky} />
<CourseFloatingSidebar isSticky={isSticky} course={course} />
</div>
</div>
</div>
</>
);
}
type DescriptionProps = {
description: string;
className?: string;
};
export function Description(props: DescriptionProps) {
const { description, className } = props;
const html = useMemo(() => {
return sanitizeHtml(markdownToHtml(description, false));
}, [description]);
return (
<div
className={cn(
'course-content prose prose-sm prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800',
className,
)}
dangerouslySetInnerHTML={{ __html: html }}
></div>
);
}

@ -9,3 +9,15 @@ export function formatCommaNumber(number: number): string {
export function decimalIfNeeded(number: number): string {
return number % 1 === 0 ? number.toString() : number.toFixed(1);
}
export function humanizeNumber(number: number): string {
if (number < 1000) {
return formatCommaNumber(number);
}
if (number < 1000000) {
return `${decimalIfNeeded(number / 1000)}k`;
}
return `${decimalIfNeeded(number / 1000000)}m`;
}

@ -1,34 +1,19 @@
---
import { courseApi } from '../../../api/course';
import { CourseLanding } from '../../../components/CourseLanding/CourseLanding';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import {
getAllCourses,
getCourseById,
type CourseFileType,
} from '../../../lib/course';
export async function getStaticPaths() {
const courses = await getAllCourses();
export const prerender = false;
return courses.map((course) => ({
params: { courseId: course.id },
props: { course },
}));
}
interface Params extends Record<string, string | undefined> {
interface Params {
courseId: string;
}
interface Props {
course: CourseFileType;
}
const { courseId } = Astro.params;
const { course } = Astro.props;
const courseClient = courseApi(Astro);
const { response: course, error } = await courseClient.getCourse(courseId!);
---
<BaseLayout title={course.frontmatter.title}>
<CourseLanding client:load />
<div slot='page-footer'></div>
<BaseLayout title={course?.title || 'Course'} description={course?.description}>
{course && <CourseLanding course={course} client:load />}
</BaseLayout>

@ -0,0 +1,40 @@
import { queryOptions } from '@tanstack/react-query';
import { isLoggedIn } from '../lib/jwt';
import { httpGet } from '../lib/query-http';
export interface CourseProgressDocument {
_id: string;
userId: string;
courseId: string;
completed: {
chapterId: string;
lessonId: string;
completedAt: Date;
}[];
review?: {
rating: number;
feedback?: string;
};
startedAt?: Date;
completedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export type CourseProgressResponse = Pick<
CourseProgressDocument,
'completed' | 'completedAt' | 'review' | 'startedAt'
>;
export function courseProgressOptions(courseSlug: string) {
return queryOptions({
queryKey: ['course-progress', courseSlug],
queryFn: async () => {
return httpGet<CourseProgressResponse>(
`/v1-course-progress/${courseSlug}`,
);
},
enabled: !!isLoggedIn(),
});
}
Loading…
Cancel
Save