feat: add course catalog, enrollment, progress tracking, quizzes, and reviews

Backend changes:
- Add Enrollment and LessonProgress models to track user progress
- Add UserRole enum (USER, MODERATOR, ADMIN)
- Add course verification and moderation fields
- New CatalogModule: public course browsing, publishing, verification
- New EnrollmentModule: enroll, progress tracking, quiz submission, reviews
- Add quiz generation endpoint to LessonsController

Frontend changes:
- Redesign course viewer: proper course UI with lesson navigation, progress bar
- Add beautiful typography styles for course content (prose-course)
- Fix first-login bug with token exchange retry logic
- New pages: /catalog (public courses), /catalog/[id] (course details), /learning (enrollments)
- Add LessonQuiz component with scoring and results
- Update sidebar navigation: add Catalog and My Learning links
- Add publish/verify buttons in course editor
- Integrate enrollment progress tracking with backend

All courses now support: sequential progression, quiz tests, reviews, ratings,
author verification badges, and full marketplace publishing workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-06 10:44:05 +00:00
parent dab726e8d1
commit 2ed65f5678
23 changed files with 1796 additions and 78 deletions

View File

@ -0,0 +1,297 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
ArrowLeft,
BookOpen,
Clock,
Users,
Star,
Shield,
Check,
Loader2,
ChevronDown,
} from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { useToast } from '@/components/ui/use-toast';
import { cn } from '@/lib/utils';
export default function PublicCoursePage() {
const params = useParams();
const router = useRouter();
const { toast } = useToast();
const { user } = useAuth();
const id = params?.id as string;
const [course, setCourse] = useState<any>(null);
const [reviews, setReviews] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [enrolling, setEnrolling] = useState(false);
const [enrolled, setEnrolled] = useState(false);
useEffect(() => {
if (!id) return;
(async () => {
setLoading(true);
try {
const [courseData, reviewsData] = await Promise.all([
api.getPublicCourse(id),
api.getCourseReviews(id).catch(() => ({ data: [] })),
]);
setCourse(courseData);
setReviews(reviewsData.data || []);
// Check if already enrolled
if (user) {
const enrollments = await api.getMyEnrollments().catch(() => []);
setEnrolled(enrollments.some((e: any) => e.course.id === id));
}
} catch {
setCourse(null);
} finally {
setLoading(false);
}
})();
}, [id, user]);
const handleEnroll = async () => {
if (!id || enrolling) return;
setEnrolling(true);
try {
await api.enrollInCourse(id);
toast({ title: 'Успех', description: 'Вы записались на курс' });
router.push(`/dashboard/courses/${id}`);
} catch (e: any) {
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
} finally {
setEnrolling(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!course) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Курс не найден</p>
<Link href="/dashboard/catalog" className="text-primary hover:underline mt-2 inline-block">
Вернуться к каталогу
</Link>
</div>
);
}
const totalLessons = course.chapters.reduce((acc: number, ch: any) => acc + ch.lessons.length, 0);
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard/catalog">
<ArrowLeft className="mr-2 h-4 w-4" />
К каталогу
</Link>
</Button>
{/* Course header */}
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-4">
{/* Cover */}
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 overflow-hidden">
{course.coverImage ? (
<img src={course.coverImage} alt={course.title} className="w-full h-full object-cover" />
) : (
<div className="flex items-center justify-center h-full">
<BookOpen className="h-16 w-16 text-primary/30" />
</div>
)}
</div>
{/* Title & meta */}
<div>
<div className="flex items-center gap-2 mb-2">
{course.isVerified && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
<Shield className="h-3 w-3" />
Проверен автором
</div>
)}
{course.difficulty && (
<span className="px-2 py-0.5 rounded-full bg-muted text-xs font-medium">
{course.difficulty === 'beginner' ? 'Начинающий' : course.difficulty === 'intermediate' ? 'Средний' : 'Продвинутый'}
</span>
)}
</div>
<h1 className="text-3xl font-bold">{course.title}</h1>
<p className="text-muted-foreground mt-2">{course.description}</p>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
{course.averageRating && (
<div className="flex items-center gap-1 text-yellow-600 font-medium">
<Star className="h-4 w-4 fill-current" />
{course.averageRating.toFixed(1)} ({course._count.reviews} отзывов)
</div>
)}
<span className="flex items-center gap-1 text-muted-foreground">
<Users className="h-4 w-4" />
{course._count.enrollments} студентов
</span>
<span className="text-muted-foreground">{totalLessons} уроков</span>
{course.estimatedHours && (
<span className="flex items-center gap-1 text-muted-foreground">
<Clock className="h-4 w-4" />
{course.estimatedHours}ч
</span>
)}
</div>
<div className="h-px bg-border my-4" />
{/* Author */}
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary">
{course.author.name?.[0] || 'A'}
</div>
<div>
<p className="text-sm font-medium">{course.author.name || 'Автор'}</p>
<p className="text-xs text-muted-foreground">Преподаватель</p>
</div>
</div>
</div>
{/* Sidebar: Enroll / Price */}
<div className="md:col-span-1">
<Card className="sticky top-4">
<CardContent className="p-6 space-y-4">
<div>
<p className="text-3xl font-bold">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</p>
</div>
{enrolled ? (
<Button className="w-full" asChild>
<Link href={`/dashboard/courses/${id}`}>Продолжить обучение</Link>
</Button>
) : (
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{course.price ? 'Купить курс' : 'Записаться'}
</Button>
)}
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>{course.chapters.length} глав</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>{totalLessons} видеоуроков</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>Сертификат по окончании</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>Пожизненный доступ</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Course content (chapters & lessons) */}
<Card>
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-4">Содержание курса</h2>
<div className="space-y-2">
{course.chapters.map((chapter: any) => {
const [expanded, setExpanded] = useState(false);
return (
<div key={chapter.id} className="border rounded-lg overflow-hidden">
<button
className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">{chapter.title}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{chapter.lessons.length} уроков</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', expanded && 'rotate-180')} />
</div>
</button>
{expanded && (
<div className="border-t bg-muted/20 p-2">
{chapter.lessons.map((lesson: any) => (
<div key={lesson.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<Check className="h-3.5 w-3.5 text-muted-foreground" />
<span>{lesson.title}</span>
{lesson.durationMinutes && (
<span className="ml-auto text-xs text-muted-foreground">{lesson.durationMinutes} мин</span>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Reviews */}
{reviews.length > 0 && (
<Card>
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-4">Отзывы студентов</h2>
<div className="space-y-4">
{reviews.map((review: any) => (
<div key={review.id} className="border-b last:border-b-0 pb-4 last:pb-0">
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary text-sm">
{review.user.name?.[0] || 'U'}
</div>
<div className="flex-1">
<p className="font-medium text-sm">{review.user.name || 'Пользователь'}</p>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
'h-3.5 w-3.5',
i < review.rating ? 'text-yellow-500 fill-current' : 'text-muted-foreground/30'
)}
/>
))}
</div>
</div>
</div>
{review.title && <p className="font-medium text-sm mb-1">{review.title}</p>}
{review.content && <p className="text-sm text-muted-foreground">{review.content}</p>}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,185 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Search, Star, Users, BookOpen, Shield, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { cn } from '@/lib/utils';
interface CatalogCourse {
id: string;
title: string;
description: string | null;
coverImage: string | null;
difficulty: string | null;
price: number | null;
currency: string;
isVerified: boolean;
averageRating: number | null;
enrollmentCount: number;
reviewCount: number;
chaptersCount: number;
lessonsCount: number;
author: { id: string; name: string | null; avatarUrl: string | null };
}
const difficultyLabels: Record<string, { label: string; color: string }> = {
beginner: { label: 'Начинающий', color: 'text-green-600 bg-green-50 dark:bg-green-900/30' },
intermediate: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30' },
advanced: { label: 'Продвинутый', color: 'text-red-600 bg-red-50 dark:bg-red-900/30' },
};
export default function CatalogPage() {
const { loading: authLoading } = useAuth();
const [courses, setCourses] = useState<CatalogCourse[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [difficulty, setDifficulty] = useState('');
const loadCourses = async () => {
setLoading(true);
try {
const result = await api.getCatalog({
search: searchQuery || undefined,
difficulty: difficulty || undefined,
});
setCourses(result.data);
} catch {
// silent
} finally {
setLoading(false);
}
};
useEffect(() => {
if (authLoading) return;
loadCourses();
}, [authLoading, difficulty]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
loadCourses();
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Каталог курсов</h1>
<p className="text-muted-foreground mt-1">Изучайте курсы от других авторов</p>
</div>
{/* Search & filters */}
<div className="flex flex-col sm:flex-row gap-3">
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Поиск курсов..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<Button type="submit" size="sm">Найти</Button>
</form>
<div className="flex gap-2">
{['', 'beginner', 'intermediate', 'advanced'].map((d) => (
<Button
key={d}
variant={difficulty === d ? 'default' : 'outline'}
size="sm"
onClick={() => setDifficulty(d)}
>
{d ? difficultyLabels[d]?.label : 'Все'}
</Button>
))}
</div>
</div>
{/* Courses grid */}
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : courses.length === 0 ? (
<div className="text-center py-20">
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground">Пока нет опубликованных курсов</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => (
<Link key={course.id} href={`/dashboard/catalog/${course.id}`}>
<Card className="group overflow-hidden transition-all hover:shadow-lg hover:-translate-y-0.5 cursor-pointer h-full">
{/* Cover image */}
<div className="aspect-video bg-gradient-to-br from-primary/20 to-primary/5 relative overflow-hidden">
{course.coverImage ? (
<img src={course.coverImage} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<BookOpen className="h-10 w-10 text-primary/30" />
</div>
)}
{course.isVerified && (
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
<Shield className="h-3 w-3" />
Проверен
</div>
)}
{course.difficulty && difficultyLabels[course.difficulty] && (
<div className={cn(
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-xs font-medium',
difficultyLabels[course.difficulty].color
)}>
{difficultyLabels[course.difficulty].label}
</div>
)}
</div>
<CardContent className="p-4">
<h3 className="font-semibold line-clamp-1 group-hover:text-primary transition-colors">
{course.title}
</h3>
{course.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">{course.description}</p>
)}
{/* Author */}
<p className="text-xs text-muted-foreground mt-3">
{course.author.name || 'Автор'}
</p>
{/* Stats */}
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
{course.averageRating && (
<span className="flex items-center gap-0.5 text-yellow-600 font-medium">
<Star className="h-3.5 w-3.5 fill-current" />
{course.averageRating.toFixed(1)}
</span>
)}
<span className="flex items-center gap-0.5">
<Users className="h-3.5 w-3.5" />
{course.enrollmentCount}
</span>
<span>{course.lessonsCount} уроков</span>
</div>
{/* Price */}
<div className="mt-3 pt-3 border-t">
<span className="font-bold text-lg">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}