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:
1 parent
faf43f7905
commit
cb64894e49
39 files changed
+3906
-25
No files matched your search
@@ -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? </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"> 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"> 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)} /
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user