your message
This commit is contained in:
@ -49,6 +49,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4",
|
||||
|
||||
@ -80,7 +80,7 @@ export default function CertificatePage() {
|
||||
<div className="no-print absolute top-4 right-4">
|
||||
<Button onClick={handlePrint} variant="secondary" className="shadow-lg">
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Печать
|
||||
Сохранить в PDF / Печать
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
147
apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx
Normal file
147
apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
|
||||
export default function AdminSupportPage() {
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [status, setStatus] = useState('in_progress');
|
||||
const [message, setMessage] = useState('');
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||
[tickets, selectedTicketId]
|
||||
);
|
||||
|
||||
const loadTickets = async () => {
|
||||
const data = await api.getAdminSupportTickets().catch(() => []);
|
||||
setTickets(data);
|
||||
if (!selectedTicketId && data.length > 0) {
|
||||
setSelectedTicketId(data[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTickets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTicketId) return;
|
||||
api
|
||||
.getAdminSupportTicketMessages(selectedTicketId)
|
||||
.then((data) => setMessages(data))
|
||||
.catch(() => setMessages([]));
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('support:join', { ticketId: selectedTicketId });
|
||||
socket.on('support:new-message', (msg: any) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
});
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [selectedTicketId]);
|
||||
|
||||
const sendReply = async () => {
|
||||
if (!selectedTicketId || !message.trim()) return;
|
||||
await api.sendAdminSupportMessage(selectedTicketId, message.trim());
|
||||
setMessage('');
|
||||
const list = await api.getAdminSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||
setMessages(list);
|
||||
await loadTickets();
|
||||
};
|
||||
|
||||
const updateStatus = async () => {
|
||||
if (!selectedTicketId) return;
|
||||
await api.updateAdminSupportTicketStatus(selectedTicketId, status);
|
||||
await loadTickets();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Админ: тикеты поддержки</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm ${
|
||||
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedTicketId(ticket.id)}
|
||||
>
|
||||
<p className="font-medium">{ticket.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ticket.user?.name || ticket.user?.email} • {ticket.status}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[520px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{selected ? `Тикет: ${selected.title}` : 'Выберите тикет'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[430px] flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<select
|
||||
className="rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
disabled={!selectedTicketId}
|
||||
>
|
||||
<option value="open">open</option>
|
||||
<option value="in_progress">in_progress</option>
|
||||
<option value="resolved">resolved</option>
|
||||
<option value="closed">closed</option>
|
||||
</select>
|
||||
<Button variant="outline" onClick={updateStatus} disabled={!selectedTicketId}>
|
||||
Обновить статус
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">
|
||||
{msg.user?.name || 'Пользователь'} {msg.isStaff ? '(Поддержка)' : ''}
|
||||
</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Ответ поддержки"
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button onClick={sendReply} disabled={!selectedTicketId}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -35,6 +35,10 @@ export default function PublicCoursePage() {
|
||||
const [enrolling, setEnrolling] = useState(false);
|
||||
const [enrolled, setEnrolled] = useState(false);
|
||||
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||
const [submittingReview, setSubmittingReview] = useState(false);
|
||||
const [reviewRating, setReviewRating] = useState(5);
|
||||
const [reviewTitle, setReviewTitle] = useState('');
|
||||
const [reviewContent, setReviewContent] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@ -65,9 +69,17 @@ export default function PublicCoursePage() {
|
||||
if (!id || enrolling) return;
|
||||
setEnrolling(true);
|
||||
try {
|
||||
await api.enrollInCourse(id);
|
||||
toast({ title: 'Успех', description: 'Вы записались на курс' });
|
||||
router.push(`/dashboard/courses/${id}`);
|
||||
if (course?.price) {
|
||||
const session = await api.checkoutCourse(id);
|
||||
if (session?.url) {
|
||||
window.location.href = session.url;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await api.enrollInCourse(id);
|
||||
toast({ title: 'Успех', description: 'Вы записались на курс' });
|
||||
router.push(`/dashboard/courses/${id}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||
} finally {
|
||||
@ -75,6 +87,27 @@ export default function PublicCoursePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
if (!id || !enrolled || submittingReview) return;
|
||||
setSubmittingReview(true);
|
||||
try {
|
||||
await api.createReview(id, {
|
||||
rating: reviewRating,
|
||||
title: reviewTitle || undefined,
|
||||
content: reviewContent || undefined,
|
||||
});
|
||||
const reviewsData = await api.getCourseReviews(id).catch(() => ({ data: [] }));
|
||||
setReviews(reviewsData.data || []);
|
||||
setReviewTitle('');
|
||||
setReviewContent('');
|
||||
toast({ title: 'Спасибо!', description: 'Ваш отзыв опубликован' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setSubmittingReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@ -190,7 +223,7 @@ export default function PublicCoursePage() {
|
||||
) : (
|
||||
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
|
||||
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{course.price ? 'Купить курс' : 'Записаться'}
|
||||
{course.price ? 'Купить курс' : 'Добавить в библиотеку'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="space-y-2 text-sm">
|
||||
@ -300,6 +333,43 @@ export default function PublicCoursePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{enrolled && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Оцените курс</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<button key={i} onClick={() => setReviewRating(i + 1)} className="p-1">
|
||||
<Star
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
i < reviewRating ? 'text-yellow-500 fill-current' : 'text-muted-foreground/30'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={reviewTitle}
|
||||
onChange={(e) => setReviewTitle(e.target.value)}
|
||||
placeholder="Заголовок отзыва"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={reviewContent}
|
||||
onChange={(e) => setReviewContent(e.target.value)}
|
||||
placeholder="Что вам понравилось в курсе?"
|
||||
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm"
|
||||
/>
|
||||
<Button onClick={handleSubmitReview} disabled={submittingReview}>
|
||||
{submittingReview ? 'Публикация...' : 'Оставить отзыв'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,7 +15,17 @@ import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
type Lesson = { id: string; title: string };
|
||||
type Chapter = { id: string; title: string; lessons: Lesson[] };
|
||||
type CourseData = { id: string; title: string; chapters: Chapter[] };
|
||||
type CourseData = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
coverImage?: string | null;
|
||||
price?: number | null;
|
||||
currency?: string;
|
||||
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
|
||||
moderationNote?: string | null;
|
||||
chapters: Chapter[];
|
||||
};
|
||||
|
||||
const emptyDoc = { type: 'doc', content: [] };
|
||||
|
||||
@ -37,6 +47,12 @@ export default function CourseEditPage() {
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [savingMeta, setSavingMeta] = useState(false);
|
||||
const [courseTitle, setCourseTitle] = useState('');
|
||||
const [courseDescription, setCourseDescription] = useState('');
|
||||
const [courseCover, setCourseCover] = useState('');
|
||||
const [coursePrice, setCoursePrice] = useState('');
|
||||
const [courseCurrency, setCourseCurrency] = useState('USD');
|
||||
|
||||
useEffect(() => {
|
||||
if (!courseId || authLoading) return;
|
||||
@ -48,6 +64,11 @@ export default function CourseEditPage() {
|
||||
const data = await api.getCourse(courseId);
|
||||
if (!cancelled) {
|
||||
setCourse(data);
|
||||
setCourseTitle(data.title || '');
|
||||
setCourseDescription(data.description || '');
|
||||
setCourseCover(data.coverImage || '');
|
||||
setCoursePrice(data.price ? String(data.price) : '');
|
||||
setCourseCurrency(data.currency || 'USD');
|
||||
const firstChapter = data.chapters?.[0];
|
||||
const firstLesson = firstChapter?.lessons?.[0];
|
||||
if (firstChapter && firstLesson) {
|
||||
@ -121,7 +142,7 @@ export default function CourseEditPage() {
|
||||
setPublishing(true);
|
||||
try {
|
||||
await api.publishCourse(courseId);
|
||||
toast({ title: 'Опубликовано', description: 'Курс теперь доступен в каталоге' });
|
||||
toast({ title: 'Отправлено', description: 'Курс отправлен на модерацию' });
|
||||
window.location.reload();
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||
@ -130,6 +151,25 @@ export default function CourseEditPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMeta = async () => {
|
||||
if (!courseId || savingMeta) return;
|
||||
setSavingMeta(true);
|
||||
try {
|
||||
await api.updateCourse(courseId, {
|
||||
title: courseTitle,
|
||||
description: courseDescription || undefined,
|
||||
coverImage: courseCover || undefined,
|
||||
price: coursePrice ? Number(coursePrice) : 0,
|
||||
currency: courseCurrency,
|
||||
});
|
||||
toast({ title: 'Сохранено', description: 'Настройки курса обновлены' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setSavingMeta(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVerify = async () => {
|
||||
if (!courseId || verifying) return;
|
||||
setVerifying(true);
|
||||
@ -198,9 +238,13 @@ export default function CourseEditPage() {
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
|
||||
<h2 className="font-medium truncate">
|
||||
{activeLessonMeta?.title ?? 'Выберите урок'}
|
||||
</h2>
|
||||
<div>
|
||||
<h2 className="font-medium truncate">{activeLessonMeta?.title ?? 'Выберите урок'}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Статус курса: {course.status}
|
||||
{course.moderationNote ? ` • Заметка: ${course.moderationNote}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{readOnly ? (
|
||||
<>
|
||||
@ -235,13 +279,57 @@ export default function CourseEditPage() {
|
||||
</Button>
|
||||
<Button size="sm" variant="default" onClick={handlePublish} disabled={publishing}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{publishing ? 'Публикация...' : 'Опубликовать'}
|
||||
{publishing ? 'Отправка...' : 'Отправить на модерацию'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b px-4 py-3 bg-muted/20">
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-5">
|
||||
<input
|
||||
value={courseTitle}
|
||||
onChange={(e) => setCourseTitle(e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-2 text-sm lg:col-span-2"
|
||||
placeholder="Название курса"
|
||||
/>
|
||||
<input
|
||||
value={courseCover}
|
||||
onChange={(e) => setCourseCover(e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="URL превью"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={coursePrice}
|
||||
onChange={(e) => setCoursePrice(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Цена"
|
||||
/>
|
||||
<select
|
||||
value={courseCurrency}
|
||||
onChange={(e) => setCourseCurrency(e.target.value)}
|
||||
className="rounded-md border bg-background px-2 py-2 text-sm"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="RUB">RUB</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
||||
{savingMeta ? 'Сохранение...' : 'Сохранить карточку'}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={courseDescription}
|
||||
onChange={(e) => setCourseDescription(e.target.value)}
|
||||
className="mt-2 w-full rounded-md border bg-background p-3 text-sm"
|
||||
rows={2}
|
||||
placeholder="Описание курса"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6">
|
||||
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
|
||||
{contentLoading ? (
|
||||
|
||||
@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Link as LinkIcon, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
export default function CourseGroupPage() {
|
||||
const params = useParams();
|
||||
const courseId = params?.id as string;
|
||||
const { backendUser } = useAuth();
|
||||
|
||||
const [group, setGroup] = useState<any>(null);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [submissions, setSubmissions] = useState<any[]>([]);
|
||||
const [reviewState, setReviewState] = useState<Record<string, { score: number; feedback: string }>>({});
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const isAuthor = useMemo(() => {
|
||||
if (!backendUser || !group?.courseId) return false;
|
||||
return group?.courseAuthorId === backendUser.id;
|
||||
}, [backendUser, group]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!courseId) return;
|
||||
const [groupData, courseData] = await Promise.all([
|
||||
api.getDefaultCourseGroup(courseId),
|
||||
api.getCourse(courseId).catch(() => null),
|
||||
]);
|
||||
setGroup({
|
||||
...(groupData.group || {}),
|
||||
courseAuthorId: courseData?.authorId,
|
||||
});
|
||||
setMessages(groupData.messages || []);
|
||||
setMembers(groupData.members || []);
|
||||
if (courseData?.authorId && backendUser?.id === courseData.authorId) {
|
||||
const list = await api.getHomeworkSubmissions(courseId).catch(() => []);
|
||||
setSubmissions(list);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [courseId, backendUser?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group?.id) 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: group.id });
|
||||
socket.on('groups:new-message', (msg: any) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
});
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [group?.id]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!group?.id || !message.trim()) return;
|
||||
await api.sendGroupMessage(group.id, message.trim());
|
||||
setMessage('');
|
||||
const latest = await api.getGroupMessages(group.id).catch(() => []);
|
||||
setMessages(latest);
|
||||
};
|
||||
|
||||
const createInvite = async () => {
|
||||
if (!group?.id) return;
|
||||
const res = await api.createGroupInviteLink(group.id);
|
||||
setInviteLink(res.inviteUrl);
|
||||
};
|
||||
|
||||
const submitReview = async (submissionId: string) => {
|
||||
const state = reviewState[submissionId];
|
||||
if (!state) return;
|
||||
await api.reviewHomeworkSubmission(courseId, submissionId, {
|
||||
teacherScore: state.score,
|
||||
teacherFeedback: state.feedback || undefined,
|
||||
});
|
||||
const list = await api.getHomeworkSubmissions(courseId).catch(() => []);
|
||||
setSubmissions(list);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Группа курса</h1>
|
||||
<p className="text-muted-foreground">Чат участников и проверка домашних заданий</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<Card className="min-h-[460px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{group?.name || 'Основная группа'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[400px] flex-col">
|
||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">{msg.user?.name || 'Участник'}</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Сообщение в чат"
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button onClick={sendMessage}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Участники ({members.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}</p>
|
||||
</div>
|
||||
))}
|
||||
{isAuthor && (
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
<Button variant="outline" onClick={createInvite} className="w-full">
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Создать invite-ссылку
|
||||
</Button>
|
||||
{inviteLink ? <p className="text-xs break-all text-muted-foreground">{inviteLink}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isAuthor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Проверка домашних заданий</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{submissions.map((submission) => (
|
||||
<div key={submission.id} className="rounded-md border p-3 space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{submission.user?.name || submission.user?.email} • {submission.homework?.lesson?.title}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{submission.content}</p>
|
||||
{submission.aiScore ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
AI-предоценка: {submission.aiScore}/5 {submission.aiFeedback ? `• ${submission.aiFeedback}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||
value={reviewState[submission.id]?.score ?? submission.teacherScore ?? 5}
|
||||
onChange={(e) =>
|
||||
setReviewState((prev) => ({
|
||||
...prev,
|
||||
[submission.id]: {
|
||||
score: Number(e.target.value),
|
||||
feedback: prev[submission.id]?.feedback || submission.teacherFeedback || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="flex-1 rounded-md border bg-background px-3 py-1 text-sm"
|
||||
value={reviewState[submission.id]?.feedback ?? submission.teacherFeedback ?? ''}
|
||||
onChange={(e) =>
|
||||
setReviewState((prev) => ({
|
||||
...prev,
|
||||
[submission.id]: {
|
||||
score: prev[submission.id]?.score ?? submission.teacherScore ?? 5,
|
||||
feedback: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Комментарий преподавателя"
|
||||
/>
|
||||
<Button size="sm" onClick={() => submitReview(submission.id)}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{submissions.length === 0 ? <p className="text-sm text-muted-foreground">Пока нет отправленных ДЗ</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,23 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Trash2,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Lock,
|
||||
BookOpen,
|
||||
Clock,
|
||||
Edit,
|
||||
GraduationCap,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Lock,
|
||||
Play,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
@ -32,94 +30,162 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
||||
import { LessonQuiz } from '@/components/dashboard/lesson-quiz';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
|
||||
type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] };
|
||||
type Chapter = { id: string; title: string; order: number; lessons: Lesson[] };
|
||||
type CourseData = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
authorId: string;
|
||||
chapters: Chapter[];
|
||||
};
|
||||
|
||||
type LessonProgressRow = {
|
||||
lessonId: string;
|
||||
quizPassed?: boolean;
|
||||
homeworkSubmitted?: boolean;
|
||||
quizScore?: number | null;
|
||||
};
|
||||
|
||||
type HomeworkState = {
|
||||
homework: { id: string; title: string; description: string } | null;
|
||||
submission: { id: string; content: string; aiScore?: number | null; aiFeedback?: string | null } | null;
|
||||
};
|
||||
|
||||
export default function CoursePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { loading: authLoading, backendUser } = useAuth();
|
||||
const id = params?.id as string;
|
||||
const { loading: authLoading, backendUser } = useAuth();
|
||||
|
||||
const [course, setCourse] = useState<CourseData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||||
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
|
||||
const [lessonContentLoading, setLessonContentLoading] = useState(false);
|
||||
const [completedLessons, setCompletedLessons] = useState<Set<string>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [enrollmentProgress, setEnrollmentProgress] = useState<any>(null);
|
||||
const [progressRows, setProgressRows] = useState<Record<string, LessonProgressRow>>({});
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
const [quizQuestions, setQuizQuestions] = useState<any[]>([]);
|
||||
const [quizLoading, setQuizLoading] = useState(false);
|
||||
const [homework, setHomework] = useState<HomeworkState>({ homework: null, submission: null });
|
||||
const [homeworkLoading, setHomeworkLoading] = useState(false);
|
||||
const [homeworkSubmitting, setHomeworkSubmitting] = useState(false);
|
||||
const [homeworkContent, setHomeworkContent] = useState('');
|
||||
|
||||
// Flat list of all lessons
|
||||
const flatLessons = useMemo(() => {
|
||||
if (!course) return [];
|
||||
return course.chapters
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.flatMap((ch) =>
|
||||
ch.lessons.sort((a, b) => a.order - b.order).map((l) => ({ ...l, chapterId: ch.id, chapterTitle: ch.title }))
|
||||
.flatMap((chapter) =>
|
||||
chapter.lessons
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((lesson) => ({
|
||||
...lesson,
|
||||
chapterId: chapter.id,
|
||||
chapterTitle: chapter.title,
|
||||
}))
|
||||
);
|
||||
}, [course]);
|
||||
|
||||
const currentLessonIndex = flatLessons.findIndex((l) => l.id === selectedLessonId);
|
||||
const currentLessonIndex = flatLessons.findIndex((lesson) => lesson.id === selectedLessonId);
|
||||
const totalLessons = flatLessons.length;
|
||||
const completedCount = completedLessons.size;
|
||||
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
|
||||
const isAuthor = Boolean(course && backendUser && course.authorId === backendUser.id);
|
||||
|
||||
const isLessonDone = (lessonId: string): boolean => {
|
||||
const row = progressRows[lessonId];
|
||||
return Boolean(row?.quizPassed && row?.homeworkSubmitted);
|
||||
};
|
||||
|
||||
const isLessonUnlocked = (lessonId: string): boolean => {
|
||||
if (isAuthor) return true;
|
||||
const idx = flatLessons.findIndex((lesson) => lesson.id === lessonId);
|
||||
if (idx <= 0) return true;
|
||||
const prevLesson = flatLessons[idx - 1];
|
||||
return isLessonDone(prevLesson.id);
|
||||
};
|
||||
|
||||
const completedCount = flatLessons.filter((lesson) => isLessonDone(lesson.id)).length;
|
||||
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
|
||||
const activeLesson = selectedLessonId ? flatLessons.find((lesson) => lesson.id === selectedLessonId) : null;
|
||||
const activeProgress = selectedLessonId ? progressRows[selectedLessonId] : undefined;
|
||||
const courseCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
||||
|
||||
const refreshProgress = async (courseId: string) => {
|
||||
const progressData = await api.getEnrollmentProgress(courseId).catch(() => null);
|
||||
const map: Record<string, LessonProgressRow> = {};
|
||||
(progressData?.lessons || []).forEach((row: any) => {
|
||||
map[String(row.lessonId)] = row;
|
||||
});
|
||||
setProgressRows(map);
|
||||
return map;
|
||||
};
|
||||
|
||||
const loadHomework = async (courseId: string, lessonId: string) => {
|
||||
setHomeworkLoading(true);
|
||||
try {
|
||||
const data = await api.getLessonHomework(courseId, lessonId);
|
||||
setHomework({ homework: data.homework || null, submission: data.submission || null });
|
||||
setHomeworkContent(data.submission?.content || '');
|
||||
} catch {
|
||||
setHomework({ homework: null, submission: null });
|
||||
setHomeworkContent('');
|
||||
} finally {
|
||||
setHomeworkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load course and progress
|
||||
useEffect(() => {
|
||||
if (!id || authLoading) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [courseData, progressData] = await Promise.all([
|
||||
api.getCourse(id),
|
||||
api.getEnrollmentProgress(id).catch(() => null),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setCourse(courseData);
|
||||
setEnrollmentProgress(progressData);
|
||||
if (progressData?.lessons) {
|
||||
const completed: Set<string> = new Set(
|
||||
progressData.lessons
|
||||
.filter((l: any) => l.completedAt)
|
||||
.map((l: any) => String(l.lessonId))
|
||||
);
|
||||
setCompletedLessons(completed);
|
||||
}
|
||||
const first = courseData.chapters?.[0]?.lessons?.[0];
|
||||
if (first) setSelectedLessonId(first.id);
|
||||
setExpandedChapters(courseData.chapters.map((ch: Chapter) => ch.id));
|
||||
}
|
||||
const courseData = await api.getCourse(id);
|
||||
if (cancelled) return;
|
||||
setCourse(courseData);
|
||||
setExpandedChapters((courseData.chapters || []).map((ch: Chapter) => ch.id));
|
||||
|
||||
const map = await refreshProgress(id);
|
||||
|
||||
const ordered = (courseData.chapters || [])
|
||||
.sort((a: Chapter, b: Chapter) => a.order - b.order)
|
||||
.flatMap((chapter: Chapter) =>
|
||||
(chapter.lessons || [])
|
||||
.sort((a: Lesson, b: Lesson) => a.order - b.order)
|
||||
.map((lesson: Lesson) => lesson.id)
|
||||
);
|
||||
const firstUnlocked = ordered.find((lessonId: string) => {
|
||||
if (isAuthor) return true;
|
||||
const idx = ordered.indexOf(lessonId);
|
||||
if (idx <= 0) return true;
|
||||
const prevLessonId = ordered[idx - 1];
|
||||
const prevRow = map[prevLessonId];
|
||||
return Boolean(prevRow?.quizPassed && prevRow?.homeworkSubmitted);
|
||||
});
|
||||
setSelectedLessonId(firstUnlocked || ordered[0] || null);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, authLoading]);
|
||||
|
||||
// Load lesson content
|
||||
useEffect(() => {
|
||||
if (!id || !selectedLessonId) {
|
||||
setLessonContent(null);
|
||||
@ -127,25 +193,43 @@ export default function CoursePage() {
|
||||
}
|
||||
let cancelled = false;
|
||||
setLessonContentLoading(true);
|
||||
setShowQuiz(false);
|
||||
setQuizQuestions([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.getLesson(id, selectedLessonId);
|
||||
const content = data?.content;
|
||||
if (!cancelled)
|
||||
if (!cancelled) {
|
||||
setLessonContent(
|
||||
typeof content === 'object' && content !== null ? (content as Record<string, unknown>) : null
|
||||
typeof data?.content === 'object' && data.content !== null
|
||||
? (data.content as Record<string, unknown>)
|
||||
: null
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setLessonContent(null);
|
||||
} finally {
|
||||
if (!cancelled) setLessonContentLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, selectedLessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !selectedLessonId) return;
|
||||
if (activeProgress?.quizPassed && !activeProgress?.homeworkSubmitted) {
|
||||
loadHomework(id, selectedLessonId);
|
||||
return;
|
||||
}
|
||||
setHomework({ homework: null, submission: null });
|
||||
setHomeworkContent('');
|
||||
}, [id, selectedLessonId, activeProgress?.quizPassed, activeProgress?.homeworkSubmitted]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!course?.id || deleting) return;
|
||||
if (!course || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteCourse(course.id);
|
||||
@ -158,95 +242,86 @@ export default function CoursePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const markComplete = async () => {
|
||||
if (!selectedLessonId || !id) return;
|
||||
try {
|
||||
await api.completeLesson(id, selectedLessonId);
|
||||
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
|
||||
} catch {
|
||||
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartQuiz = async () => {
|
||||
if (!selectedLessonId || !id) return;
|
||||
if (!id || !selectedLessonId || quizLoading) return;
|
||||
setQuizLoading(true);
|
||||
try {
|
||||
const quiz = await api.getLessonQuiz(id, selectedLessonId);
|
||||
setQuizQuestions(quiz.questions || []);
|
||||
const data = await api.getLessonQuiz(id, selectedLessonId);
|
||||
setQuizQuestions(Array.isArray(data?.questions) ? data.questions : []);
|
||||
setShowQuiz(true);
|
||||
} catch {
|
||||
setQuizQuestions([]);
|
||||
} finally {
|
||||
setQuizLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuizComplete = async (score: number) => {
|
||||
if (!selectedLessonId || !id) return;
|
||||
const handleQuizComplete = async (answers: number[]) => {
|
||||
if (!id || !selectedLessonId) {
|
||||
return { score: 0, passed: false };
|
||||
}
|
||||
const result = await api.submitQuizAnswers(id, selectedLessonId, answers);
|
||||
setProgressRows((prev) => ({
|
||||
...prev,
|
||||
[selectedLessonId]: result.progress || {
|
||||
lessonId: selectedLessonId,
|
||||
quizPassed: Boolean(result.passed),
|
||||
},
|
||||
}));
|
||||
|
||||
if (result.passed) {
|
||||
await loadHomework(id, selectedLessonId);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleSubmitHomework = async () => {
|
||||
if (!id || !selectedLessonId || !homeworkContent.trim() || homeworkSubmitting) return;
|
||||
setHomeworkSubmitting(true);
|
||||
try {
|
||||
await api.submitQuizScore(id, selectedLessonId, score);
|
||||
markComplete();
|
||||
} catch {
|
||||
markComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetCertificate = () => {
|
||||
if (!id) return;
|
||||
window.open(`/certificate/${id}`, '_blank');
|
||||
};
|
||||
|
||||
const goToNextLesson = () => {
|
||||
if (currentLessonIndex < flatLessons.length - 1) {
|
||||
markComplete();
|
||||
setSelectedLessonId(flatLessons[currentLessonIndex + 1].id);
|
||||
const submission = await api.submitLessonHomework(id, selectedLessonId, homeworkContent.trim());
|
||||
setHomework((prev) => ({ ...prev, submission }));
|
||||
await refreshProgress(id);
|
||||
} finally {
|
||||
setHomeworkSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPrevLesson = () => {
|
||||
if (currentLessonIndex > 0) {
|
||||
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
|
||||
}
|
||||
if (currentLessonIndex <= 0) return;
|
||||
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
|
||||
};
|
||||
|
||||
const toggleChapter = (chapterId: string) => {
|
||||
setExpandedChapters((prev) =>
|
||||
prev.includes(chapterId)
|
||||
? prev.filter((id) => id !== chapterId)
|
||||
: [...prev, chapterId]
|
||||
);
|
||||
const goToNextLesson = () => {
|
||||
if (currentLessonIndex < 0 || currentLessonIndex >= flatLessons.length - 1) return;
|
||||
if (!selectedLessonId || !isLessonDone(selectedLessonId)) return;
|
||||
const nextLesson = flatLessons[currentLessonIndex + 1];
|
||||
if (!isLessonUnlocked(nextLesson.id)) return;
|
||||
setSelectedLessonId(nextLesson.id);
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Загрузка курса...</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !course) {
|
||||
if (!course || error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="p-6">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/dashboard"><ArrowLeft className="mr-2 h-4 w-4" />Назад к курсам</Link>
|
||||
<Link href="/dashboard">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Назад
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-destructive">{error || 'Курс не найден'}</p>
|
||||
<p className="text-destructive mt-4">{error || 'Курс не найден'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeLessonMeta = selectedLessonId
|
||||
? flatLessons.find((l) => l.id === selectedLessonId)
|
||||
: null;
|
||||
|
||||
const isAuthor = course && backendUser && course.authorId === backendUser.id;
|
||||
const courseCompleted = completedCount >= totalLessons && totalLessons > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-2.5 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
@ -258,25 +333,21 @@ export default function CoursePage() {
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium truncate max-w-[300px]">{course.title}</span>
|
||||
<span className="font-medium truncate max-w-[280px]">{course.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Progress badge */}
|
||||
<div className="hidden sm:flex items-center gap-2 mr-2 px-3 py-1.5 bg-muted rounded-full">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
<span className="text-xs font-medium">{progressPercent}% пройдено</span>
|
||||
</div>
|
||||
|
||||
{/* Certificate button - show when course completed */}
|
||||
{courseCompleted && (
|
||||
<Button size="sm" variant="default" onClick={handleGetCertificate}>
|
||||
<GraduationCap className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Button size="sm" onClick={() => window.open(`/certificate/${id}`, '_blank')}>
|
||||
Получить сертификат
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit/Delete - only for author */}
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/dashboard/courses/${id}/group`}>Группа курса</Link>
|
||||
</Button>
|
||||
{isAuthor && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
@ -301,7 +372,10 @@ export default function CoursePage() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => { e.preventDefault(); handleDelete(); }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={deleting}
|
||||
>
|
||||
@ -316,7 +390,6 @@ export default function CoursePage() {
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 min-h-0">
|
||||
{/* ─── Left sidebar: course navigation ─── */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-r bg-muted/10 flex flex-col transition-all duration-300 ease-in-out',
|
||||
@ -325,7 +398,6 @@ export default function CoursePage() {
|
||||
>
|
||||
{sidebarOpen && (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Course progress */}
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Прогресс курса</span>
|
||||
@ -333,79 +405,67 @@ export default function CoursePage() {
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Chapters & lessons */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{course.chapters
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((chapter, chapterIdx) => {
|
||||
.map((chapter) => {
|
||||
const isExpanded = expandedChapters.includes(chapter.id);
|
||||
const chapterLessons = chapter.lessons.sort((a, b) => a.order - b.order);
|
||||
const chapterComplete = chapterLessons.every((l) => completedLessons.has(l.id));
|
||||
const chapterStarted = chapterLessons.some((l) => completedLessons.has(l.id));
|
||||
|
||||
return (
|
||||
<div key={chapter.id} className="mb-1">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
onClick={() => toggleChapter(chapter.id)}
|
||||
onClick={() =>
|
||||
setExpandedChapters((prev) =>
|
||||
prev.includes(chapter.id) ? prev.filter((id) => id !== chapter.id) : [...prev, chapter.id]
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
|
||||
chapterComplete
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: chapterStarted
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
{chapterComplete ? <CheckCircle2 className="h-4 w-4" /> : chapterIdx + 1}
|
||||
</div>
|
||||
<BookOpen className="h-4 w-4 text-primary" />
|
||||
<span className="flex-1 text-left font-medium truncate">{chapter.title}</span>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', isExpanded && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-4 border-l-2 border-muted pl-2 mb-2">
|
||||
{chapterLessons.map((lesson, lessonIdx) => {
|
||||
const isActive = selectedLessonId === lesson.id;
|
||||
const isCompleted = completedLessons.has(lesson.id);
|
||||
const globalIdx = flatLessons.findIndex((l) => l.id === lesson.id);
|
||||
// Lesson is locked if sequential mode: all previous must be complete
|
||||
// For now, don't lock (allow free navigation)
|
||||
const isLocked = false;
|
||||
{chapter.lessons
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((lesson) => {
|
||||
const isActive = selectedLessonId === lesson.id;
|
||||
const isDone = isLessonDone(lesson.id);
|
||||
const unlocked = isLessonUnlocked(lesson.id);
|
||||
const isLocked = !unlocked;
|
||||
const row = progressRows[lesson.id];
|
||||
const quizOnly = row?.quizPassed && !row?.homeworkSubmitted;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 w-full rounded-lg px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: isLocked
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: 'hover:bg-muted/80 text-foreground/80'
|
||||
)}
|
||||
onClick={() => !isLocked && setSelectedLessonId(lesson.id)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary-foreground' : 'text-green-500')} />
|
||||
) : isLocked ? (
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
) : isActive ? (
|
||||
<Play className="h-4 w-4 shrink-0 fill-current" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-left">{lesson.title}</span>
|
||||
{lesson.durationMinutes && (
|
||||
<span className={cn('ml-auto text-xs shrink-0', isActive ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
|
||||
{lesson.durationMinutes} мин
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 w-full rounded-lg px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: isLocked
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: 'hover:bg-muted/80 text-foreground/80'
|
||||
)}
|
||||
onClick={() => !isLocked && setSelectedLessonId(lesson.id)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary-foreground' : 'text-green-500')} />
|
||||
) : isLocked ? (
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
) : isActive ? (
|
||||
<Play className="h-4 w-4 shrink-0 fill-current" />
|
||||
) : quizOnly ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-left">{lesson.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -416,41 +476,31 @@ export default function CoursePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-all',
|
||||
)}
|
||||
className="absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-all"
|
||||
style={{ left: sidebarOpen ? '19.9rem' : 0 }}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
{/* ─── Main content area ─── */}
|
||||
<main className="flex-1 flex flex-col min-h-0">
|
||||
{/* Lesson content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-3xl mx-auto w-full px-8 py-10">
|
||||
{/* Chapter & lesson header */}
|
||||
{activeLessonMeta && (
|
||||
{activeLesson && (
|
||||
<div className="mb-8">
|
||||
<p className="text-sm text-primary font-medium mb-1">
|
||||
{activeLessonMeta.chapterTitle}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
{activeLessonMeta.title}
|
||||
</h1>
|
||||
<p className="text-sm text-primary font-medium mb-1">{activeLesson.chapterTitle}</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">{activeLesson.title}</h1>
|
||||
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Урок {currentLessonIndex + 1} из {totalLessons}
|
||||
</span>
|
||||
{activeLessonMeta.durationMinutes && (
|
||||
{activeLesson.durationMinutes && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{activeLessonMeta.durationMinutes} мин
|
||||
{activeLesson.durationMinutes} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -458,26 +508,26 @@ export default function CoursePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{lessonContentLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : selectedLessonId ? (
|
||||
<>
|
||||
<LessonContentViewer
|
||||
content={lessonContent}
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
{!showQuiz && !completedLessons.has(selectedLessonId) && (
|
||||
<LessonContentViewer content={lessonContent} className="min-h-[320px]" />
|
||||
|
||||
{!activeProgress?.quizPassed && (
|
||||
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center">
|
||||
<h3 className="font-semibold mb-2">Проверьте свои знания</h3>
|
||||
<h3 className="font-semibold mb-2">Шаг 1 из 2: тест</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Пройдите тест, чтобы закрепить материал и получить сертификат
|
||||
Для открытия следующего урока нужно пройти тест и отправить письменное ДЗ.
|
||||
</p>
|
||||
<Button onClick={handleStartQuiz}>Начать тест</Button>
|
||||
<Button onClick={handleStartQuiz} disabled={quizLoading}>
|
||||
{quizLoading ? 'Загрузка теста...' : 'Начать тест'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuiz && (
|
||||
<LessonQuiz
|
||||
courseId={id}
|
||||
@ -486,54 +536,79 @@ export default function CoursePage() {
|
||||
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 ? (
|
||||
<p className="text-sm text-muted-foreground">Подготовка задания...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{homework.homework?.title || 'Домашнее задание'}
|
||||
</p>
|
||||
<p className="text-sm mb-4">
|
||||
{homework.homework?.description ||
|
||||
'Опишите применение материала урока на практике и приведите минимум один пример.'}
|
||||
</p>
|
||||
<textarea
|
||||
value={homeworkContent}
|
||||
onChange={(e) => setHomeworkContent(e.target.value)}
|
||||
className="w-full min-h-[180px] rounded-md border bg-background p-3 text-sm"
|
||||
placeholder="Ваш ответ..."
|
||||
disabled={Boolean(activeProgress?.homeworkSubmitted)}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Минимум 50 символов</p>
|
||||
<Button
|
||||
onClick={handleSubmitHomework}
|
||||
disabled={homeworkSubmitting || Boolean(activeProgress?.homeworkSubmitted)}
|
||||
>
|
||||
{activeProgress?.homeworkSubmitted
|
||||
? 'ДЗ отправлено'
|
||||
: homeworkSubmitting
|
||||
? 'Отправка...'
|
||||
: 'Отправить ДЗ'}
|
||||
</Button>
|
||||
</div>
|
||||
{homework.submission?.aiScore ? (
|
||||
<div className="mt-3 rounded-md border bg-background p-3 text-sm">
|
||||
<p className="font-medium">AI-предоценка: {homework.submission.aiScore}/5</p>
|
||||
{homework.submission.aiFeedback ? (
|
||||
<p className="text-muted-foreground mt-1">{homework.submission.aiFeedback}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<BookOpen className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground">Выберите урок для начала обучения</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground">Выберите урок</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="shrink-0 border-t bg-background px-6 py-3">
|
||||
<div className="max-w-3xl mx-auto flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPrevLesson}
|
||||
disabled={currentLessonIndex <= 0}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={goToPrevLesson} disabled={currentLessonIndex <= 0}>
|
||||
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
||||
Предыдущий
|
||||
</Button>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedLessonId && isLessonDone(selectedLessonId)
|
||||
? 'Урок завершён'
|
||||
: 'Для следующего урока нужны тест + ДЗ'}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markComplete}
|
||||
disabled={!selectedLessonId || completedLessons.has(selectedLessonId)}
|
||||
className={cn(
|
||||
completedLessons.has(selectedLessonId ?? '')
|
||||
? 'text-green-600'
|
||||
: 'text-muted-foreground hover:text-primary'
|
||||
)}
|
||||
onClick={goToNextLesson}
|
||||
disabled={currentLessonIndex >= flatLessons.length - 1 || !selectedLessonId || !isLessonDone(selectedLessonId)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-4 w-4" />
|
||||
{completedLessons.has(selectedLessonId ?? '') ? 'Пройден' : 'Отметить пройденным'}
|
||||
Следующий урок
|
||||
<ChevronRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{currentLessonIndex < flatLessons.length - 1 ? (
|
||||
<Button size="sm" onClick={goToNextLesson}>
|
||||
Следующий урок
|
||||
<ChevronRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{courseCompleted ? 'Курс пройден!' : 'Последний урок'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
100
apps/web/src/app/(dashboard)/dashboard/groups/[groupId]/page.tsx
Normal file
100
apps/web/src/app/(dashboard)/dashboard/groups/[groupId]/page.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
|
||||
export default function InviteGroupPage() {
|
||||
const params = useParams();
|
||||
const groupId = params?.groupId as string;
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
(async () => {
|
||||
await api.joinGroupByInvite(groupId).catch(() => null);
|
||||
const [msgs, mbrs] = await Promise.all([
|
||||
api.getGroupMessages(groupId).catch(() => []),
|
||||
api.getGroupMembers(groupId).catch(() => []),
|
||||
]);
|
||||
setMessages(msgs);
|
||||
setMembers(mbrs);
|
||||
})();
|
||||
}, [groupId]);
|
||||
|
||||
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', (msg: any) => setMessages((prev) => [...prev, msg]));
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
const send = async () => {
|
||||
if (!groupId || !message.trim()) return;
|
||||
await api.sendGroupMessage(groupId, message.trim());
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<Card className="min-h-[460px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Групповой чат курса</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[400px] flex-col">
|
||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">{msg.user?.name || 'Участник'}</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Сообщение"
|
||||
/>
|
||||
<Button onClick={send}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Участники ({members.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
apps/web/src/app/(dashboard)/dashboard/support/page.tsx
Normal file
154
apps/web/src/app/(dashboard)/dashboard/support/page.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { MessageCircle, Plus, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
|
||||
export default function SupportPage() {
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [ticketTitle, setTicketTitle] = useState('');
|
||||
const [ticketMessage, setTicketMessage] = useState('');
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const selectedTicket = useMemo(
|
||||
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||
[tickets, selectedTicketId]
|
||||
);
|
||||
|
||||
const loadTickets = async () => {
|
||||
const data = await api.getMySupportTickets().catch(() => []);
|
||||
setTickets(data);
|
||||
if (!selectedTicketId && data.length > 0) {
|
||||
setSelectedTicketId(data[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTickets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTicketId) return;
|
||||
api.getSupportTicketMessages(selectedTicketId).then((data) => setMessages(data)).catch(() => setMessages([]));
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('support:join', { ticketId: selectedTicketId });
|
||||
socket.on('support:new-message', (message: any) => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [selectedTicketId]);
|
||||
|
||||
const createTicket = async () => {
|
||||
if (!ticketTitle.trim()) return;
|
||||
await api.createSupportTicket({ title: ticketTitle.trim(), initialMessage: ticketMessage.trim() || undefined });
|
||||
setTicketTitle('');
|
||||
setTicketMessage('');
|
||||
await loadTickets();
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!selectedTicketId || !newMessage.trim()) return;
|
||||
await api.sendSupportMessage(selectedTicketId, newMessage.trim());
|
||||
setNewMessage('');
|
||||
const latest = await api.getSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||
setMessages(latest);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Тикеты поддержки</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={ticketTitle}
|
||||
onChange={(e) => setTicketTitle(e.target.value)}
|
||||
placeholder="Тема тикета"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={ticketMessage}
|
||||
onChange={(e) => setTicketMessage(e.target.value)}
|
||||
placeholder="Опишите проблему"
|
||||
className="w-full min-h-[90px] rounded-md border bg-background p-3 text-sm"
|
||||
/>
|
||||
<Button onClick={createTicket} className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать тикет
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{tickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm ${
|
||||
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedTicketId(ticket.id)}
|
||||
>
|
||||
<p className="font-medium">{ticket.title}</p>
|
||||
<p className="text-xs text-muted-foreground">Статус: {ticket.status}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[520px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{selectedTicket ? `Чат тикета: ${selectedTicket.title}` : 'Выберите тикет'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[430px] flex-col">
|
||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">
|
||||
{msg.user?.name || 'Пользователь'} {msg.isStaff ? '(Поддержка)' : ''}
|
||||
</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Сообщений пока нет
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Сообщение"
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button onClick={sendMessage} disabled={!selectedTicketId}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -33,7 +33,7 @@ interface CourseCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: 'DRAFT' | 'GENERATING' | 'PUBLISHED' | 'ARCHIVED';
|
||||
status: 'DRAFT' | 'GENERATING' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | 'ARCHIVED';
|
||||
chaptersCount: number;
|
||||
lessonsCount: number;
|
||||
updatedAt: string;
|
||||
@ -55,6 +55,14 @@ const statusConfig = {
|
||||
label: 'Опубликован',
|
||||
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
},
|
||||
PENDING_REVIEW: {
|
||||
label: 'На модерации',
|
||||
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
},
|
||||
REJECTED: {
|
||||
label: 'Отклонён',
|
||||
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: 'Архив',
|
||||
className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, XCircle, Trophy } from 'lucide-react';
|
||||
import { CheckCircle2, XCircle, Trophy, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuizQuestion {
|
||||
@ -13,36 +13,50 @@ interface QuizQuestion {
|
||||
correctAnswer: number;
|
||||
}
|
||||
|
||||
interface QuizSubmitResult {
|
||||
score: number;
|
||||
passed: boolean;
|
||||
correctAnswers?: number;
|
||||
totalQuestions?: number;
|
||||
}
|
||||
|
||||
interface LessonQuizProps {
|
||||
courseId: string;
|
||||
lessonId: string;
|
||||
questions: QuizQuestion[];
|
||||
onComplete: (score: number) => void;
|
||||
onComplete: (answers: number[]) => Promise<QuizSubmitResult>;
|
||||
}
|
||||
|
||||
export function LessonQuiz({ courseId, lessonId, questions, onComplete }: LessonQuizProps) {
|
||||
export function LessonQuiz({ questions, onComplete }: LessonQuizProps) {
|
||||
const [currentQuestion, setCurrentQuestion] = useState(0);
|
||||
const [answers, setAnswers] = useState<number[]>([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [serverResult, setServerResult] = useState<QuizSubmitResult | null>(null);
|
||||
|
||||
const handleSelectAnswer = (optionIndex: number) => {
|
||||
setSelectedAnswer(optionIndex);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (selectedAnswer === null) return;
|
||||
const handleNext = async () => {
|
||||
if (selectedAnswer === null || submitting) return;
|
||||
const newAnswers = [...answers, selectedAnswer];
|
||||
setAnswers(newAnswers);
|
||||
|
||||
if (currentQuestion < questions.length - 1) {
|
||||
setCurrentQuestion(currentQuestion + 1);
|
||||
setSelectedAnswer(null);
|
||||
} else {
|
||||
const correct = newAnswers.filter((ans, idx) => ans === questions[idx].correctAnswer).length;
|
||||
const score = Math.round((correct / questions.length) * 100);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await onComplete(newAnswers);
|
||||
setServerResult(result);
|
||||
setShowResults(true);
|
||||
onComplete(score);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -51,37 +65,43 @@ export function LessonQuiz({ courseId, lessonId, questions, onComplete }: Lesson
|
||||
}
|
||||
|
||||
if (showResults) {
|
||||
const correct = answers.filter((ans, idx) => ans === questions[idx].correctAnswer).length;
|
||||
const score = Math.round((correct / questions.length) * 100);
|
||||
const passed = score >= 70;
|
||||
const score = serverResult?.score ?? 0;
|
||||
const passed = Boolean(serverResult?.passed);
|
||||
const correct =
|
||||
serverResult?.correctAnswers ??
|
||||
answers.filter((ans, idx) => ans === questions[idx].correctAnswer).length;
|
||||
const total = serverResult?.totalQuestions ?? questions.length;
|
||||
|
||||
return (
|
||||
<Card className="mt-8">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className={cn(
|
||||
'mx-auto h-16 w-16 rounded-full flex items-center justify-center mb-4',
|
||||
passed ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto h-16 w-16 rounded-full flex items-center justify-center mb-4',
|
||||
passed ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
)}
|
||||
>
|
||||
{passed ? (
|
||||
<Trophy className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-8 w-8 text-yellow-600 dark:text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">
|
||||
{passed ? 'Отлично!' : 'Неплохо!'}
|
||||
</h3>
|
||||
<h3 className="text-xl font-bold mb-2">{passed ? 'Тест пройден' : 'Тест не пройден'}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Ваш результат: {correct} из {questions.length} ({score}%)
|
||||
Ваш результат: {correct} из {total} ({score}%)
|
||||
</p>
|
||||
<div className="space-y-2 max-w-md mx-auto">
|
||||
{questions.map((q, idx) => {
|
||||
const isCorrect = answers[idx] === q.correctAnswer;
|
||||
return (
|
||||
<div key={q.id} className={cn(
|
||||
'flex items-center gap-2 p-2 rounded text-sm',
|
||||
isCorrect ? 'bg-green-50 dark:bg-green-900/30' : 'bg-red-50 dark:bg-red-900/30'
|
||||
)}>
|
||||
<div
|
||||
key={q.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded text-sm',
|
||||
isCorrect ? 'bg-green-50 dark:bg-green-900/30' : 'bg-red-50 dark:bg-red-900/30'
|
||||
)}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
@ -102,9 +122,7 @@ export function LessonQuiz({ courseId, lessonId, questions, onComplete }: Lesson
|
||||
return (
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Тест по уроку ({currentQuestion + 1}/{questions.length})
|
||||
</CardTitle>
|
||||
<CardTitle>Тест по уроку ({currentQuestion + 1}/{questions.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="font-medium">{question.question}</p>
|
||||
@ -115,21 +133,24 @@ export function LessonQuiz({ courseId, lessonId, questions, onComplete }: Lesson
|
||||
onClick={() => handleSelectAnswer(idx)}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border text-left transition-colors',
|
||||
selectedAnswer === idx
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-muted'
|
||||
selectedAnswer === idx ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={selectedAnswer === null}
|
||||
className="w-full"
|
||||
>
|
||||
{currentQuestion < questions.length - 1 ? 'Следующий вопрос' : 'Завершить тест'}
|
||||
<Button onClick={handleNext} disabled={selectedAnswer === null || submitting} className="w-full">
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Проверка...
|
||||
</>
|
||||
) : currentQuestion < questions.length - 1 ? (
|
||||
'Следующий вопрос'
|
||||
) : (
|
||||
'Завершить тест'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -11,15 +11,18 @@ import {
|
||||
Settings,
|
||||
CreditCard,
|
||||
Plus,
|
||||
LifeBuoy,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen, exact: true },
|
||||
{ name: 'Каталог', href: '/dashboard/catalog', icon: Compass },
|
||||
{ name: 'Курсы', href: '/dashboard/catalog', icon: Compass },
|
||||
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
|
||||
{ name: 'Поддержка', href: '/dashboard/support', icon: LifeBuoy },
|
||||
];
|
||||
|
||||
const bottomNavigation = [
|
||||
@ -29,6 +32,12 @@ const bottomNavigation = [
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { backendUser } = useAuth();
|
||||
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
||||
|
||||
const effectiveBottomNavigation = isStaff
|
||||
? [{ name: 'Админ поддержка', href: '/dashboard/admin/support', icon: LifeBuoy }, ...bottomNavigation]
|
||||
: bottomNavigation;
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:border-r lg:bg-muted/30">
|
||||
@ -78,7 +87,7 @@ export function Sidebar() {
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<nav className="p-4 border-t space-y-1">
|
||||
{bottomNavigation.map((item) => {
|
||||
{effectiveBottomNavigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
|
||||
@ -12,6 +12,7 @@ interface BackendUser {
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
subscriptionTier: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
|
||||
@ -254,6 +254,10 @@ class ApiClient {
|
||||
return this.request<any>(`/catalog/${id}/submit`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async checkoutCourse(id: string) {
|
||||
return this.request<{ url: string }>(`/catalog/${id}/checkout`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async toggleCourseVerification(id: string) {
|
||||
return this.request<any>(`/catalog/${id}/verify`, { method: 'PATCH' });
|
||||
}
|
||||
@ -275,10 +279,21 @@ class ApiClient {
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/complete`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async submitQuizScore(courseId: string, lessonId: string, score: number) {
|
||||
async submitQuizAnswers(courseId: string, lessonId: string, answers: number[]) {
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/quiz`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ score }),
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
}
|
||||
|
||||
async getLessonHomework(courseId: string, lessonId: string) {
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`);
|
||||
}
|
||||
|
||||
async submitLessonHomework(courseId: string, lessonId: string, content: string) {
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
@ -305,6 +320,105 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async reviewHomeworkSubmission(
|
||||
courseId: string,
|
||||
submissionId: string,
|
||||
data: { teacherScore: number; teacherFeedback?: string }
|
||||
) {
|
||||
return this.request<any>(`/courses/${courseId}/homework-submissions/${submissionId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getHomeworkSubmissions(courseId: string) {
|
||||
return this.request<any[]>(`/courses/${courseId}/homework-submissions`);
|
||||
}
|
||||
|
||||
// Groups
|
||||
async getDefaultCourseGroup(courseId: string) {
|
||||
return this.request<any>(`/groups/course/${courseId}/default`);
|
||||
}
|
||||
|
||||
async getGroupMessages(groupId: string) {
|
||||
return this.request<any[]>(`/groups/${groupId}/messages`);
|
||||
}
|
||||
|
||||
async getGroupMembers(groupId: string) {
|
||||
return this.request<any[]>(`/groups/${groupId}/members`);
|
||||
}
|
||||
|
||||
async sendGroupMessage(groupId: string, content: string) {
|
||||
return this.request<any>(`/groups/${groupId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async createGroupInviteLink(groupId: string) {
|
||||
return this.request<{ inviteUrl: string }>(`/groups/${groupId}/invite-link`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async joinGroupByInvite(groupId: string) {
|
||||
return this.request<any>(`/groups/join/${groupId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Support
|
||||
async createSupportTicket(data: { title: string; initialMessage?: string; priority?: string }) {
|
||||
return this.request<any>('/support/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getMySupportTickets() {
|
||||
return this.request<any[]>('/support/tickets');
|
||||
}
|
||||
|
||||
async getSupportTicketMessages(id: string) {
|
||||
return this.request<any[]>(`/support/tickets/${id}/messages`);
|
||||
}
|
||||
|
||||
async sendSupportMessage(id: string, content: string) {
|
||||
return this.request<any>(`/support/tickets/${id}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminSupportTickets() {
|
||||
return this.request<any[]>('/support/admin/tickets');
|
||||
}
|
||||
|
||||
async getAdminSupportTicketMessages(id: string) {
|
||||
return this.request<any[]>(`/support/admin/tickets/${id}/messages`);
|
||||
}
|
||||
|
||||
async sendAdminSupportMessage(id: string, content: string) {
|
||||
return this.request<any>(`/support/admin/tickets/${id}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAdminSupportTicketStatus(id: string, status: string) {
|
||||
return this.request<any>(`/support/admin/tickets/${id}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
// Moderation reviews
|
||||
async hideReview(reviewId: string) {
|
||||
return this.request<any>(`/moderation/reviews/${reviewId}/hide`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async unhideReview(reviewId: string) {
|
||||
return this.request<any>(`/moderation/reviews/${reviewId}/unhide`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Search
|
||||
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
||||
const searchParams = new URLSearchParams({ q: query });
|
||||
|
||||
21
apps/web/src/lib/ws.ts
Normal file
21
apps/web/src/lib/ws.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export function getWsBaseUrl(): string {
|
||||
if (process.env.NEXT_PUBLIC_API_ORIGIN) {
|
||||
return process.env.NEXT_PUBLIC_API_ORIGIN;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return 'http://localhost:3125';
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location;
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return `${protocol}//${hostname}:3125`;
|
||||
}
|
||||
|
||||
// In production-like environments backend can be served behind same host
|
||||
// or exposed on a dedicated API origin via NEXT_PUBLIC_API_ORIGIN.
|
||||
if (port === '3080') {
|
||||
return `${protocol}//${hostname}:3125`;
|
||||
}
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user