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:
117
apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx
Normal file
117
apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/app/(certificate)/layout.tsx
Normal file
7
apps/web/src/app/(certificate)/layout.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function CertificateLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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" />
|
||||
Скачать сертификат
|
||||
|
||||
@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user