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:
297
apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx
Normal file
297
apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx
Normal file
185
apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user