feat: certificate page for print, API certificate data, AI prompts for full lessons

- Certificate: page /certificate/[courseId] with print-friendly design, GET /certificates/:courseId/data
- Buttons 'Получить сертификат' open certificate page instead of data-URL
- AI: prompts for longer courses (more chapters/lessons), full detailed lesson content with examples (1000–1500+ words)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-06 11:32:58 +00:00
parent 5241144bc5
commit c809d049fe
8 changed files with 192 additions and 61 deletions

View File

@ -0,0 +1,117 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Printer, Loader2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
interface CertificateData {
userName: string;
courseTitle: string;
completedAt: string;
}
export default function CertificatePage() {
const params = useParams();
const courseId = params?.courseId as string | undefined;
const { loading: authLoading, user } = useAuth();
const [data, setData] = useState<CertificateData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authLoading || !user || !courseId) {
setLoading(false);
return;
}
(async () => {
try {
const res = await api.getCertificateData(courseId);
setData(res);
} catch {
setError('Сертификат недоступен. Завершите курс и попробуйте снова.');
} finally {
setLoading(false);
}
})();
}, [authLoading, user, courseId]);
const handlePrint = () => {
window.print();
};
const dateStr = data?.completedAt
? new Date(data.completedAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
: '';
if (authLoading || loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100">
<Loader2 className="h-10 w-10 animate-spin text-indigo-600" />
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
<div className="text-center max-w-md">
<AlertCircle className="h-12 w-12 mx-auto text-amber-600 mb-4" />
<p className="text-lg text-slate-700">{error || 'Данные не найдены'}</p>
</div>
</div>
);
}
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `@media print { body * { visibility: hidden; } .certificate-wrap, .certificate-wrap * { visibility: visible; } .certificate-wrap { position: absolute; left: 0; top: 0; width: 100%; } .no-print { display: none !important; } }`,
}}
/>
<div className="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-700 flex flex-col items-center justify-center p-6 print:bg-white print:p-0">
<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" />
Печать
</Button>
</div>
<div className="certificate-wrap bg-white w-full max-w-[1000px] py-16 px-20 shadow-2xl border-[12px] border-slate-100 relative print:shadow-none print:border-0">
<div className="absolute inset-8 border-2 border-indigo-500 pointer-events-none print:border-indigo-600" aria-hidden />
<div className="relative z-10 text-center">
<div className="text-xl font-bold text-indigo-600 mb-8">CourseCraft</div>
<h1 className="text-4xl md:text-5xl font-bold text-indigo-600 uppercase tracking-widest mb-4">
Сертификат
</h1>
<h2 className="text-2xl text-slate-700 font-normal mb-10">о прохождении курса</h2>
<div className="my-8">
<p className="text-lg text-slate-500 mb-2">Настоящий сертификат подтверждает, что</p>
<p className="text-3xl md:text-4xl font-bold text-slate-900 border-b-2 border-indigo-500 pb-2 inline-block w-full">
{data.userName}
</p>
</div>
<div className="my-6">
<p className="text-lg text-slate-500 mb-2">успешно завершил(а) курс</p>
<p className="text-2xl md:text-3xl text-slate-600 font-medium">{data.courseTitle}</p>
</div>
<p className="text-slate-500 mt-12 text-base">
Дата выдачи: {dateStr}
</p>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,7 @@
export default function CertificateLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@ -67,7 +67,6 @@ export default function CoursePage() {
const [enrollmentProgress, setEnrollmentProgress] = useState<any>(null);
const [showQuiz, setShowQuiz] = useState(false);
const [quizQuestions, setQuizQuestions] = useState<any[]>([]);
const [generatingCertificate, setGeneratingCertificate] = useState(false);
// Flat list of all lessons
const flatLessons = useMemo(() => {
@ -190,17 +189,9 @@ export default function CoursePage() {
}
};
const handleGetCertificate = async () => {
if (!id || generatingCertificate) return;
setGeneratingCertificate(true);
try {
const { certificateUrl } = await api.getCertificate(id);
window.open(certificateUrl, '_blank');
} catch {
// silent
} finally {
setGeneratingCertificate(false);
}
const handleGetCertificate = () => {
if (!id) return;
window.open(`/certificate/${id}`, '_blank');
};
const goToNextLesson = () => {
@ -279,9 +270,9 @@ export default function CoursePage() {
{/* Certificate button - show when course completed */}
{courseCompleted && (
<Button size="sm" variant="default" onClick={handleGetCertificate} disabled={generatingCertificate}>
<Button size="sm" variant="default" onClick={handleGetCertificate}>
<GraduationCap className="mr-1.5 h-3.5 w-3.5" />
{generatingCertificate ? 'Генерация...' : 'Получить сертификат'}
Получить сертификат
</Button>
)}

View File

@ -29,11 +29,8 @@ export default function LearningPage() {
const [enrollments, setEnrollments] = useState<EnrollmentData[]>([]);
const [loading, setLoading] = useState(true);
const handleDownloadCertificate = async (courseId: string) => {
try {
const { certificateUrl } = await api.getCertificate(courseId);
window.open(certificateUrl, '_blank');
} catch { /* silent */ }
const handleOpenCertificate = (courseId: string) => {
window.open(`/certificate/${courseId}`, '_blank');
};
useEffect(() => {
@ -110,7 +107,7 @@ export default function LearningPage() {
size="sm"
variant="outline"
className="w-full mt-3"
onClick={(e) => { e.preventDefault(); handleDownloadCertificate(enrollment.course.id); }}
onClick={(e) => { e.preventDefault(); handleOpenCertificate(enrollment.course.id); }}
>
<Download className="mr-2 h-3.5 w-3.5" />
Скачать сертификат

View File

@ -299,6 +299,12 @@ class ApiClient {
return this.request<{ certificateUrl: string; html: string }>(`/certificates/${courseId}`);
}
async getCertificateData(courseId: string) {
return this.request<{ userName: string; courseTitle: string; completedAt: string }>(
`/certificates/${courseId}/data`
);
}
// Search
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
const searchParams = new URLSearchParams({ q: query });