feat: add ai course generator (#8322)

* Course landing page

* Add ai course page

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip: error handling

* wip

* wip

* wip

* wip: ai course progress

* wip

* wip

* wip

* feat: code highlighting

* feat: usage limit

* feat: follow up message

* Update UI

* wip

* Add course content

* wip: autogrow textarea & examples

* Update types

* Update

* fix: add highlight to the AI chat

* UI changes

* Refactor

* Update

* Improve outline style

* Improve spacing

* Improve spacing

* UI changes for sidebar

* Update UI for sidebar

* Improve course UI

* Mark done, undone

* Add toggle lesson done/undone

* Update forward backward UI

* wip

* Minor ui change

* Responsiveness of sidebar

* wip

* wip

* wip: billing page

* wip

* Update UI

* fix: hide upgrade if paid user

* feat: token usage

* feat: list ai courses

* fix: limit for followup

* Course content responsiveness

* Make course content responsive

* Responsiveness

* Outline button

* Responsiveness of course content

* Responsiveness of course content

* Add course upgrade button

* Update design for upgrade

* Improve logic for upgrade and limits button

* Limits and errors

* Add lesson count

* Add course card

* Improve UI for course generator

* Update course functionality

* Refactor AI course generation

* Responsiveness of screen

* Improve

* Add responsiveness

* Improve empty billing page design

* Add empty billing screen

* Update UI for billing page

* Update UI for billing page

* Update UI for billing page

* Update billing page design

* Update

* Remove sidebar

* Update

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
Kamran AhmedandArik Chakma authored and GitHub committed 2025-03-12 13:17:38 +00:00
1 parent faf43f7905
commit cb64894e49
39 files changed
+3906 -25

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import { SearchIcon, WandIcon } from 'lucide-react';
import { useState } from 'react';
import { cn } from '../../lib/classname';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { UserCoursesList } from './UserCoursesList';
export const difficultyLevels = [
'beginner',
'intermediate',
'advanced',
] as const;
export type DifficultyLevel = (typeof difficultyLevels)[number];
type AICourseProps = {};
export function AICourse(props: AICourseProps) {
const [keyword, setKeyword] = useState('');
const [difficulty, setDifficulty] = useState<DifficultyLevel>('beginner');
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && keyword.trim()) {
onSubmit();
}
};
function onSubmit() {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
window.location.href = `/ai-tutor/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}`;
}
return (
<section className="flex flex-grow flex-col bg-gray-100">
<div className="container mx-auto flex max-w-3xl flex-col py-24 max-sm:py-4">
<h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl">
Learn anything with AI
</h1>
<p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm">
Enter a topic below to generate a personalized course for it
</p>
<div className="rounded-lg border border-gray-200 bg-white p-6 max-sm:p-4">
<form
className="flex flex-col gap-5"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<div className="flex flex-col">
<label
htmlFor="keyword"
className="mb-2.5 text-sm font-medium text-gray-700"
>
Course Topic
</label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<SearchIcon size={18} />
</div>
<input
id="keyword"
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., Algebra, JavaScript, Photography"
className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-500 max-sm:placeholder:text-base"
maxLength={50}
/>
</div>
</div>
<div className="flex flex-col">
<label className="mb-2.5 text-sm font-medium text-gray-700">
Difficulty Level
</label>
<div className="flex gap-2 max-sm:flex-col max-sm:gap-1">
{difficultyLevels.map((level) => (
<button
key={level}
type="button"
onClick={() => setDifficulty(level)}
className={cn(
'rounded-md border px-4 py-2 capitalize max-sm:text-sm',
difficulty === level
? 'border-gray-800 bg-gray-800 text-white'
: 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200',
)}
>
{level}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={!keyword.trim()}
className={cn(
'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors max-sm:text-sm',
!keyword.trim()
? 'cursor-not-allowed bg-gray-400'
: 'bg-black hover:bg-gray-800',
)}
>
<WandIcon size={18} className="mr-2" />
Generate Course
</button>
</form>
</div>
<div className="mt-8 min-h-[200px]">
<UserCoursesList />
</div>
</div>
</section>
);
}
@@ -0,0 +1,73 @@
import type { AICourseListItem } from '../../queries/ai-course';
import type { DifficultyLevel } from './AICourse';
import { BookOpen } from 'lucide-react';
type AICourseCardProps = {
course: AICourseListItem;
};
export function AICourseCard(props: AICourseCardProps) {
const { course } = props;
// Format date if available
const formattedDate = course.createdAt
? new Date(course.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
: null;
// Map difficulty to color
const difficultyColor =
{
beginner: 'text-green-700',
intermediate: 'text-blue-700',
advanced: 'text-purple-700',
}[course.difficulty as DifficultyLevel] || 'text-gray-700';
// Calculate progress percentage
const totalTopics = course.lessonCount || 0;
const completedTopics = course.progress?.done?.length || 0;
const progressPercentage =
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
return (
<a
href={`/ai-tutor/${course.slug}`}
className="hover:border-gray-3 00 group relative flex w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
>
{course.difficulty}
</span>
</div>
<h3 className="my-2 text-base font-semibold text-gray-900">
{course.title}
</h3>
<div className="mt-auto flex items-center justify-between pt-2">
<div className="flex items-center text-xs text-gray-600">
<BookOpen className="mr-1 h-3.5 w-3.5" />
<span>{totalTopics} lessons</span>
</div>
{totalTopics > 0 && (
<div className="flex items-center">
<div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-blue-600"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<span className="text-xs font-medium text-gray-700">
{progressPercentage}%
</span>
</div>
)}
</div>
</a>
);
}
@@ -0,0 +1,451 @@
import { useQuery } from '@tanstack/react-query';
import {
BookOpenCheck,
ChevronLeft,
Loader2,
Menu,
X,
CircleAlert,
} from 'lucide-react';
import { useState } from 'react';
import { type AiCourse } from '../../lib/ai';
import { cn } from '../../lib/classname';
import { slugify } from '../../lib/slugger';
import { getAiCourseProgressOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
import { AICourseLimit } from './AICourseLimit';
import { AICourseModuleList } from './AICourseModuleList';
import { AICourseModuleView } from './AICourseModuleView';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AILimitsPopup } from './AILimitsPopup';
type AICourseContentProps = {
courseSlug?: string;
course: AiCourse;
isLoading: boolean;
error?: string;
};
export function AICourseContent(props: AICourseContentProps) {
const { course, courseSlug, isLoading, error } = props;
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
const [activeModuleIndex, setActiveModuleIndex] = useState(0);
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [viewMode, setViewMode] = useState<'module' | 'full'>('full');
const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
const [expandedModules, setExpandedModules] = useState<
Record<number, boolean>
>({});
const goToNextModule = () => {
if (activeModuleIndex < course.modules.length - 1) {
const nextModuleIndex = activeModuleIndex + 1;
setActiveModuleIndex(nextModuleIndex);
setActiveLessonIndex(0);
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
newState[nextModuleIndex] = true;
return newState;
});
}
};
const goToNextLesson = () => {
const currentModule = course.modules[activeModuleIndex];
if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) {
setActiveLessonIndex(activeLessonIndex + 1);
} else {
goToNextModule();
}
};
const goToPrevLesson = () => {
if (activeLessonIndex > 0) {
setActiveLessonIndex(activeLessonIndex - 1);
} else {
const prevModule = course.modules[activeModuleIndex - 1];
if (prevModule) {
const prevModuleIndex = activeModuleIndex - 1;
setActiveModuleIndex(prevModuleIndex);
setActiveLessonIndex(prevModule.lessons.length - 1);
// Expand the previous module in the sidebar
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
// Set all modules to collapsed
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
// Expand only the previous module
newState[prevModuleIndex] = true;
return newState;
});
}
}
};
const currentModule = course.modules[activeModuleIndex];
const currentLesson = currentModule?.lessons[activeLessonIndex];
const totalModules = course.modules.length;
const totalLessons = currentModule?.lessons.length || 0;
const totalCourseLessons = course.modules.reduce(
(total, module) => total + module.lessons.length,
0,
);
const totalDoneLessons = aiCourseProgress?.done?.length || 0;
const finishedPercentage = Math.round(
(totalDoneLessons / totalCourseLessons) * 100,
);
const modals = (
<>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{showAILimitsPopup && (
<AILimitsPopup
onClose={() => setShowAILimitsPopup(false)}
onUpgrade={() => {
setShowAILimitsPopup(false);
setShowUpgradeModal(true);
}}
/>
)}
</>
);
if (error && !isLoading) {
const isLimitReached = error.includes('limit');
const icon = isLimitReached ? (
<CircleAlert className="mb-4 size-16 text-yellow-500" />
) : (
<ErrorIcon additionalClasses="mb-4 size-16" />
);
const title = isLimitReached ? 'Limit Reached' : 'Error Generating Course';
const message = isLimitReached
? 'You have reached the daily AI usage limit. Please upgrade your account to continue.'
: error;
return (
<>
{modals}
<div className="flex h-screen flex-col items-center justify-center px-4 text-center">
{icon}
<h1 className="text-2xl font-bold">{title}</h1>
<p className="my-3 max-w-sm text-balance text-gray-500">{message}</p>
{isLimitReached && (
<div className="mt-4">
<button
onClick={() => setShowUpgradeModal(true)}
className="rounded-md bg-yellow-400 px-6 py-2 text-sm font-medium text-black hover:bg-yellow-500"
>
Upgrade to remove Limits
</button>
<p className="mt-4 text-sm text-black">
<a href="/ai-tutor" className="underline underline-offset-2">
Back to AI Tutor
</a>
</p>
</div>
)}
</div>
</>
);
}
return (
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50">
{modals}
<div className="border-b border-gray-200 bg-gray-100">
<div className="flex items-center justify-between px-4 py-2">
<a
href="/ai-tutor"
className="flex flex-row items-center gap-1.5 text-sm font-medium text-gray-700 hover:text-gray-900"
aria-label="Back to generator"
>
<ChevronLeft className="size-4" strokeWidth={2.5} />
Back<span className="hidden lg:inline"> to Generator</span>
</a>
<div className="flex items-center gap-2">
<div className="flex flex-row lg:hidden">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden"
>
{sidebarOpen ? (
<X size={17} strokeWidth={3} />
) : (
<Menu size={17} strokeWidth={3} />
)}
</button>
</div>
</div>
</div>
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 max-lg:py-4 lg:h-[80px]">
<div className="flex items-center">
<div className="flex flex-col">
<h1 className="text-balance text-xl font-bold !leading-tight text-gray-900 max-lg:mb-0.5 max-lg:text-lg">
{course.title || 'Loading Course...'}
</h1>
<div className="mt-1 flex flex-row items-center gap-2 text-sm text-gray-600 max-lg:text-xs">
<span className="font-medium">{totalModules} modules</span>
<span className="text-gray-400">•</span>
<span className="font-medium">{totalCourseLessons} lessons</span>
{viewMode === 'module' && (
<span className="flex flex-row items-center gap-1 lg:hidden">
<span className="text-gray-400">•</span>
<button
className="underline underline-offset-2"
onClick={() => {
setExpandedModules({});
setViewMode('full');
}}
>
View outline
</button>
</span>
)}
{finishedPercentage > 0 && (
<>
<span className="text-gray-400">•</span>
<span className="font-medium text-green-600">
{finishedPercentage}% complete
</span>
</>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<div className="hidden gap-2 lg:flex">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
{viewMode === 'module' && (
<button
onClick={() => {
setExpandedModules({});
setViewMode('full');
}}
className="flex flex-shrink-0 items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 max-lg:hidden"
>
<BookOpenCheck size={18} className="mr-2" />
View Course Outline
</button>
)}
</div>
</header>
<div className="flex flex-1 overflow-hidden">
<aside
className={cn(
'fixed inset-y-0 left-0 z-20 w-80 transform overflow-y-auto border-r border-gray-200 bg-white transition-transform duration-200 ease-in-out lg:relative lg:mt-0 lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div
className={cn(
'relative flex min-h-[40px] items-center justify-between border-b border-gray-200 px-3',
isLoading && 'striped-loader bg-gray-50',
)}
>
{!isLoading && (
<div className="text-xs text-black">
<span className="relative z-10 rounded-full bg-yellow-400 px-1.5 py-0.5">
{finishedPercentage}%
</span>{' '}
<span className="relative z-10">Completed</span>
<span
style={{
width: `${finishedPercentage}%`,
}}
className={cn(
'absolute bottom-0 left-0 top-0',
'bg-gray-200/50',
)}
></span>
</div>
)}
<button
onClick={() => setSidebarOpen(false)}
className="rounded-md p-1 hover:bg-gray-100 lg:hidden"
>
<X size={18} />
</button>
</div>
<AICourseModuleList
course={course}
courseSlug={courseSlug}
activeModuleIndex={
viewMode === 'module' ? activeModuleIndex : undefined
}
setActiveModuleIndex={setActiveModuleIndex}
activeLessonIndex={
viewMode === 'module' ? activeLessonIndex : undefined
}
setActiveLessonIndex={setActiveLessonIndex}
setSidebarOpen={setSidebarOpen}
viewMode={viewMode}
setViewMode={setViewMode}
expandedModules={expandedModules}
setExpandedModules={setExpandedModules}
isLoading={isLoading}
/>
</aside>
<main
className={cn(
'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out max-lg:p-3',
sidebarOpen ? 'lg:ml-0' : '',
)}
>
{viewMode === 'module' && (
<AICourseModuleView
courseSlug={courseSlug!}
activeModuleIndex={activeModuleIndex}
totalModules={totalModules}
currentModuleTitle={currentModule?.title || ''}
activeLessonIndex={activeLessonIndex}
totalLessons={totalLessons}
currentLessonTitle={currentLesson || ''}
onGoToPrevLesson={goToPrevLesson}
onGoToNextLesson={goToNextLesson}
key={`${courseSlug}-${activeModuleIndex}-${activeLessonIndex}`}
onUpgrade={() => setShowUpgradeModal(true)}
/>
)}
{viewMode === 'full' && (
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
<div
className={cn(
'mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden',
isLoading && 'striped-loader',
)}
>
<div>
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight">
{course.title || 'Loading course ..'}
</h2>
<p className="text-sm capitalize text-gray-500">
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
</div>
{course.title ? (
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">
{course.modules.map((courseModule, moduleIdx) => {
return (
<div
key={moduleIdx}
className="mb-5 pb-4 last:border-0 last:pb-0 max-lg:mb-2"
>
<h2 className="mb-4 text-xl font-bold text-gray-800 max-lg:mb-2 max-lg:text-lg max-lg:leading-tight">
{courseModule.title}
</h2>
<div className="divide-y divide-gray-100">
{courseModule.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(courseModule.title)}__${slugify(lesson)}`;
const isCompleted =
aiCourseProgress?.done.includes(key);
return (
<div
key={key}
className="flex cursor-pointer items-center gap-2 px-2 py-2.5 transition-colors hover:bg-gray-100 max-lg:px-0 max-lg:py-1.5"
onClick={() => {
setActiveModuleIndex(moduleIdx);
setActiveLessonIndex(lessonIdx);
setExpandedModules((prev) => {
const newState: Record<number, boolean> =
{};
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
newState[moduleIdx] = true;
return newState;
});
setSidebarOpen(false);
setViewMode('module');
}}
>
{!isCompleted && (
<span
className={cn(
'flex size-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm font-medium text-gray-800 max-lg:size-5 max-lg:text-xs',
)}
>
{lessonIdx + 1}
</span>
)}
{isCompleted && (
<CheckIcon additionalClasses="size-6 flex-shrink-0 text-green-500" />
)}
<p className="flex-1 truncate text-base text-gray-800 max-lg:text-sm">
{lesson.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</p>
<span className="text-sm font-medium text-gray-700 max-lg:hidden">
{isCompleted ? 'View' : 'Start'} →
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="flex h-64 items-center justify-center">
<Loader2 size={36} className="animate-spin text-gray-300" />
</div>
)}
</div>
)}
</main>
</div>
{sidebarOpen && (
<div
className="fixed inset-0 z-10 bg-gray-900 bg-opacity-50 lg:hidden"
onClick={() => setSidebarOpen(false)}
></div>
)}
</section>
);
}
@@ -0,0 +1,131 @@
.prose ul li > code,
.prose ol li > code,
p code,
a > code,
strong > code,
em > code,
h1 > code,
h2 > code,
h3 > code {
background: #ebebeb !important;
color: currentColor !important;
font-size: 14px;
font-weight: normal !important;
}
.course-ai-content.course-content.prose ul li > code,
.course-ai-content.course-content.prose ol li > code,
.course-ai-content.course-content.prose p code,
.course-ai-content.course-content.prose a > code,
.course-ai-content.course-content.prose strong > code,
.course-ai-content.course-content.prose em > code,
.course-ai-content.course-content.prose h1 > code,
.course-ai-content.course-content.prose h2 > code,
.course-ai-content.course-content.prose h3 > code,
.course-notes-content.prose ul li > code,
.course-notes-content.prose ol li > code,
.course-notes-content.prose p code,
.course-notes-content.prose a > code,
.course-notes-content.prose strong > code,
.course-notes-content.prose em > code,
.course-notes-content.prose h1 > code,
.course-notes-content.prose h2 > code,
.course-notes-content.prose h3 > code {
font-size: 12px !important;
}
.course-ai-content pre {
-ms-overflow-style: none;
scrollbar-width: none;
}
.course-ai-content pre::-webkit-scrollbar {
display: none;
}
.course-ai-content pre,
.course-notes-content pre {
overflow: scroll;
font-size: 15px;
margin: 10px 0;
}
.prose ul li > code:before,
p > code:before,
.prose ul li > code:after,
.prose ol li > code:before,
p > code:before,
.prose ol li > code:after,
.course-content h1 > code:after,
.course-content h1 > code:before,
.course-content h2 > code:after,
.course-content h2 > code:before,
.course-content h3 > code:after,
.course-content h3 > code:before,
.course-content h4 > code:after,
.course-content h4 > code:before,
p > code:after,
a > code:after,
a > code:before {
content: '' !important;
}
.course-content.prose ul li > code,
.course-content.prose ol li > code,
.course-content p code,
.course-content a > code,
.course-content strong > code,
.course-content em > code,
.course-content h1 > code,
.course-content h2 > code,
.course-content h3 > code,
.course-content table code {
background: #f4f4f5 !important;
border: 1px solid #282a36 !important;
color: #282a36 !important;
padding: 2px 4px;
border-radius: 5px;
font-size: 16px !important;
white-space: pre;
font-weight: normal;
}
.course-content blockquote {
font-style: normal;
}
.course-content.prose blockquote h1,
.course-content.prose blockquote h2,
.course-content.prose blockquote h3,
.course-content.prose blockquote h4 {
font-style: normal;
margin-bottom: 8px;
}
.course-content.prose ul li > code:before,
.course-content p > code:before,
.course-content.prose ul li > code:after,
.course-content p > code:after,
.course-content h2 > code:after,
.course-content h2 > code:before,
.course-content table code:before,
.course-content table code:after,
.course-content a > code:after,
.course-content a > code:before,
.course-content h2 code:after,
.course-content h2 code:before,
.course-content h2 code:after,
.course-content h2 code:before {
content: '' !important;
}
.course-content table {
border-collapse: collapse;
border: 1px solid black;
border-radius: 5px;
}
.course-content table td,
.course-content table th {
padding: 5px 10px;
}
@@ -0,0 +1,77 @@
import { ArrowRightIcon, BotIcon } from 'lucide-react';
import { useState } from 'react';
import {
AICourseFollowUpPopover,
type AIChatHistoryType,
} from './AICourseFollowUpPopover';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
type AICourseFollowUpProps = {
courseSlug: string;
moduleTitle: string;
lessonTitle: string;
};
export function AICourseFollowUp(props: AICourseFollowUpProps) {
const { courseSlug, moduleTitle, lessonTitle } = props;
const [isOpen, setIsOpen] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [courseAIChatHistory, setCourseAIChatHistory] = useState<
AIChatHistoryType[]
>([
{
role: 'assistant',
content:
'Hey, I am your AI instructor. Here are some examples of what you can ask me about 🤖',
isDefault: true,
},
]);
return (
<div className="relative">
<button
className="mt-4 flex w-full items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-100 p-4 hover:bg-yellow-200 max-lg:mt-3 max-lg:text-sm"
onClick={() => setIsOpen(true)}
>
<BotIcon className="h-4 w-4" />
<span>
<span className="max-sm:hidden">Still confused?&nbsp;</span>
Ask AI some follow up questions
</span>
<ArrowRightIcon className="ml-auto h-4 w-4 max-sm:hidden" />
</button>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{isOpen && (
<AICourseFollowUpPopover
courseSlug={courseSlug}
moduleTitle={moduleTitle}
lessonTitle={lessonTitle}
courseAIChatHistory={courseAIChatHistory}
setCourseAIChatHistory={setCourseAIChatHistory}
onUpgradeClick={() => {
setIsOpen(false);
setShowUpgradeModal(true);
}}
onOutsideClick={() => {
if (!isOpen) {
return;
}
setIsOpen(false);
}}
/>
)}
{isOpen && (
<div className="pointer-events-none fixed inset-0 z-50 bg-black/50" />
)}
</div>
);
}
@@ -0,0 +1,382 @@
import { useQuery } from '@tanstack/react-query';
import { BookOpen, Bot, Code, HelpCircle, LockIcon, Send } from 'lucide-react';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { flushSync } from 'react-dom';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { readAICourseLessonStream } from '../../helper/read-stream';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { useToast } from '../../hooks/use-toast';
import {
markdownToHtml,
markdownToHtmlWithHighlighting,
} from '../../lib/markdown';
import { cn } from '../../lib/classname';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import TextareaAutosize from 'react-textarea-autosize';
export type AllowedAIChatRole = 'user' | 'assistant';
export type AIChatHistoryType = {
role: AllowedAIChatRole;
content: string;
isDefault?: boolean;
html?: string;
};
type AICourseFollowUpPopoverProps = {
courseSlug: string;
moduleTitle: string;
lessonTitle: string;
courseAIChatHistory: AIChatHistoryType[];
setCourseAIChatHistory: (value: AIChatHistoryType[]) => void;
onOutsideClick?: () => void;
onUpgradeClick: () => void;
};
export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
const {
courseSlug,
moduleTitle,
lessonTitle,
onOutsideClick,
onUpgradeClick,
courseAIChatHistory,
setCourseAIChatHistory,
} = props;
const toast = useToast();
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollareaRef = useRef<HTMLDivElement | null>(null);
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [message, setMessage] = useState('');
const [streamedMessage, setStreamedMessage] = useState('');
useOutsideClick(containerRef, onOutsideClick);
const { data: tokenUsage, isLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmedMessage = message.trim();
if (
!trimmedMessage ||
isStreamingMessage ||
!isLoggedIn() ||
isLimitExceeded ||
isLoading
) {
return;
}
const newMessages: AIChatHistoryType[] = [
...courseAIChatHistory,
{
role: 'user',
content: trimmedMessage,
},
];
flushSync(() => {
setCourseAIChatHistory(newMessages);
setMessage('');
});
scrollToBottom();
completeCourseAIChat(newMessages);
};
const scrollToBottom = () => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior: 'smooth',
});
};
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
setIsStreamingMessage(true);
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-follow-up-ai-course/${courseSlug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
moduleTitle,
lessonTitle,
messages: messages.slice(-10),
}),
},
);
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setCourseAIChatHistory([...messages].slice(0, messages.length - 1));
setIsStreamingMessage(false);
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
if (!reader) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
await readAICourseLessonStream(reader, {
onStream: async (content) => {
flushSync(() => {
setStreamedMessage(content);
});
scrollToBottom();
},
onStreamEnd: async (content) => {
const newMessages: AIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
html: await markdownToHtmlWithHighlighting(content),
},
];
flushSync(() => {
setStreamedMessage('');
setIsStreamingMessage(false);
setCourseAIChatHistory(newMessages);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
});
setIsStreamingMessage(false);
};
useEffect(() => {
scrollToBottom();
}, []);
return (
<div
className="absolute bottom-0 left-0 z-[99] flex h-[500px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow"
ref={containerRef}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
<h4 className="text-base font-medium">Course AI</h4>
</div>
<div
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
ref={scrollareaRef}
>
<div className="absolute inset-0 flex flex-col">
<div className="flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
{courseAIChatHistory.map((chat, index) => {
return (
<>
<AIChatCard
key={`chat-${index}`}
role={chat.role}
content={chat.content}
html={chat.html}
/>
{chat.isDefault && (
<div className="mb-1 mt-0.5">
<div className="grid grid-cols-2 gap-2">
{capabilities.map((capability, index) => (
<CapabilityCard
key={`capability-${index}`}
{...capability}
/>
))}
</div>
</div>
)}
</>
);
})}
{isStreamingMessage && !streamedMessage && (
<AIChatCard role="assistant" content="Thinking..." />
)}
{streamedMessage && (
<AIChatCard role="assistant" content={streamedMessage} />
)}
</div>
</div>
</div>
</div>
<form
className="relative flex items-start border-t border-gray-200 text-sm"
onSubmit={handleChatSubmit}
>
{isLimitExceeded && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
<p className="cursor-not-allowed">Limit reached for today</p>
<button
onClick={() => {
onUpgradeClick();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
</div>
)}
<TextareaAutosize
className="h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none"
placeholder="Ask AI anything about the lesson..."
value={message}
onChange={(e) => setMessage(e.target.value)}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
}
}}
/>
<button
type="submit"
disabled={isStreamingMessage || isLimitExceeded}
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black"
>
<Send className="size-4 stroke-[2.5]" />
</button>
</form>
</div>
);
}
type AIChatCardProps = {
role: AllowedAIChatRole;
content: string;
html?: string;
};
function AIChatCard(props: AIChatCardProps) {
const { role, content, html: defaultHtml } = props;
const html = useMemo(() => {
if (defaultHtml) {
return defaultHtml;
}
return markdownToHtml(content, false);
}, [content, defaultHtml]);
return (
<div
className={cn(
'flex flex-col rounded-lg',
role === 'user' ? 'bg-gray-300/30' : 'bg-yellow-500/30',
)}
>
<div className="flex items-start gap-2.5 p-3">
<div
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
role === 'user'
? 'bg-gray-200 text-black'
: 'bg-yellow-400 text-black',
)}
>
<Bot className="size-4 stroke-[2.5]" />
</div>
<div
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
);
}
type CapabilityCardProps = {
icon: React.ReactNode;
title: string;
description: string;
className?: string;
};
function CapabilityCard({
icon,
title,
description,
className,
}: CapabilityCardProps) {
return (
<div
className={cn(
'flex flex-col gap-2 rounded-lg bg-yellow-500/10 p-3',
className,
)}
>
<div className="flex items-center gap-2">
{icon}
<span className="text-[13px] font-medium leading-none text-black">
{title}
</span>
</div>
<p className="text-[12px] leading-normal text-gray-600">{description}</p>
</div>
);
}
const capabilities = [
{
icon: (
<HelpCircle
className="size-4 shrink-0 text-yellow-600"
strokeWidth={2.5}
/>
),
title: 'Clarify Concepts',
description: "If you don't understand a concept, ask me to clarify it",
},
{
icon: (
<BookOpen className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />
),
title: 'More Details',
description: 'Get deeper insights about topics covered in the lesson',
},
{
icon: (
<Code className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />
),
title: 'Code Help',
description: 'Share your code and ask me to help you debug it',
},
{
icon: <Bot className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />,
title: 'Best Practices',
description: 'Share your code and ask me the best way to do something',
},
] as const;
@@ -0,0 +1,78 @@
import { useQuery } from '@tanstack/react-query';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { getPercentage } from '../../helper/number';
import { Gift, Info } from 'lucide-react';
type AICourseLimitProps = {
onUpgrade: () => void;
onShowLimits: () => void;
};
export function AICourseLimit(props: AICourseLimitProps) {
const { onUpgrade, onShowLimits } = props;
const { data: limits, isLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
if (isLoading || !limits || isBillingDetailsLoading || !userBillingDetails) {
return (
<div className="hidden h-[38px] w-[208.09px] animate-pulse rounded-lg border border-gray-200 bg-gray-200 lg:block"></div>
);
}
const { used, limit } = limits;
const totalPercentage = getPercentage(used, limit);
// has consumed 80% of the limit
const isNearLimit = used >= limit * 0.8;
const isPaidUser = userBillingDetails.status !== 'none';
return (
<>
<button
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
onClick={() => onShowLimits()}
>
<Info className="size-4" />
{totalPercentage}% limit used
</button>
{(!isPaidUser || isNearLimit) && (
<button
onClick={() => {
onShowLimits();
}}
className="relative hidden h-full min-h-[38px] cursor-pointer items-center overflow-hidden rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 lg:flex"
>
<span className="relative z-10">
{totalPercentage}% of the daily limit used
</span>
<div
className="absolute inset-0 h-full bg-gray-200/80"
style={{
width: `${totalPercentage}%`,
}}
></div>
</button>
)}
{!isPaidUser && (
<button
className="hidden items-center justify-center gap-1 rounded-md bg-yellow-400 px-4 py-1 text-sm font-medium underline-offset-2 hover:bg-yellow-500 lg:flex"
onClick={() => onUpgrade()}
>
<Gift className="size-4" />
Upgrade
</button>
)}
</>
);
}
@@ -0,0 +1,208 @@
import { type Dispatch, type SetStateAction, useState } from 'react';
import type { AiCourse } from '../../lib/ai';
import { Check, ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
import { getAiCourseProgressOptions } from '../../queries/ai-course';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { slugify } from '../../lib/slugger';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { CircularProgress } from './CircularProgress';
type AICourseModuleListProps = {
course: AiCourse;
courseSlug?: string;
activeModuleIndex: number | undefined;
setActiveModuleIndex: (index: number) => void;
activeLessonIndex: number | undefined;
setActiveLessonIndex: (index: number) => void;
setSidebarOpen: (open: boolean) => void;
viewMode: 'module' | 'full';
setViewMode: (mode: 'module' | 'full') => void;
expandedModules: Record<number, boolean>;
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;
isLoading: boolean;
};
export function AICourseModuleList(props: AICourseModuleListProps) {
const {
course,
courseSlug,
activeModuleIndex,
setActiveModuleIndex,
activeLessonIndex,
setActiveLessonIndex,
setSidebarOpen,
setViewMode,
expandedModules,
setExpandedModules,
isLoading,
} = props;
const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
const toggleModule = (index: number) => {
setExpandedModules((prev) => {
// If this module is already expanded, collapse it
if (prev[index]) {
return {
...prev,
[index]: false,
};
}
// Otherwise, collapse all modules and expand only this one
const newState: Record<number, boolean> = {};
// Set all modules to collapsed
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
// Expand only the clicked module
newState[index] = true;
return newState;
});
};
const { done = [] } = aiCourseProgress || {};
return (
<nav className="bg-gray-100">
{course.modules.map((courseModule, moduleIdx) => {
const totalLessons = courseModule.lessons.length;
const completedLessons = courseModule.lessons.filter((lesson) => {
const key = `${slugify(courseModule.title)}__${slugify(lesson)}`;
return done.includes(key);
}).length;
const percentage = Math.round((completedLessons / totalLessons) * 100);
const isActive = expandedModules[moduleIdx];
const isModuleCompleted = completedLessons === totalLessons;
return (
<div key={moduleIdx} className="rounded-md">
<button
onClick={() => toggleModule(moduleIdx)}
className={cn(
'relative z-10 flex w-full cursor-pointer flex-row items-center gap-2 border-b border-b-gray-200 bg-white px-2 py-3 text-base text-gray-600 hover:bg-gray-100',
activeModuleIndex === moduleIdx
? 'text-gray-900'
: 'text-gray-700',
moduleIdx === 0 && 'pt-4',
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex-shrink-0">
<CircularProgress
percentage={percentage}
isVisible={!isModuleCompleted}
isActive={isActive}
isLoading={isLoading}
>
<span
className={cn(
'flex size-[21px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white',
{
'bg-black': isActive,
'bg-green-600': isModuleCompleted,
},
)}
>
{!isModuleCompleted && moduleIdx + 1}
{isModuleCompleted && (
<Check className="size-3 stroke-[3] text-white" />
)}
</span>
</CircularProgress>
</div>
<span className="flex flex-1 items-center break-words text-left text-sm leading-relaxed">
{courseModule.title?.replace(/^Module\s*?\d+[\.:]\s*/, '')}
</span>
</div>
<div className="ml-auto self-center">
{expandedModules[moduleIdx] ? (
<ChevronDownIcon size={16} className="flex-shrink-0" />
) : (
<ChevronRightIcon size={16} className="flex-shrink-0" />
)}
</div>
</button>
{/* Lessons */}
{expandedModules[moduleIdx] && (
<div className="flex flex-col border-b border-b-gray-200 bg-gray-100">
{courseModule.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(courseModule.title)}__${slugify(lesson)}`;
const isCompleted = done.includes(key);
return (
<button
key={key}
onClick={() => {
setActiveModuleIndex(moduleIdx);
setActiveLessonIndex(lessonIdx);
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
newState[moduleIdx] = true;
return newState;
});
setSidebarOpen(false);
setViewMode('module');
}}
className={cn(
'flex gap-2.5 w-full cursor-pointer items-center py-3 pl-3.5 pr-2 text-left text-sm leading-normal',
activeModuleIndex === moduleIdx &&
activeLessonIndex === lessonIdx
? 'bg-gray-200 text-black'
: 'text-gray-600 hover:bg-gray-200/70',
)}
>
{isCompleted ? (
<CheckIcon
additionalClasses={cn(
'size-[18px] relative bg-white rounded-full top-[2px] flex-shrink-0 text-green-600',
{
'text-black':
activeModuleIndex === moduleIdx &&
activeLessonIndex === lessonIdx,
},
)}
/>
) : (
<span
className={cn(
'flex size-[18px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white',
{
'bg-black':
activeModuleIndex === moduleIdx &&
activeLessonIndex === lessonIdx,
},
)}
>
{lessonIdx + 1}
</span>
)}
<span className="break-words">
{lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</nav>
);
}
@@ -0,0 +1,344 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import {
CheckIcon,
ChevronLeft,
ChevronRight,
Loader2Icon,
LockIcon,
XIcon,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { readAICourseLessonStream } from '../../helper/read-stream';
import { cn } from '../../lib/classname';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import {
markdownToHtml,
markdownToHtmlWithHighlighting,
} from '../../lib/markdown';
import { httpPatch } from '../../lib/query-http';
import { slugify } from '../../lib/slugger';
import {
getAiCourseLimitOptions,
getAiCourseProgressOptions,
type AICourseProgressDocument,
} from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { AICourseFollowUp } from './AICourseFollowUp';
import './AICourseFollowUp.css';
type AICourseModuleViewProps = {
courseSlug: string;
activeModuleIndex: number;
totalModules: number;
currentModuleTitle: string;
activeLessonIndex: number;
totalLessons: number;
currentLessonTitle: string;
onGoToPrevLesson: () => void;
onGoToNextLesson: () => void;
onUpgrade: () => void;
};
export function AICourseModuleView(props: AICourseModuleViewProps) {
const {
courseSlug,
activeModuleIndex,
totalModules,
currentModuleTitle,
activeLessonIndex,
totalLessons,
currentLessonTitle,
onGoToPrevLesson,
onGoToNextLesson,
onUpgrade,
} = props;
const [isLoading, setIsLoading] = useState(true);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState('');
const [lessonHtml, setLessonHtml] = useState('');
const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`;
const isLessonDone = aiCourseProgress?.done.includes(lessonId);
const abortController = useMemo(
() => new AbortController(),
[activeModuleIndex, activeLessonIndex],
);
const generateAiCourseContent = async () => {
setIsLoading(true);
setError('');
setLessonHtml('');
if (!isLoggedIn()) {
setIsLoading(false);
setError('Please login to generate course content');
return;
}
if (!currentModuleTitle || !currentLessonTitle) {
setIsLoading(false);
setError('Invalid module title or lesson title');
return;
}
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-lesson/${courseSlug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal,
credentials: 'include',
body: JSON.stringify({
moduleTitle: currentModuleTitle,
lessonTitle: currentLessonTitle,
modulePosition: activeModuleIndex,
lessonPosition: activeLessonIndex,
totalLessonsInModule: totalLessons,
}),
},
);
if (!response.ok) {
const data = await response.json();
setError(data?.message || 'Something went wrong');
setIsLoading(false);
// Logout user if token is invalid
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
if (!reader) {
setIsLoading(false);
setError('Something went wrong');
return;
}
setIsLoading(false);
setIsGenerating(true);
await readAICourseLessonStream(reader, {
onStream: async (result) => {
if (abortController.signal.aborted) {
return;
}
setLessonHtml(markdownToHtml(result, false));
},
onStreamEnd: async (result) => {
if (abortController.signal.aborted) {
return;
}
setLessonHtml(await markdownToHtmlWithHighlighting(result));
queryClient.invalidateQueries(getAiCourseLimitOptions());
setIsGenerating(false);
},
});
};
const { mutate: toggleDone, isPending: isTogglingDone } = useMutation(
{
mutationFn: () => {
return httpPatch<AICourseProgressDocument>(
`/v1-toggle-done-ai-lesson/${courseSlug}`,
{
lessonId,
},
);
},
onSuccess: (data) => {
queryClient.setQueryData(
['ai-course-progress', { aiCourseSlug: courseSlug }],
data,
);
},
},
queryClient,
);
useEffect(() => {
generateAiCourseContent();
}, [currentModuleTitle, currentLessonTitle]);
useEffect(() => {
return () => {
abortController.abort();
};
}, [abortController]);
const cantGoForward =
(activeModuleIndex === totalModules - 1 &&
activeLessonIndex === totalLessons - 1) ||
isGenerating ||
isLoading;
const cantGoBack =
(activeModuleIndex === 0 && activeLessonIndex === 0) || isGenerating;
return (
<div className="mx-auto max-w-4xl">
<div className="relative rounded-lg border border-gray-200 bg-white p-6 shadow-sm max-lg:px-4 max-lg:pb-4 max-lg:pt-3">
{(isGenerating || isLoading) && (
<div className="absolute right-3 top-3 flex items-center justify-center">
<Loader2Icon
size={18}
strokeWidth={3}
className="animate-spin text-gray-400/70"
/>
</div>
)}
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-500">
Lesson {activeLessonIndex + 1} of {totalLessons}
</div>
{!isGenerating && !isLoading && (
<>
<button
disabled={isLoading || isTogglingDone}
className={cn(
'absolute right-3 top-3 flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs',
isLessonDone
? 'bg-red-500 hover:bg-red-600'
: 'bg-green-500 hover:bg-green-600',
)}
onClick={() => toggleDone()}
>
{isTogglingDone ? (
<>
<Loader2Icon
size={16}
strokeWidth={3}
className="animate-spin text-white"
/>
Please wait ...
</>
) : (
<>
{isLessonDone ? (
<>
<XIcon size={16} />
Mark as Undone
</>
) : (
<>
<CheckIcon size={16} />
Mark as Done
</>
)}
</>
)}
</button>
</>
)}
</div>
<h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl">
{currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</h1>
{!error && isLoggedIn() && (
<div
className="course-content prose prose-lg mt-8 max-w-full text-black 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 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm"
dangerouslySetInnerHTML={{ __html: lessonHtml }}
/>
)}
{error && isLoggedIn() && (
<div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80">
{error.includes('reached the limit') ? (
<div className="flex max-w-sm flex-col items-center text-center">
<h2 className="text-xl font-semibold text-red-600">
Limit reached
</h2>
<p className="my-3 text-red-600">
You have reached the AI usage limit for today. Please upgrade
your account to continue.
</p>
<button
onClick={() => {
onUpgrade();
}}
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
>
Upgrade Account
</button>
</div>
) : (
<p className="text-red-600">{error}</p>
)}
</div>
)}
{!isLoggedIn() && (
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8">
<LockIcon className="size-7 stroke-[2] text-gray-400/90" />
<p className="text-sm text-gray-500">
Please login to generate course content
</p>
</div>
)}
<div className="mt-8 flex items-center justify-between">
<button
onClick={onGoToPrevLesson}
disabled={cantGoBack}
className={cn(
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
cantGoBack
? 'cursor-not-allowed text-gray-400'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
)}
>
<ChevronLeft size={16} className="mr-2" />
Previous <span className="hidden lg:inline">&nbsp;Lesson</span>
</button>
<button
onClick={onGoToNextLesson}
disabled={cantGoForward}
className={cn(
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
cantGoForward
? 'cursor-not-allowed text-gray-400'
: 'bg-gray-800 text-white hover:bg-gray-700',
)}
>
Next <span className="hidden lg:inline">&nbsp;Lesson</span>
<ChevronRight size={16} className="ml-2" />
</button>
</div>
</div>
{!isGenerating && !isLoading && (
<AICourseFollowUp
courseSlug={courseSlug}
moduleTitle={currentModuleTitle}
lessonTitle={currentLessonTitle}
/>
)}
</div>
);
}
@@ -0,0 +1,103 @@
import { Gift } from 'lucide-react';
import { Modal } from '../Modal';
import { formatCommaNumber } from '../../lib/number';
import { billingDetailsOptions } from '../../queries/billing';
import { queryClient } from '../../stores/query-client';
import { useQuery } from '@tanstack/react-query';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
type AILimitsPopupProps = {
onClose: () => void;
onUpgrade: () => void;
};
export function AILimitsPopup(props: AILimitsPopupProps) {
const { onClose, onUpgrade } = props;
const { data: limits, isLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { used, limit } = limits ?? { used: 0, limit: 0 };
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status !== 'none';
return (
<Modal
onClose={onClose}
wrapperClassName="rounded-xl max-w-xl w-full h-auto"
bodyClassName="p-6"
overlayClassName="items-start md:items-center"
>
<h2 className="mb-8 text-center text-xl font-semibold">
Daily AI Limits
</h2>
{/* Usage Progress Bar */}
<div className="mb-6">
<div className="mb-2 flex justify-between">
<span className="text-sm font-medium">
Usage: {formatCommaNumber(used)}&nbsp;/&nbsp;
{formatCommaNumber(limit)} tokens
</span>
<span className="text-sm font-medium">
{Math.round((used / limit) * 100)}%
</span>
</div>
<div className="h-2.5 w-full rounded-full bg-gray-200">
<div
className="h-2.5 rounded-full bg-yellow-500"
style={{ width: `${Math.min(100, (used / limit) * 100)}%` }}
></div>
</div>
</div>
{/* Usage Stats */}
<div className="rounded-lg bg-gray-50 p-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">Used Today</p>
<p className="text-2xl font-bold">{formatCommaNumber(used)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Daily Limit</p>
<p className="text-2xl font-bold">{formatCommaNumber(limit)}</p>
</div>
</div>
</div>
{/* Explanation */}
<div className="mt-2">
<div className="space-y-3 text-gray-600">
<p className="text-sm">
Limit resets every 24 hours.{' '}
{!isPaidUser && 'Consider upgrading for more tokens.'}
</p>
</div>
</div>
{/* Action Button */}
<div className="mt-auto flex flex-col gap-2 pt-4">
{!isPaidUser && (
<button
onClick={onUpgrade}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-yellow-400 px-4 py-2.5 text-sm font-medium text-black transition-colors hover:bg-yellow-500"
>
<Gift className="size-4" />
Upgrade to Unlimited
</button>
)}
<button
onClick={onClose}
className="w-full rounded-lg bg-gray-200 px-4 py-2.5 text-sm text-gray-600 transition-colors hover:bg-gray-300"
>
Close
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,57 @@
import { cn } from '../../lib/classname';
export function ChapterNumberSkeleton() {
return (
<div className="h-[28px] w-[28px] animate-pulse rounded-full bg-gray-200" />
);
}
type CircularProgressProps = {
percentage: number;
children: React.ReactNode;
isVisible?: boolean;
isActive?: boolean;
isLoading?: boolean;
};
export function CircularProgress(props: CircularProgressProps) {
const {
percentage,
children,
isVisible = true,
isActive = false,
isLoading = false,
} = props;
const circumference = 2 * Math.PI * 13;
const strokeDasharray = `${circumference}`;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
return (
<div className="relative flex h-[28px] w-[28px] flex-shrink-0 items-center justify-center">
{isVisible && !isLoading && (
<svg className="absolute h-full w-full -rotate-90">
<circle
cx="14"
cy="14"
r="13"
stroke="currentColor"
strokeWidth="1.75"
fill="none"
className={cn('text-gray-400/70', {
'text-black': isActive,
})}
style={{
strokeDasharray,
strokeDashoffset,
transition: 'stroke-dashoffset 0.3s ease',
}}
/>
</svg>
)}
{!isLoading && children}
{isLoading && <ChapterNumberSkeleton />}
</div>
);
}
@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { generateAiCourseStructure, type AiCourse } from '../../lib/ai';
import { readAICourseStream } from '../../helper/read-stream';
import { AICourseContent } from './AICourseContent';
import { queryClient } from '../../stores/query-client';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
type GenerateAICourseProps = {};
export function GenerateAICourse(props: GenerateAICourseProps) {
const [term, setTerm] = useState('');
const [difficulty, setDifficulty] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [courseId, setCourseId] = useState('');
const [courseSlug, setCourseSlug] = useState('');
const [course, setCourse] = useState<AiCourse>({
title: '',
modules: [],
difficulty: '',
});
useEffect(() => {
if (term || difficulty) {
return;
}
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDifficulty = params?.difficulty;
if (!paramsTerm || !paramsDifficulty) {
return;
}
setTerm(paramsTerm);
setDifficulty(paramsDifficulty);
generateCourse({ term: paramsTerm, difficulty: paramsDifficulty });
}, [term, difficulty]);
const generateCourse = async (options: {
term: string;
difficulty: string;
}) => {
const { term, difficulty } = options;
if (!isLoggedIn()) {
window.location.href = '/ai-tutor';
return;
}
setIsLoading(true);
setCourse({
title: '',
modules: [],
difficulty: '',
});
setError('');
try {
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: term,
difficulty,
}),
credentials: 'include',
},
);
if (!response.ok) {
const data = await response.json();
console.error(
'Error generating course:',
data?.message || 'Something went wrong',
);
setIsLoading(false);
setError(data?.message || 'Something went wrong');
return;
}
const reader = response.body?.getReader();
if (!reader) {
console.error('Failed to get reader from response');
setError('Something went wrong');
setIsLoading(false);
return;
}
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@');
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/);
await readAICourseStream(reader, {
onStream: (result) => {
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
const courseIdMatch = result.match(COURSE_ID_REGEX);
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
const extractedCourseId = courseIdMatch?.[1] || '';
const extractedCourseSlug = courseSlugMatch?.[1] || '';
if (extractedCourseSlug) {
window.history.replaceState(
{
courseId,
courseSlug: extractedCourseSlug,
term,
difficulty,
},
'',
`${origin}/ai-tutor/${extractedCourseSlug}`,
);
}
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
setCourseId(extractedCourseId);
setCourseSlug(extractedCourseSlug);
}
try {
const aiCourse = generateAiCourseStructure(result);
setCourse({
...aiCourse,
difficulty: difficulty || '',
});
} catch (e) {
console.error('Error parsing streamed course content:', e);
}
},
onStreamEnd: (result) => {
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
setIsLoading(false);
queryClient.invalidateQueries(getAiCourseLimitOptions());
},
});
} catch (error: any) {
setError(error?.message || 'Something went wrong');
console.error('Error in course generation:', error);
setIsLoading(false);
}
};
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
const { courseId, courseSlug, term, difficulty } = e.state || {};
if (!courseId || !courseSlug) {
window.location.reload();
return;
}
setCourseId(courseId);
setCourseSlug(courseSlug);
setTerm(term);
setDifficulty(difficulty);
setIsLoading(true);
generateCourse({ term, difficulty }).finally(() => {
setIsLoading(false);
});
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
return (
<AICourseContent
courseSlug={courseSlug}
course={course}
isLoading={isLoading}
error={error}
/>
);
}
@@ -0,0 +1,65 @@
import { useQuery } from '@tanstack/react-query';
import { getAiCourseOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { useEffect, useState } from 'react';
import { AICourseContent } from './AICourseContent';
import { generateAiCourseStructure } from '../../lib/ai';
import { isLoggedIn } from '../../lib/jwt';
type GetAICourseProps = {
courseSlug: string;
};
export function GetAICourse(props: GetAICourseProps) {
const { courseSlug } = props;
const [isLoading, setIsLoading] = useState(true);
const { data: aiCourse, error } = useQuery(
{
...getAiCourseOptions({ aiCourseSlug: courseSlug }),
select: (data) => {
return {
...data,
course: generateAiCourseStructure(data.data),
};
},
enabled: !!courseSlug && !!isLoggedIn(),
},
queryClient,
);
useEffect(() => {
if (!isLoggedIn()) {
window.location.href = '/ai-tutor';
}
}, [isLoggedIn]);
useEffect(() => {
if (!aiCourse) {
return;
}
setIsLoading(false);
}, [aiCourse]);
useEffect(() => {
if (!error) {
return;
}
setIsLoading(false);
}, [error]);
return (
<AICourseContent
course={{
title: aiCourse?.title || '',
modules: aiCourse?.course.modules || [],
difficulty: aiCourse?.difficulty || 'Easy',
}}
isLoading={isLoading}
courseSlug={courseSlug}
error={error?.message}
/>
);
}
@@ -0,0 +1,180 @@
import { useQuery } from '@tanstack/react-query';
import {
getAiCourseLimitOptions,
listUserAiCoursesOptions,
} from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { AICourseCard } from './AICourseCard';
import { useEffect, useState } from 'react';
import { Gift, Loader2, Search, User2 } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname';
import { billingDetailsOptions } from '../../queries/billing';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
type UserCoursesListProps = {};
export function UserCoursesList(props: UserCoursesListProps) {
const [searchTerm, setSearchTerm] = useState('');
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const { data: limits, isLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { used, limit } = limits ?? { used: 0, limit: 0 };
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status !== 'none';
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
listUserAiCoursesOptions(),
queryClient,
);
useEffect(() => {
setIsInitialLoading(false);
}, [userAiCourses]);
const filteredCourses = userAiCourses?.filter((course) => {
if (!searchTerm.trim()) {
return true;
}
const searchLower = searchTerm.toLowerCase();
return (
course.title.toLowerCase().includes(searchLower) ||
course.keyword.toLowerCase().includes(searchLower)
);
});
const isAuthenticated = isLoggedIn();
const canSearch =
!isInitialLoading &&
!isUserAiCoursesLoading &&
isAuthenticated &&
userAiCourses?.length !== 0;
const limitUsedPercentage = Math.round((used / limit) * 100);
return (
<>
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
<span className='max-md:hidden'>Your </span>Courses
</h2>
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex items-center gap-2 opacity-0 transition-opacity',
{
'opacity-100': !isPaidUser,
},
)}
>
<p className="flex items-center text-sm text-yellow-600">
<span className="max-md:hidden">
{limitUsedPercentage}% of daily limit used{' '}
</span>
<span className="inline md:hidden">
{limitUsedPercentage}% used
</span>
<button
onClick={() => {
setShowUpgradePopup(true);
}}
className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pl-1.5 pr-2 text-xs text-white"
>
<Gift className="size-4" />
Upgrade
</button>
</p>
</div>
<div className={cn('relative w-64 max-sm:hidden', {})}>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search className="h-4 w-4 text-gray-400" />
</div>
<input
type="text"
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 transition-all focus:border-gray-300 focus:outline-none focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
placeholder="Search your courses..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
{!isInitialLoading && !isUserAiCoursesLoading && !isAuthenticated && (
<div className="flex min-h-[152px] flex-col items-center justify-center rounded-lg border border-gray-200 bg-white px-6 py-4">
<User2 className="mb-2 size-8 text-gray-300" />
<p className="max-w-sm text-balance text-center text-gray-500">
<button
onClick={() => {
showLoginPopup();
}}
className="font-medium text-black underline underline-offset-2 hover:opacity-80"
>
Sign up (free and takes 2s) or login
</button>{' '}
to generate and save courses.
</p>
</div>
)}
{!isUserAiCoursesLoading &&
!isInitialLoading &&
userAiCourses?.length === 0 && (
<div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
<p className="text-sm text-gray-600">
You haven't generated any courses yet.
</p>
</div>
)}
{(isUserAiCoursesLoading || isInitialLoading) && (
<div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4">
<Loader2
className="size-4 animate-spin text-gray-400"
strokeWidth={2.5}
/>
<p className="text-sm font-medium text-gray-600">Loading...</p>
</div>
)}
{!isUserAiCoursesLoading &&
filteredCourses &&
filteredCourses.length > 0 && (
<div className="flex flex-col gap-2">
{filteredCourses.map((course) => (
<AICourseCard key={course._id} course={course} />
))}
</div>
)}
{!isUserAiCoursesLoading &&
(userAiCourses?.length || 0 > 0) &&
filteredCourses?.length === 0 && (
<div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
<p className="text-sm text-gray-600">
No courses match your search.
</p>
</div>
)}
</>
);
}