feat: phase1 platform upgrade with moderation, dev payments, admin panel and landing updates

This commit is contained in:
root
2026-02-06 17:26:53 +00:00
parent 4ca66ea896
commit 979adb9d3d
54 changed files with 2687 additions and 318 deletions

View File

@ -4,15 +4,20 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import {
CheckSquare,
ChevronLeft,
ChevronRight,
Eye,
FileArchive,
FileText,
FolderOpen,
ListChecks,
Layers3,
Lock,
Save,
Settings2,
Shield,
Sparkles,
Upload,
Wallet,
} from 'lucide-react';
@ -35,7 +40,7 @@ type CourseData = {
coverImage?: string | null;
price?: number | null;
currency?: string;
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
status: 'DRAFT' | 'PENDING_MODERATION' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
moderationNote?: string | null;
difficulty?: string | null;
estimatedHours?: number | null;
@ -43,12 +48,15 @@ type CourseData = {
chapters: Chapter[];
};
type EditTab = 'general' | 'content' | 'pricing' | 'settings' | 'access';
type EditTab = 'general' | 'content' | 'quiz' | 'homework' | 'materials' | 'pricing' | 'settings' | 'access';
const emptyDoc = { type: 'doc', content: [] };
const tabs: { key: EditTab; label: string; icon: any }[] = [
{ key: 'general', label: 'Общая информация', icon: FileText },
{ key: 'content', label: 'Контент', icon: Layers3 },
{ key: 'quiz', label: 'Тест', icon: CheckSquare },
{ key: 'homework', label: 'Домашнее задание', icon: ListChecks },
{ key: 'materials', label: 'Доп. материалы', icon: FolderOpen },
{ key: 'pricing', label: 'Цены', icon: Wallet },
{ key: 'settings', label: 'Настройки', icon: Settings2 },
{ key: 'access', label: 'Доступ', icon: Lock },
@ -84,6 +92,13 @@ export default function CourseEditPage() {
const [courseDifficulty, setCourseDifficulty] = useState('');
const [courseEstimatedHours, setCourseEstimatedHours] = useState('');
const [courseTags, setCourseTags] = useState('');
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
const [quizGenerated, setQuizGenerated] = useState(false);
const [materials, setMaterials] = useState<any[]>([]);
const [outlineHints, setOutlineHints] = useState<any[]>([]);
const [uploadingSource, setUploadingSource] = useState(false);
const [generatingHomework, setGeneratingHomework] = useState(false);
const [generatingQuiz, setGeneratingQuiz] = useState(false);
useEffect(() => {
if (!courseId || authLoading) return;
@ -156,6 +171,11 @@ export default function CourseEditPage() {
};
}, [courseId, activeLesson?.lessonId]);
useEffect(() => {
if (activeTab !== 'materials') return;
loadMaterials();
}, [activeTab, courseId]);
const handleSelectLesson = (lessonId: string) => {
if (!course) return;
for (const chapter of course.chapters) {
@ -233,6 +253,65 @@ export default function CourseEditPage() {
}
};
const handleGenerateQuiz = async () => {
if (!courseId || !activeLesson) return;
setGeneratingQuiz(true);
try {
await api.getLessonQuiz(courseId, activeLesson.lessonId);
setQuizGenerated(true);
toast({ title: 'Готово', description: 'Тест для урока сгенерирован или обновлён' });
} catch (e: any) {
toast({ title: 'Ошибка', description: e.message || 'Не удалось сгенерировать тест', variant: 'destructive' });
} finally {
setGeneratingQuiz(false);
}
};
const handleGenerateHomework = async () => {
if (!courseId || !activeLesson) return;
setGeneratingHomework(true);
try {
await api.generateLessonHomework(courseId, activeLesson.lessonId, homeworkType);
toast({ title: 'Готово', description: 'Домашнее задание обновлено' });
} catch (e: any) {
toast({ title: 'Ошибка', description: e.message || 'Не удалось сгенерировать ДЗ', variant: 'destructive' });
} finally {
setGeneratingHomework(false);
}
};
const loadMaterials = async () => {
if (!courseId) return;
try {
const [files, hints] = await Promise.all([
api.getCourseSources(courseId).catch(() => []),
api.getCourseSourceOutlineHints(courseId).catch(() => ({ hints: [] })),
]);
setMaterials(files || []);
setOutlineHints(hints?.hints || []);
} catch {
setMaterials([]);
setOutlineHints([]);
}
};
const handleUploadSource = async (event: any) => {
if (!courseId) return;
const file = event.target.files?.[0];
if (!file) return;
setUploadingSource(true);
try {
await api.uploadCourseSource(courseId, file);
toast({ title: 'Файл загружен', description: 'Источник добавлен в анализ курса' });
await loadMaterials();
} catch (e: any) {
toast({ title: 'Ошибка', description: e.message || 'Не удалось загрузить файл', variant: 'destructive' });
} finally {
setUploadingSource(false);
event.target.value = '';
}
};
if (authLoading || loading) {
return (
<div className="flex min-h-[400px] items-center justify-center">
@ -376,6 +455,123 @@ export default function CourseEditPage() {
</Card>
)}
{activeTab === 'quiz' && (
<Card>
<CardHeader>
<CardTitle>Тест урока</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Выберите урок во вкладке «Контент», затем сгенерируйте тест в один клик.
</p>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<p className="font-medium">Текущий урок:</p>
<p className="text-muted-foreground">{activeLessonMeta?.title || 'Урок не выбран'}</p>
</div>
<Button onClick={handleGenerateQuiz} disabled={!activeLesson || generatingQuiz}>
<Sparkles className="mr-2 h-4 w-4" />
{generatingQuiz ? 'Генерация...' : 'Сгенерировать тест'}
</Button>
{quizGenerated ? (
<p className="text-sm text-emerald-600">Тест доступен студентам в режиме обучения.</p>
) : null}
</CardContent>
</Card>
)}
{activeTab === 'homework' && (
<Card>
<CardHeader>
<CardTitle>Домашнее задание</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Типы: Текстовый ответ, Файл, Проект, Тест, GitHub ссылка.
</p>
<div className="flex flex-wrap gap-2">
<select
value={homeworkType}
onChange={(e) => setHomeworkType(e.target.value as any)}
className="h-10 rounded-md border bg-background px-3 text-sm"
>
<option value="TEXT">Текстовый ответ</option>
<option value="FILE">Файл</option>
<option value="PROJECT">Проект</option>
<option value="QUIZ">Тест</option>
<option value="GITHUB">GitHub ссылка</option>
</select>
<Button onClick={handleGenerateHomework} disabled={!activeLesson || generatingHomework}>
<FileArchive className="mr-2 h-4 w-4" />
{generatingHomework ? 'Генерация...' : ' Добавить ДЗ'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
ДЗ создаётся для выбранного урока: {activeLessonMeta?.title || 'урок не выбран'}.
</p>
</CardContent>
</Card>
)}
{activeTab === 'materials' && (
<Card>
<CardHeader>
<CardTitle>Дополнительные материалы и источники</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
<p className="font-medium">Поддержка форматов</p>
<p className="text-muted-foreground mt-1">PDF, DOCX, TXT, PPTX, изображения, ZIP</p>
<p className="text-xs text-muted-foreground mt-1">
В фазе 1: PDF/TXT анализируются для структуры, остальные сохраняются как вложения.
</p>
</div>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<Upload className="h-4 w-4" />
{uploadingSource ? 'Загрузка...' : 'Загрузить источник'}
<input
type="file"
className="hidden"
accept=".pdf,.docx,.txt,.pptx,.zip,image/*"
onChange={handleUploadSource}
disabled={uploadingSource}
/>
</label>
<div className="space-y-2">
<p className="text-sm font-medium">Загруженные файлы</p>
{materials.length === 0 ? (
<p className="text-sm text-muted-foreground">Пока нет загруженных материалов.</p>
) : (
<div className="space-y-2">
{materials.map((file) => (
<div key={file.id} className="rounded-md border p-2 text-sm">
<p className="font-medium">{file.fileName}</p>
<p className="text-xs text-muted-foreground">
{file.sourceType} {file.parseStatus}
</p>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Предложенная структура из источников</p>
{outlineHints.length === 0 ? (
<p className="text-sm text-muted-foreground">Пока нет рекомендаций. Добавьте PDF/TXT.</p>
) : (
<ul className="list-disc pl-5 text-sm space-y-1">
{outlineHints.map((hint: any) => (
<li key={hint.id}>{hint.title}</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
)}
{activeTab === 'pricing' && (
<Card>
<CardHeader>

View File

@ -12,6 +12,7 @@ import {
ChevronRight,
Clock,
Edit,
FilePlus2,
GraduationCap,
Lock,
Play,
@ -32,6 +33,7 @@ import {
} from '@/components/ui/alert-dialog';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonQuiz } from '@/components/dashboard/lesson-quiz';
import { LessonChatPanel } from '@/components/dashboard/lesson-chat-panel';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { cn } from '@/lib/utils';
@ -53,7 +55,7 @@ type LessonProgressRow = {
};
type HomeworkState = {
homework: { id: string; title: string; description: string } | null;
homework: { id: string; title: string; description: string; type?: string; config?: any } | null;
submission: { id: string; content: string; aiScore?: number | null; aiFeedback?: string | null } | null;
};
@ -80,6 +82,9 @@ export default function CoursePage() {
const [homeworkLoading, setHomeworkLoading] = useState(false);
const [homeworkSubmitting, setHomeworkSubmitting] = useState(false);
const [homeworkContent, setHomeworkContent] = useState('');
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
const [groupId, setGroupId] = useState<string | null>(null);
const [activeLessonPanel, setActiveLessonPanel] = useState<'content' | 'quiz' | 'homework' | 'materials'>('content');
const flatLessons = useMemo(() => {
if (!course) return [];
@ -156,7 +161,13 @@ export default function CoursePage() {
setCourse(courseData);
setExpandedChapters((courseData.chapters || []).map((ch: Chapter) => ch.id));
const map = await refreshProgress(id);
const [map, groupData] = await Promise.all([
refreshProgress(id),
api.getDefaultCourseGroup(id).catch(() => null),
]);
if (groupData?.group?.id) {
setGroupId(groupData.group.id);
}
const ordered = (courseData.chapters || [])
.sort((a: Chapter, b: Chapter) => a.order - b.order)
@ -195,6 +206,7 @@ export default function CoursePage() {
setLessonContentLoading(true);
setShowQuiz(false);
setQuizQuestions([]);
setActiveLessonPanel('content');
(async () => {
try {
@ -277,7 +289,10 @@ export default function CoursePage() {
if (!id || !selectedLessonId || !homeworkContent.trim() || homeworkSubmitting) return;
setHomeworkSubmitting(true);
try {
const submission = await api.submitLessonHomework(id, selectedLessonId, homeworkContent.trim());
const submission = await api.submitLessonHomework(id, selectedLessonId, {
content: homeworkContent.trim(),
type: homeworkType,
});
setHomework((prev) => ({ ...prev, submission }));
await refreshProgress(id);
} finally {
@ -285,6 +300,12 @@ export default function CoursePage() {
}
};
const handleGenerateHomework = async () => {
if (!id || !selectedLessonId) return;
await api.generateLessonHomework(id, selectedLessonId, homeworkType).catch(() => null);
await loadHomework(id, selectedLessonId);
};
const goToPrevLesson = () => {
if (currentLessonIndex <= 0) return;
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
@ -514,33 +535,96 @@ export default function CoursePage() {
</div>
) : selectedLessonId ? (
<>
<LessonContentViewer content={lessonContent} className="min-h-[320px]" />
<div className="mb-6 flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-2">
<Button
size="sm"
variant={activeLessonPanel === 'content' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('content')}
>
Контент
</Button>
<Button
size="sm"
variant={activeLessonPanel === 'quiz' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('quiz')}
>
Тест
</Button>
<Button
size="sm"
variant={activeLessonPanel === 'homework' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('homework')}
>
Домашнее задание
</Button>
<Button
size="sm"
variant={activeLessonPanel === 'materials' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('materials')}
>
Доп. материалы
</Button>
</div>
{!activeProgress?.quizPassed && (
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center">
<h3 className="font-semibold mb-2">Шаг 1 из 2: тест</h3>
<p className="text-sm text-muted-foreground mb-4">
Для открытия следующего урока нужно пройти тест и отправить письменное ДЗ.
</p>
<Button onClick={handleStartQuiz} disabled={quizLoading}>
{quizLoading ? 'Загрузка теста...' : 'Начать тест'}
</Button>
{activeLessonPanel === 'content' ? (
<LessonContentViewer content={lessonContent} className="min-h-[320px]" />
) : null}
{activeLessonPanel === 'quiz' ? (
<div className="space-y-4">
{!activeProgress?.quizPassed ? (
<div className="p-6 border rounded-xl bg-muted/20 text-center">
<h3 className="font-semibold mb-2">Шаг 1 из 2: тест</h3>
<p className="text-sm text-muted-foreground mb-4">
Для открытия следующего урока пройдите тест.
</p>
<Button onClick={handleStartQuiz} disabled={quizLoading}>
{quizLoading ? 'Загрузка теста...' : 'Начать тест'}
</Button>
</div>
) : (
<div className="p-4 rounded-xl border bg-emerald-50 text-emerald-800">
Тест уже пройден. Можно переходить к домашнему заданию.
</div>
)}
{showQuiz ? (
<LessonQuiz
courseId={id}
lessonId={selectedLessonId}
questions={quizQuestions}
onComplete={handleQuizComplete}
/>
) : null}
</div>
)}
) : null}
{showQuiz && (
<LessonQuiz
courseId={id}
lessonId={selectedLessonId}
questions={quizQuestions}
onComplete={handleQuizComplete}
/>
)}
{activeProgress?.quizPassed && (
<div className="mt-8 p-6 border rounded-xl bg-muted/20">
<h3 className="font-semibold mb-2">Шаг 2 из 2: письменное домашнее задание</h3>
{homeworkLoading ? (
{activeLessonPanel === 'homework' ? (
<div className="p-6 border rounded-xl bg-muted/20">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">Домашнее задание</h3>
{isAuthor ? (
<div className="flex items-center gap-2">
<select
value={homeworkType}
onChange={(e) => setHomeworkType(e.target.value as any)}
className="h-9 rounded-lg border bg-background px-2 text-sm"
>
<option value="TEXT">Текстовый ответ</option>
<option value="FILE">Файл</option>
<option value="PROJECT">Проект</option>
<option value="QUIZ">Тест</option>
<option value="GITHUB">GitHub ссылка</option>
</select>
<Button variant="outline" size="sm" onClick={handleGenerateHomework}>
<FilePlus2 className="mr-2 h-4 w-4" />
Добавить ДЗ
</Button>
</div>
) : null}
</div>
{!activeProgress?.quizPassed ? (
<p className="text-sm text-muted-foreground">Сначала пройдите тест этого урока.</p>
) : homeworkLoading ? (
<p className="text-sm text-muted-foreground">Подготовка задания...</p>
) : (
<>
@ -559,7 +643,7 @@ export default function CoursePage() {
disabled={Boolean(activeProgress?.homeworkSubmitted)}
/>
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Минимум 50 символов</p>
<p className="text-xs text-muted-foreground">Рекомендуется подробный ответ и примеры</p>
<Button
onClick={handleSubmitHomework}
disabled={homeworkSubmitting || Boolean(activeProgress?.homeworkSubmitted)}
@ -582,7 +666,14 @@ export default function CoursePage() {
</>
)}
</div>
)}
) : null}
{activeLessonPanel === 'materials' ? (
<div className="p-6 rounded-xl border bg-muted/20 text-sm text-muted-foreground">
Дополнительные материалы для урока можно добавить в редакторе курса во вкладке
{' '}<span className="font-medium text-foreground">«Доп. материалы»</span>.
</div>
) : null}
</>
) : (
<div className="text-muted-foreground">Выберите урок</div>
@ -613,6 +704,7 @@ export default function CoursePage() {
</div>
</main>
</div>
<LessonChatPanel groupId={groupId} lessonId={selectedLessonId} userId={backendUser?.id || null} />
</div>
);
}

View File

@ -11,7 +11,7 @@ import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast';
type Step = 'prompt' | 'questions' | 'generating' | 'complete' | 'error';
type Step = 'prompt' | 'questions' | 'recommendations' | 'generating' | 'complete' | 'error';
interface ClarifyingQuestion {
id: string;
@ -34,6 +34,12 @@ export default function NewCoursePage() {
const [errorMessage, setErrorMessage] = useState('');
const [courseId, setCourseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [aiRecommendation, setAiRecommendation] = useState<{
modules: number;
lessonFormat: string;
assignmentTypes: string[];
suggestedStructure: string[];
} | null>(null);
// Poll for generation status
const pollStatus = useCallback(async () => {
@ -74,7 +80,7 @@ export default function NewCoursePage() {
break;
default:
// Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT)
if (step !== 'questions') {
if (step !== 'questions' && step !== 'recommendations') {
setStep('generating');
}
}
@ -85,7 +91,7 @@ export default function NewCoursePage() {
// Start polling when we have a generation ID
useEffect(() => {
if (!generationId || step === 'complete' || step === 'error' || step === 'questions') {
if (!generationId || step === 'complete' || step === 'error' || step === 'questions' || step === 'recommendations') {
return;
}
@ -132,13 +138,44 @@ export default function NewCoursePage() {
const handleSubmitAnswers = async () => {
if (!generationId || isSubmitting) return;
const audience = String(answers.q_audience || '');
const format = String(answers.q_format || '');
const goal = String(answers.q_goal || '');
const volume = String(answers.q_volume || '');
const modules =
volume.includes('Короткий') ? 4 : volume.includes('Полный') ? 9 : 6;
const lessonFormat =
format === 'Практика' ? '70% практика / 30% теория' : format === 'Теория' ? '80% теория / 20% практика' : 'Смешанный 50/50';
const assignmentTypes =
goal === 'Подготовиться к экзамену'
? ['Тесты', 'Контрольные кейсы', 'Проверка на время']
: goal === 'Освоить профессию'
? ['Практика', 'Мини-проекты', 'Портфолио задания']
: ['Практика', 'Тест', 'Домашнее задание'];
const suggestedStructure = [
`Введение для уровня: ${audience || 'не указан'}`,
'Базовые принципы и инструменты',
'Практические модули по задачам',
'Финальный блок с закреплением',
];
setAiRecommendation({
modules,
lessonFormat,
assignmentTypes,
suggestedStructure,
});
setStep('recommendations');
};
const handleConfirmGeneration = async () => {
if (!generationId || isSubmitting) return;
setIsSubmitting(true);
try {
await api.answerQuestions(generationId, answers);
setStep('generating');
// Resume polling
setTimeout(pollStatus, 1000);
} catch (error: any) {
toast({
@ -171,6 +208,7 @@ export default function NewCoursePage() {
setCurrentStepText('');
setErrorMessage('');
setCourseId(null);
setAiRecommendation(null);
};
const allRequiredAnswered = questions
@ -392,6 +430,68 @@ export default function NewCoursePage() {
</motion.div>
)}
{/* Step 3: Generating */}
{step === 'recommendations' && (
<motion.div
key="recommendations"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">AI-рекомендации перед генерацией</h3>
</div>
{aiRecommendation ? (
<div className="space-y-3 text-sm">
<div className="rounded-lg border bg-muted/30 p-3">
<p><span className="font-medium">Рекомендуемое число модулей:</span> {aiRecommendation.modules}</p>
<p><span className="font-medium">Формат уроков:</span> {aiRecommendation.lessonFormat}</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="font-medium mb-1">Типы заданий:</p>
<ul className="list-disc pl-5 space-y-1">
{aiRecommendation.assignmentTypes.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="font-medium mb-1">Рекомендованная структура:</p>
<ul className="list-decimal pl-5 space-y-1">
{aiRecommendation.suggestedStructure.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</div>
) : null}
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={() => setStep('questions')}>
Назад к вопросам
</Button>
<Button onClick={handleConfirmGeneration} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Запуск...
</>
) : (
<>
Подтвердить и создать курс
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 3: Generating */}
{step === 'generating' && (
<motion.div

View File

@ -14,7 +14,15 @@ type Course = {
id: string;
title: string;
description: string | null;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING' | 'PENDING_REVIEW' | 'REJECTED';
status:
| 'DRAFT'
| 'PUBLISHED'
| 'ARCHIVED'
| 'GENERATING'
| 'PENDING_MODERATION'
| 'PENDING_REVIEW'
| 'REJECTED'
| 'APPROVED';
chaptersCount: number;
lessonsCount: number;
updatedAt: string;
@ -56,7 +64,9 @@ export default function DashboardPage() {
const stats = useMemo(() => {
const drafts = courses.filter((course) => course.status === 'DRAFT' || course.status === 'REJECTED');
const published = courses.filter((course) => course.status === 'PUBLISHED');
const pending = courses.filter((course) => course.status === 'PENDING_REVIEW');
const pending = courses.filter(
(course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW'
);
return {
drafts,
published,

View File

@ -2,7 +2,17 @@
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { CheckCircle2, Loader2, MessageCircle, Search, Trash2, XCircle } from 'lucide-react';
import {
CheckCircle2,
CreditCard,
ExternalLink,
Loader2,
MessageCircle,
Search,
Trash2,
Users,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
@ -19,9 +29,30 @@ type ModerationCourse = {
_count?: { chapters?: number; enrollments?: number; reviews?: number };
};
type AdminUser = {
id: string;
email: string;
name?: string | null;
role: 'USER' | 'MODERATOR' | 'ADMIN';
subscriptionTier?: string;
};
type AdminPayment = {
id: string;
amount: string | number;
currency: string;
status: string;
mode: 'DEV' | 'PROD';
provider: 'STRIPE' | 'YOOMONEY';
eventCode?: string | null;
createdAt: string;
user?: { name?: string | null; email?: string };
course?: { id: string; title: string };
};
const statusFilters = [
{ value: '', label: 'Все статусы' },
{ value: 'PENDING_REVIEW', label: 'На проверке' },
{ value: 'PENDING_MODERATION', label: 'На проверке' },
{ value: 'PUBLISHED', label: 'Опубликованные' },
{ value: 'REJECTED', label: 'Отклонённые' },
{ value: 'DRAFT', label: 'Черновики' },
@ -29,6 +60,7 @@ const statusFilters = [
const badgeMap: Record<string, string> = {
PENDING_REVIEW: 'bg-amber-100 text-amber-900',
PENDING_MODERATION: 'bg-amber-100 text-amber-900',
PUBLISHED: 'bg-green-100 text-green-900',
REJECTED: 'bg-rose-100 text-rose-900',
DRAFT: 'bg-slate-100 text-slate-900',
@ -38,6 +70,8 @@ const badgeMap: Record<string, string> = {
export default function AdminPage() {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<'courses' | 'users' | 'payments'>('courses');
const [courses, setCourses] = useState<ModerationCourse[]>([]);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
@ -45,10 +79,20 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true);
const [actingId, setActingId] = useState<string | null>(null);
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [usersSearch, setUsersSearch] = useState('');
const [payments, setPayments] = useState<AdminPayment[]>([]);
const [paymentsLoading, setPaymentsLoading] = useState(false);
const [paymentSearch, setPaymentSearch] = useState('');
const [paymentMode, setPaymentMode] = useState('');
const loadCourses = async () => {
setLoading(true);
try {
const data = await api.getModerationCourses({ status: status || undefined, search: search || undefined });
const requestedStatus = status === 'PENDING_REVIEW' ? 'PENDING_MODERATION' : status;
const data = await api.getModerationCourses({ status: requestedStatus || undefined, search: search || undefined });
setCourses(data || []);
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить курсы', variant: 'destructive' });
@ -58,14 +102,52 @@ export default function AdminPage() {
}
};
const loadUsers = async () => {
setUsersLoading(true);
try {
const data = await api.getAdminUsers({ search: usersSearch || undefined });
setAdminUsers(data || []);
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить пользователей', variant: 'destructive' });
setAdminUsers([]);
} finally {
setUsersLoading(false);
}
};
const loadPayments = async () => {
setPaymentsLoading(true);
try {
const data = await api.getAdminPayments({
search: paymentSearch || undefined,
mode: (paymentMode || undefined) as any,
});
setPayments(data || []);
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить платежи', variant: 'destructive' });
setPayments([]);
} finally {
setPaymentsLoading(false);
}
};
useEffect(() => {
loadCourses();
}, [status]);
useEffect(() => {
if (activeTab === 'users') {
loadUsers();
}
if (activeTab === 'payments') {
loadPayments();
}
}, [activeTab]);
const stats = useMemo(() => {
return {
total: courses.length,
pending: courses.filter((course) => course.status === 'PENDING_REVIEW').length,
pending: courses.filter((course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW').length,
published: courses.filter((course) => course.status === 'PUBLISHED').length,
};
}, [courses]);
@ -109,14 +191,24 @@ export default function AdminPage() {
}
};
const updateUserRole = async (userId: string, role: 'USER' | 'MODERATOR' | 'ADMIN') => {
try {
await api.updateAdminUserRole(userId, role);
toast({ title: 'Роль обновлена', description: `Новая роль: ${role}` });
await loadUsers();
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось изменить роль', variant: 'destructive' });
}
};
return (
<div className="space-y-6">
<section className="rounded-2xl border bg-background p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Модерация курсов</h1>
<h1 className="text-3xl font-bold tracking-tight">Админ Панель</h1>
<p className="mt-1 text-sm text-muted-foreground">
Проверка курсов, публикация, отклонение и удаление.
Модерация курсов, поддержка, управление пользователями и платежами.
</p>
</div>
<Button variant="outline" asChild>
@ -128,133 +220,255 @@ export default function AdminPage() {
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Всего в выдаче</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.total}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Ожидают модерации</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.pending}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Опубликовано</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.published}</CardContent>
</Card>
</section>
<section className="flex flex-col gap-3 rounded-2xl border bg-card p-4 md:flex-row">
<label className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по курсам и авторам"
className="h-10 w-full rounded-xl border bg-background pl-10 pr-3 text-sm"
/>
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-10 rounded-xl border bg-background px-3 text-sm"
>
{statusFilters.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<Button onClick={loadCourses} disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Применить
<section className="flex flex-wrap gap-2">
<Button variant={activeTab === 'courses' ? 'default' : 'outline'} onClick={() => setActiveTab('courses')}>
Курсы
</Button>
<Button variant={activeTab === 'users' ? 'default' : 'outline'} onClick={() => setActiveTab('users')}>
<Users className="mr-2 h-4 w-4" />
Пользователи
</Button>
<Button variant={activeTab === 'payments' ? 'default' : 'outline'} onClick={() => setActiveTab('payments')}>
<CreditCard className="mr-2 h-4 w-4" />
Платежи
</Button>
</section>
<section className="space-y-3">
{courses.map((course) => (
<Card key={course.id} className="border-border/60">
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-lg">{course.title}</CardTitle>
<CardDescription>
{course.author?.name || 'Без имени'} ({course.author?.email || '—'})
</CardDescription>
</div>
<span
className={cn(
'rounded-full px-2.5 py-1 text-xs font-semibold',
badgeMap[course.status] || 'bg-muted text-muted-foreground'
)}
>
{course.status}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<p>Глав: {course._count?.chapters || 0}</p>
<p>Студентов: {course._count?.enrollments || 0}</p>
<p>Отзывов: {course._count?.reviews || 0}</p>
</div>
{activeTab === 'courses' ? (
<>
<section className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Всего в выдаче</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.total}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Ожидают модерации</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.pending}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Опубликовано</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.published}</CardContent>
</Card>
</section>
<section className="flex flex-col gap-3 rounded-2xl border bg-card p-4 md:flex-row">
<label className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={noteDraft[course.id] || ''}
onChange={(e) => setNoteDraft((prev) => ({ ...prev, [course.id]: e.target.value }))}
placeholder="Комментарий модерации"
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по курсам и авторам"
className="h-10 w-full rounded-xl border bg-background pl-10 pr-3 text-sm"
/>
</label>
<div className="flex flex-wrap gap-2">
{course.status === 'PENDING_REVIEW' ? (
<>
<Button size="sm" onClick={() => approve(course.id)} disabled={actingId === course.id}>
<CheckCircle2 className="mr-2 h-4 w-4" />
Опубликовать
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-10 rounded-xl border bg-background px-3 text-sm"
>
{statusFilters.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<Button onClick={loadCourses} disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Применить
</Button>
</section>
<section className="space-y-3">
{courses.map((course) => (
<Card key={course.id} className="border-border/60">
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-lg">{course.title}</CardTitle>
<CardDescription>
{course.author?.name || 'Без имени'} ({course.author?.email || '—'})
</CardDescription>
</div>
<span
className={cn(
'rounded-full px-2.5 py-1 text-xs font-semibold',
badgeMap[course.status] || 'bg-muted text-muted-foreground'
)}
>
{course.status}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<p>Глав: {course._count?.chapters || 0}</p>
<p>Студентов: {course._count?.enrollments || 0}</p>
<p>Отзывов: {course._count?.reviews || 0}</p>
</div>
<div className="flex gap-2">
<input
value={noteDraft[course.id] || ''}
onChange={(e) => setNoteDraft((prev) => ({ ...prev, [course.id]: e.target.value }))}
placeholder="Комментарий модерации"
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
/>
<Button variant="outline" asChild>
<Link href={`/courses/${course.id}`} target="_blank">
Preview
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
<div className="flex flex-wrap gap-2">
{course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW' ? (
<>
<Button size="sm" onClick={() => approve(course.id)} disabled={actingId === course.id}>
<CheckCircle2 className="mr-2 h-4 w-4" />
Опубликовать
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reject(course.id)}
disabled={actingId === course.id}
>
<XCircle className="mr-2 h-4 w-4" />
Отклонить
</Button>
</>
) : null}
<Button
size="sm"
variant="outline"
onClick={() => reject(course.id)}
variant="destructive"
onClick={() => removeCourse(course.id)}
disabled={actingId === course.id}
>
<XCircle className="mr-2 h-4 w-4" />
Отклонить
<Trash2 className="mr-2 h-4 w-4" />
Удалить курс
</Button>
</>
</div>
</CardContent>
</Card>
))}
{!loading && courses.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-sm text-muted-foreground">
Курсы по заданным фильтрам не найдены.
</CardContent>
</Card>
) : null}
</section>
</>
) : null}
{activeTab === 'users' ? (
<section className="space-y-3">
<div className="flex gap-2">
<input
value={usersSearch}
onChange={(e) => setUsersSearch(e.target.value)}
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
placeholder="Поиск пользователя"
/>
<Button onClick={loadUsers} disabled={usersLoading}>
{usersLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Найти
</Button>
</div>
{adminUsers.map((user) => (
<Card key={user.id}>
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4">
<div>
<p className="font-medium">{user.name || user.email}</p>
<p className="text-sm text-muted-foreground">{user.email} {user.subscriptionTier || 'FREE'}</p>
</div>
<div className="flex items-center gap-2">
<select
value={user.role}
onChange={(e) => updateUserRole(user.id, e.target.value as any)}
className="h-9 rounded-lg border bg-background px-2 text-sm"
>
<option value="USER">USER</option>
<option value="MODERATOR">MODERATOR</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
</CardContent>
</Card>
))}
{!usersLoading && adminUsers.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-sm text-muted-foreground">Пользователи не найдены.</CardContent>
</Card>
) : null}
</section>
) : null}
{activeTab === 'payments' ? (
<section className="space-y-3">
<div className="flex flex-wrap gap-2">
<input
value={paymentSearch}
onChange={(e) => setPaymentSearch(e.target.value)}
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
placeholder="Поиск по курсу / пользователю"
/>
<select
value={paymentMode}
onChange={(e) => setPaymentMode(e.target.value)}
className="h-10 rounded-xl border bg-background px-3 text-sm"
>
<option value="">Все режимы</option>
<option value="DEV">DEV</option>
<option value="PROD">PROD</option>
</select>
<Button onClick={loadPayments} disabled={paymentsLoading}>
{paymentsLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Применить
</Button>
</div>
{payments.map((payment) => (
<Card key={payment.id}>
<CardContent className="space-y-2 p-4 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium">{payment.course?.title || 'Курс удалён'}</p>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
{payment.mode} {payment.provider}
</span>
</div>
<p className="text-muted-foreground">
{payment.user?.name || payment.user?.email} {payment.amount} {payment.currency} {payment.status}
</p>
{payment.eventCode ? (
<p className="text-xs text-muted-foreground">Событие: {payment.eventCode}</p>
) : null}
</CardContent>
</Card>
))}
<Button
size="sm"
variant="destructive"
onClick={() => removeCourse(course.id)}
disabled={actingId === course.id}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить курс
</Button>
</div>
</CardContent>
</Card>
))}
{!loading && courses.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-sm text-muted-foreground">
Курсы по заданным фильтрам не найдены.
</CardContent>
</Card>
) : null}
</section>
{!paymentsLoading && payments.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-sm text-muted-foreground">Платежи не найдены.</CardContent>
</Card>
) : null}
</section>
) : null}
</div>
);
}

View File

@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/landing/header';
import { Footer } from '@/components/landing/footer';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
export default function CooperationPage() {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [form, setForm] = useState({
organization: '',
contactName: '',
email: '',
phone: '',
role: '',
organizationType: '',
message: '',
});
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
setSuccess(null);
try {
const result = await api.submitCooperationRequest({
organization: form.organization,
contactName: form.contactName,
email: form.email,
phone: form.phone || undefined,
role: form.role || undefined,
organizationType: form.organizationType || undefined,
message: form.message,
});
setSuccess(result?.status === 'stored_and_sent' ? 'Заявка отправлена. Мы свяжемся с вами.' : 'Заявка сохранена. Мы свяжемся с вами по почте.');
setForm({
organization: '',
contactName: '',
email: '',
phone: '',
role: '',
organizationType: '',
message: '',
});
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container py-10 space-y-6">
<section className="rounded-3xl border border-border/50 bg-gradient-to-br from-primary/10 via-background to-primary/5 p-6">
<h1 className="text-3xl font-bold">Сотрудничество</h1>
<p className="mt-2 text-muted-foreground max-w-3xl">
Предоставляем платформу для вузов, школ, колледжей и компаний по договорённости:
запуск внутренних академий, каталогов курсов, трекинг прогресса и поддержка авторов.
</p>
</section>
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle>Что можем предоставить</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>1. White-label платформу с вашей айдентикой.</p>
<p>2. Инструменты для авторов и методистов.</p>
<p>3. Проверку контента, модерацию и аналитику обучения.</p>
<p>4. Корпоративные группы, чаты и домашние задания.</p>
<p>5. Интеграцию с процессами вашей организации.</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Оставить заявку</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-3">
<input
value={form.organization}
onChange={(e) => setForm((prev) => ({ ...prev, organization: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Организация"
required
/>
<input
value={form.contactName}
onChange={(e) => setForm((prev) => ({ ...prev, contactName: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Контактное лицо"
required
/>
<input
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Email"
required
/>
<input
value={form.phone}
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Телефон (необязательно)"
/>
<textarea
value={form.message}
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
className="min-h-[140px] w-full rounded-lg border bg-background p-3 text-sm"
placeholder="Опишите задачу и масштаб внедрения"
required
/>
<Button className="w-full" disabled={loading}>
{loading ? 'Отправка...' : 'Отправить заявку'}
</Button>
{success ? <p className="text-xs text-emerald-600">{success}</p> : null}
</form>
</CardContent>
</Card>
</div>
</main>
<Footer />
</div>
);
}

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, BookOpen, Check, ChevronDown, Clock, Loader2, Shield, Star, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -16,6 +16,7 @@ import { Footer } from '@/components/landing/footer';
export default function PublicCoursePage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const { user } = useAuth();
const id = params?.id as string;
@ -26,6 +27,7 @@ export default function PublicCoursePage() {
const [enrolling, setEnrolling] = useState(false);
const [enrolled, setEnrolled] = useState(false);
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
const isDevPayment = searchParams?.get('devPayment') === '1';
useEffect(() => {
if (!id) return;
@ -133,9 +135,12 @@ export default function PublicCoursePage() {
<div>
<div className="mb-2 flex flex-wrap items-center gap-2">
{course.isVerified ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
<span className="group relative inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
<Shield className="h-3 w-3" />
Проверен автором
<span className="pointer-events-none absolute -bottom-9 left-0 hidden w-56 rounded-md bg-black/85 px-2 py-1 text-[10px] font-normal leading-snug text-white shadow-lg group-hover:block">
Автор подтвердил корректность и актуальность материала.
</span>
</span>
) : null}
{course.difficulty ? (
@ -218,6 +223,11 @@ export default function PublicCoursePage() {
<p className="text-3xl font-bold">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</p>
{isDevPayment ? (
<span className="inline-flex w-fit items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-900">
DEV Payment
</span>
) : null}
{enrolled ? (
<Button className="w-full" asChild>
<Link href={`/dashboard/courses/${id}`}>Перейти к обучению</Link>

View File

@ -123,9 +123,12 @@ export default function CoursesPage() {
</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">
<div className="group 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" />
Проверен
Проверен автором
<span className="pointer-events-none absolute -bottom-9 right-0 hidden w-56 rounded-md bg-black/85 px-2 py-1 text-[10px] font-normal leading-snug text-white shadow-lg group-hover:block">
Автор подтвердил корректность и актуальность материала.
</span>
</div>
)}
{course.difficulty && difficultyLabels[course.difficulty] && (

View File

@ -33,7 +33,15 @@ interface CourseCardProps {
id: string;
title: string;
description: string | null;
status: 'DRAFT' | 'GENERATING' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | 'ARCHIVED';
status:
| 'DRAFT'
| 'GENERATING'
| 'PENDING_MODERATION'
| 'PENDING_REVIEW'
| 'APPROVED'
| 'PUBLISHED'
| 'REJECTED'
| 'ARCHIVED';
chaptersCount: number;
lessonsCount: number;
updatedAt: string;
@ -59,6 +67,14 @@ const statusConfig = {
label: 'На модерации',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
PENDING_MODERATION: {
label: 'На модерации',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
APPROVED: {
label: 'Одобрен',
className: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
},
REJECTED: {
label: 'Отклонён',
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',

View File

@ -0,0 +1,154 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { ChevronRight, MessageCircle, Minus, Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { getWsBaseUrl } from '@/lib/ws';
import { cn } from '@/lib/utils';
type Props = {
groupId: string | null;
lessonId: string | null;
userId: string | null;
};
export function LessonChatPanel({ groupId, lessonId, userId }: Props) {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<any[]>([]);
const [message, setMessage] = useState('');
const [sending, setSending] = useState(false);
const socketRef = useRef<Socket | null>(null);
const endRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!groupId || !lessonId) {
setMessages([]);
return;
}
api
.getGroupMessages(groupId, lessonId)
.then((data) => setMessages(data || []))
.catch(() => setMessages([]));
}, [groupId, lessonId]);
useEffect(() => {
if (!groupId) return;
const token =
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
const socket = io(`${getWsBaseUrl()}/ws/course-groups`, {
transports: ['websocket'],
auth: { token },
});
socketRef.current = socket;
socket.emit('groups:join', { groupId });
socket.on('groups:new-message', (payload: any) => {
if (!lessonId) return;
if (payload?.lessonId !== lessonId) return;
setMessages((prev) => [...prev, payload]);
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [groupId, lessonId]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, open]);
const send = async () => {
if (!groupId || !lessonId || !message.trim() || sending) return;
setSending(true);
try {
await api.sendGroupMessage(groupId, message.trim(), lessonId);
setMessage('');
const latest = await api.getGroupMessages(groupId, lessonId).catch(() => []);
setMessages(latest || []);
} finally {
setSending(false);
}
};
return (
<div className="fixed bottom-4 right-4 z-40">
{open ? (
<div className="w-[360px] rounded-2xl border border-slate-700/60 bg-slate-950/85 text-slate-100 shadow-2xl backdrop-blur-xl">
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-primary/20 p-1.5 text-primary">
<MessageCircle className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold">Чат урока</p>
<p className="text-xs text-slate-300">Контекст текущего урока</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-slate-200 hover:bg-slate-800"
onClick={() => setOpen(false)}
>
<Minus className="h-4 w-4" />
</Button>
</div>
<div className="flex h-[420px] flex-col">
<div className="flex-1 space-y-2 overflow-auto p-3">
{messages.map((item) => {
const own = item.user?.id === userId;
return (
<div key={item.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[82%] rounded-2xl px-3 py-2 text-sm',
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-slate-800 text-slate-100'
)}
>
<p className="mb-1 text-xs opacity-80">{item.user?.name || 'Участник'}</p>
<p>{item.content}</p>
</div>
</div>
);
})}
{messages.length === 0 ? (
<p className="text-center text-xs text-slate-400">Сообщений по этому уроку пока нет</p>
) : null}
<div ref={endRef} />
</div>
<div className="border-t border-slate-700/60 p-3">
<div className="flex gap-2">
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
className="h-10 flex-1 rounded-lg border border-slate-700 bg-slate-900 px-3 text-sm text-slate-100 placeholder:text-slate-400"
placeholder="Написать в чат урока"
/>
<Button size="icon" className="h-10 w-10" onClick={send} disabled={sending}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
) : (
<Button
className="h-12 rounded-full px-4 shadow-lg"
onClick={() => setOpen(true)}
disabled={!groupId || !lessonId}
>
<MessageCircle className="mr-2 h-4 w-4" />
Чат урока
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { Sparkles } from 'lucide-react';
const navigation = {
product: [
{ name: 'Курсы', href: '/courses' },
{ name: 'Сотрудничество', href: '/cooperation' },
{ name: 'Возможности', href: '/#features' },
{ name: 'Тарифы', href: '/#pricing' },
{ name: 'FAQ', href: '/#faq' },

View File

@ -10,6 +10,7 @@ import { useAuth } from '@/contexts/auth-context';
const navigation = [
{ name: 'Курсы', href: '/courses' },
{ name: 'Сотрудничество', href: '/cooperation' },
{ name: 'Возможности', href: '/#features' },
{ name: 'Как это работает', href: '/#how-it-works' },
{ name: 'Тарифы', href: '/#pricing' },

View File

@ -1,11 +1,60 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { ArrowRight, Zap, BookOpen, Brain } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/auth-context';
import { useToast } from '@/components/ui/use-toast';
export function Hero() {
const router = useRouter();
const { user, signIn } = useAuth();
const { toast } = useToast();
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [thinkingTick, setThinkingTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setThinkingTick((prev) => (prev + 1) % 3), 900);
return () => clearInterval(id);
}, []);
const handleCreateCourse = async () => {
if (user) {
setIsLaunching(true);
setTimeout(() => router.push('/dashboard/courses/new'), 320);
return;
}
setIsLoginOpen(true);
};
const handleModalLogin = async (event: React.FormEvent) => {
event.preventDefault();
if (signingIn) return;
setSigningIn(true);
const result = await signIn(email.trim(), password);
setSigningIn(false);
if (result.error) {
toast({
title: 'Не удалось войти',
description: result.error.message,
variant: 'destructive',
});
return;
}
setIsLoginOpen(false);
setIsLaunching(true);
setTimeout(() => router.push('/dashboard/courses/new'), 320);
};
return (
<section className="relative overflow-hidden py-20 sm:py-32">
{/* Background gradient */}
@ -58,17 +107,48 @@ export function Hero() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Button size="xl" asChild>
<Link href="/register">
Создать первый курс
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
<Button size="xl" onClick={handleCreateCourse}>
Создать курс
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
<Button size="xl" variant="outline" asChild>
<Link href="#how-it-works">Как это работает</Link>
<Link href="/courses">
Смотреть курсы
</Link>
</Button>
</motion.div>
<motion.div
className={`mx-auto mt-10 w-full max-w-2xl rounded-2xl border border-white/25 bg-white/10 p-4 text-left shadow-xl backdrop-blur-xl transition-all duration-300 ${isLaunching ? 'scale-[1.02] ring-2 ring-primary/30' : ''}`}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.35 }}
>
<div className="mb-3 flex items-center justify-between text-xs text-muted-foreground">
<span className="rounded-full border border-white/20 bg-background/40 px-2 py-1">
AI Studio
</span>
<span className="text-primary">
thinking{'.'.repeat(thinkingTick + 1)}
</span>
</div>
<div className="rounded-xl border border-white/20 bg-background/70 p-3">
<p className="text-sm text-foreground/90">
О чём вы хотите создать курс?
</p>
<div className="mt-2 rounded-lg bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
Пример: «Python для аналитиков с нуля до проектов»
<span className="ml-1 inline-block h-4 w-[1px] animate-pulse bg-primary align-middle" />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>Квиз, структура и план модулей формируются автоматически.</span>
<Button size="sm" onClick={handleCreateCourse}>
Создать первый курс
</Button>
</div>
</motion.div>
{/* Stats */}
<motion.div
className="mt-16 grid grid-cols-3 gap-8"
@ -100,6 +180,54 @@ export function Hero() {
</motion.div>
</div>
</div>
{isLoginOpen ? (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 px-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-background p-5 shadow-2xl">
<div className="mb-4">
<h3 className="text-lg font-semibold">Войдите, чтобы продолжить</h3>
<p className="mt-1 text-sm text-muted-foreground">
После входа сразу откроется создание курса.
</p>
</div>
<form className="space-y-3" onSubmit={handleModalLogin}>
<input
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Email"
/>
<input
type="password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Пароль"
/>
<div className="flex gap-2 pt-1">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => setIsLoginOpen(false)}
disabled={signingIn}
>
Отмена
</Button>
<Button type="submit" className="flex-1" disabled={signingIn}>
{signingIn ? 'Входим...' : 'Войти'}
</Button>
</div>
</form>
<p className="mt-3 text-xs text-muted-foreground">
Нет аккаунта? <Link href="/register" className="text-primary hover:underline">Зарегистрироваться</Link>
</p>
</div>
</div>
) : null}
</section>
);
}

View File

@ -183,6 +183,13 @@ class ApiClient {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`);
}
async generateLessonHomework(courseId: string, lessonId: string, type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB') {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/homework/generate`, {
method: 'POST',
body: JSON.stringify({ type }),
});
}
// Generation
async startGeneration(prompt: string) {
return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
@ -290,10 +297,15 @@ class ApiClient {
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`);
}
async submitLessonHomework(courseId: string, lessonId: string, content: string) {
async submitLessonHomework(
courseId: string,
lessonId: string,
data: { content?: string; type?: string; attachmentUrl?: string; githubUrl?: string } | string
) {
const payload = typeof data === 'string' ? { content: data } : data;
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`, {
method: 'POST',
body: JSON.stringify({ content }),
body: JSON.stringify(payload),
});
}
@ -340,18 +352,19 @@ class ApiClient {
return this.request<any>(`/groups/course/${courseId}/default`);
}
async getGroupMessages(groupId: string) {
return this.request<any[]>(`/groups/${groupId}/messages`);
async getGroupMessages(groupId: string, lessonId?: string) {
const query = lessonId ? `?lessonId=${encodeURIComponent(lessonId)}` : '';
return this.request<any[]>(`/groups/${groupId}/messages${query}`);
}
async getGroupMembers(groupId: string) {
return this.request<any[]>(`/groups/${groupId}/members`);
}
async sendGroupMessage(groupId: string, content: string) {
async sendGroupMessage(groupId: string, content: string, lessonId?: string) {
return this.request<any>(`/groups/${groupId}/messages`, {
method: 'POST',
body: JSON.stringify({ content }),
body: JSON.stringify({ content, lessonId }),
});
}
@ -431,6 +444,17 @@ class ApiClient {
return this.request<any[]>('/moderation/pending');
}
async getModerationCoursePreview(courseId: string) {
return this.request<any>(`/moderation/${courseId}/preview`);
}
async previewModerationQuiz(courseId: string, lessonId: string, answers?: number[]) {
return this.request<any>(`/moderation/${courseId}/quiz-preview`, {
method: 'POST',
body: JSON.stringify({ lessonId, answers }),
});
}
async approveModerationCourse(courseId: string, note?: string) {
return this.request<any>(`/moderation/${courseId}/approve`, {
method: 'POST',
@ -451,6 +475,80 @@ class ApiClient {
});
}
async getAdminUsers(params?: { search?: string; role?: string; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
if (params?.role) searchParams.set('role', params.role);
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.request<any[]>(`/admin/users${query ? `?${query}` : ''}`);
}
async updateAdminUserRole(userId: string, role: 'USER' | 'MODERATOR' | 'ADMIN') {
return this.request<any>(`/admin/users/${userId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
});
}
async getAdminPayments(params?: {
mode?: 'DEV' | 'PROD';
provider?: 'STRIPE' | 'YOOMONEY';
status?: string;
search?: string;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.mode) searchParams.set('mode', params.mode);
if (params?.provider) searchParams.set('provider', params.provider);
if (params?.status) searchParams.set('status', params.status);
if (params?.search) searchParams.set('search', params.search);
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.request<any[]>(`/admin/payments${query ? `?${query}` : ''}`);
}
async submitCooperationRequest(data: {
organization: string;
contactName: string;
email: string;
phone?: string;
role?: string;
organizationType?: string;
message: string;
}) {
return this.request<any>('/cooperation/requests', {
method: 'POST',
body: JSON.stringify(data),
});
}
async uploadCourseSource(courseId: string, file: File) {
const token = getApiToken();
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_URL}/courses/${courseId}/sources/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
async getCourseSources(courseId: string) {
return this.request<any[]>(`/courses/${courseId}/sources`);
}
async getCourseSourceOutlineHints(courseId: string) {
return this.request<any>(`/courses/${courseId}/sources/outline-hints`);
}
// Search
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
const searchParams = new URLSearchParams({ q: query });