fix: useState hook error in catalog + add certificate download in learning page
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -34,6 +34,7 @@ export default function PublicCoursePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [enrolling, setEnrolling] = useState(false);
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
const [enrolled, setEnrolled] = useState(false);
|
const [enrolled, setEnrolled] = useState(false);
|
||||||
|
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@ -221,12 +222,19 @@ export default function PublicCoursePage() {
|
|||||||
<h2 className="text-xl font-semibold mb-4">Содержание курса</h2>
|
<h2 className="text-xl font-semibold mb-4">Содержание курса</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{course.chapters.map((chapter: any) => {
|
{course.chapters.map((chapter: any) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const expanded = expandedChapters.includes(chapter.id);
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setExpandedChapters(prev =>
|
||||||
|
prev.includes(chapter.id)
|
||||||
|
? prev.filter(id => id !== chapter.id)
|
||||||
|
: [...prev, chapter.id]
|
||||||
|
);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div key={chapter.id} className="border rounded-lg overflow-hidden">
|
<div key={chapter.id} className="border rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors"
|
className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={toggleExpanded}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<BookOpen className="h-4 w-4 text-primary" />
|
<BookOpen className="h-4 w-4 text-primary" />
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { GraduationCap, BookOpen, Loader2, Trophy } from 'lucide-react';
|
import { GraduationCap, BookOpen, Loader2, Trophy, Download } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -28,6 +29,13 @@ export default function LearningPage() {
|
|||||||
const [enrollments, setEnrollments] = useState<EnrollmentData[]>([]);
|
const [enrollments, setEnrollments] = useState<EnrollmentData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const handleDownloadCertificate = async (courseId: string) => {
|
||||||
|
try {
|
||||||
|
const { certificateUrl } = await api.getCertificate(courseId);
|
||||||
|
window.open(certificateUrl, '_blank');
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading || !user) { setLoading(false); return; }
|
if (authLoading || !user) { setLoading(false); return; }
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -97,6 +105,17 @@ export default function LearningPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Progress value={enrollment.progress} className="h-1.5" />
|
<Progress value={enrollment.progress} className="h-1.5" />
|
||||||
</div>
|
</div>
|
||||||
|
{enrollment.completedAt && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mt-3"
|
||||||
|
onClick={(e) => { e.preventDefault(); handleDownloadCertificate(enrollment.course.id); }}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Скачать сертификат
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
88
apps/web/src/components/dashboard/course-chat.tsx
Normal file
88
apps/web/src/components/dashboard/course-chat.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Send, MessageCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: { id: string; name: string | null; avatarUrl: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourseChatProps {
|
||||||
|
groupId: string;
|
||||||
|
userId: string;
|
||||||
|
onSendMessage: (content: string) => Promise<void>;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseChat({ groupId, userId, onSendMessage, messages }: CourseChatProps) {
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!newMessage.trim() || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await onSendMessage(newMessage);
|
||||||
|
setNewMessage('');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col h-[500px]">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
Чат курса
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col min-h-0 p-4">
|
||||||
|
<div className="flex-1 overflow-auto space-y-3 mb-3">
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const isOwn = msg.user.id === userId;
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}>
|
||||||
|
<div className="h-8 w-8 shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
|
||||||
|
{msg.user.name?.[0] || 'U'}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex flex-col gap-1 max-w-[70%]', isOwn && 'items-end')}>
|
||||||
|
<span className="text-xs text-muted-foreground">{msg.user.name || 'Аноним'}</span>
|
||||||
|
<div className={cn('rounded-lg px-3 py-2 text-sm', isOwn ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||||
|
placeholder="Написать сообщение..."
|
||||||
|
className="flex-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={handleSend} disabled={sending || !newMessage.trim()}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user