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

@ -143,10 +143,10 @@ export class OpenRouterProvider {
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы, Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
чтобы лучше понять его потребности и создать максимально релевантный курс. чтобы лучше понять его потребности и создать максимально релевантный курс.
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса: Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса (важно для длины):
- Короткий (2-4 главы, введение в тему) - Короткий (3-4 главы, по 2-4 урока — только введение в тему)
- Средний (4-7 глав, хорошее покрытие) - Средний (5-7 глав, по 4-6 уроков — полноценное покрытие)
- Длинный / полный (6-12 глав, глубокое погружение) - Длинный / полный (7-12 глав, по 5-8 уроков — глубокое погружение, рекомендуемый вариант для серьёзного обучения)
Остальные вопросы: целевая аудитория, глубина материала, специфические темы. Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
@ -198,13 +198,13 @@ export class OpenRouterProvider {
const systemPrompt = `Ты - эксперт по созданию образовательных курсов. const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы. Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя): ОБЪЁМ КУРСА (соблюдай по ответам; при сомнении выбирай более полный вариант):
- Если пользователь выбрал короткий курс / введение: 24 главы, в каждой 24 урока. estimatedTotalHours: 28. - Короткий / введение: не менее 3 глав, в каждой по 34 урока. estimatedTotalHours: 410.
- Если средний курс: 47 глав, в каждой 35 уроков. estimatedTotalHours: 820. - Средний: 57 глав, в каждой по 46 уроков. estimatedTotalHours: 1025.
- Если длинный / полный курс: 612 глав, в каждой 48 уроков. estimatedTotalHours: 1540. - Длинный / полный: 712 глав, в каждой по 58 уроков. estimatedTotalHours: 2045.
- Если объём не указан — предложи средний (46 глав по 35 уроков). - Если объём не указан — делай средний или длинный: 57 глав, по 46 уроков в главе (не менее 25 уроков в курсе).
Укажи примерное время на каждый урок (estimatedMinutes: 1045). estimatedTotalHours = сумма уроков. Укажи примерное время на каждый урок (estimatedMinutes: 1545, чаще 2035). estimatedTotalHours = сумма уроков.
Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги. Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги.
Ответь в формате JSON со структурой: Ответь в формате JSON со структурой:
@ -281,32 +281,27 @@ ${Object.entries(answers)
log.request('generateLessonContent', model); log.request('generateLessonContent', model);
log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`); log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`);
const systemPrompt = `Ты - эксперт по созданию образовательного контента. const systemPrompt = `Ты - эксперт по созданию образовательного контента. Пиши ПОЛНЫЙ, ПОДРОБНЫЙ материал урока — не поверхностный обзор, а глубокое раскрытие темы с объяснениями и примерами.
Пиши содержание урока СРАЗУ в форматированном виде — в формате TipTap JSON. Каждый блок контента должен быть правильным узлом TipTap (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, image).
ФОРМАТИРОВАНИЕ (используй обязательно): ГЛАВНОЕ ТРЕБОВАНИЕ — СОДЕРЖАТЕЛЬНОСТЬ:
- Заголовки: { "type": "heading", "attrs": { "level": 1|2|3 }, "content": [{ "type": "text", "text": "..." }] } - Материал должен быть полным и подробным: объясняй понятия по шагам, раскрывай причины и следствия, давай контекст.
- Параграфы: { "type": "paragraph", "content": [{ "type": "text", "text": "..." }] } — для выделения используй "marks": [{ "type": "bold" }] или [{ "type": "italic" }] - Обязательно включай практические примеры: код, числа, сценарии использования. Без примеров урок считается неполным.
- Списки: bulletList > listItem > paragraph; orderedList > listItem > paragraph - Описывай не только "что", но и "зачем" и "как": типичные ошибки, лучшие практики, нюансы.
- Цитаты: { "type": "blockquote", "content": [{ "type": "paragraph", "content": [...] }] } - Каждую важную мысль подкрепляй пояснением или примером. Избегай перечисления фактов без раскрытия.
- Код: { "type": "codeBlock", "attrs": { "language": "javascript"|"python"|"text" }, "content": [{ "type": "text", "text": "код" }] }
- Mermaid-диаграммы: { "type": "codeBlock", "attrs": { "language": "mermaid" }, "content": [{ "type": "text", "text": "graph LR\\n A --> B" }] } — вставляй где уместно (схемы, процессы, связи)
- Картинки не генерируй (src нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
ОБЪЁМ: зависит от темы. Короткий урок: 300600 слов (35 блоков). Средний: 6001200 слов. Длинный: 12002500 слов. Структура: заголовок, введение, 24 секции с подзаголовками, примеры/код/списки, резюме. СТРУКТУРА УРОКА (соблюдай):
- Заголовок (h1), краткое введение в тему (23 абзаца).
- 47 смысловых секций с подзаголовками (h2/h3). В каждой секции: развёрнутый текст, при необходимости списки, примеры, блоки кода.
- Примеры и код: минимум 12 рабочих примера на урок (codeBlock с пояснением до/после). Для технических тем — больше.
- Резюме или выводы в конце (что важно запомнить, как применить).
ФОРМАТ — TipTap JSON (heading, paragraph, bulletList, orderedList, blockquote, codeBlock с language). Mermaid — где уместно (схемы, процессы). Картинки не выдумывай.
ОБЪЁМ: не менее 10001500 слов на урок. Сложные темы — 18003000 слов. Короткие абзацы из 12 предложений без примеров запрещены.
Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''} Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''}
Ответь только валидным JSON: Ответь только валидным JSON: { "content": { "type": "doc", "content": [ ... ] } }`;
{
"content": {
"type": "doc",
"content": [
{ "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Заголовок" }] },
{ "type": "paragraph", "content": [{ "type": "text", "text": "Текст..." }] }
]
}
}`;
return this.withRetry(async () => { return this.withRetry(async () => {
const response = await this.client.chat.completions.create({ const response = await this.client.chat.completions.create({
@ -319,7 +314,7 @@ ${Object.entries(answers)
Глава: "${chapterTitle}" Глава: "${chapterTitle}"
Урок: "${lessonTitle}" Урок: "${lessonTitle}"
Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`, Создай полный и подробный урок в TipTap JSON. Обязательно: развёрнутые объяснения, минимум 12 примера или блока кода с пояснениями, описание нюансов и практические советы. Не пиши поверхностно — материал должен быть глубоким и пригодным для самостоятельного изучения. Объём не менее 10001500 слов.`,
}, },
], ],
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },

View File

@ -10,6 +10,12 @@ import { User } from '@coursecraft/database';
export class CertificatesController { export class CertificatesController {
constructor(private certificatesService: CertificatesService) {} constructor(private certificatesService: CertificatesService) {}
@Get(':courseId/data')
@ApiOperation({ summary: 'Get certificate data for display/print page' })
async getCertificateData(@Param('courseId') courseId: string, @CurrentUser() user: User) {
return this.certificatesService.getCertificateData(user.id, courseId);
}
@Get(':courseId') @Get(':courseId')
@ApiOperation({ summary: 'Generate certificate for completed course' }) @ApiOperation({ summary: 'Generate certificate for completed course' })
async getCertificate(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> { async getCertificate(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {

View File

@ -5,7 +5,11 @@ import { PrismaService } from '../common/prisma/prisma.service';
export class CertificatesService { export class CertificatesService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async generateCertificate(userId: string, courseId: string): Promise<any> { async getCertificateData(userId: string, courseId: string): Promise<{
userName: string;
courseTitle: string;
completedAt: string;
}> {
const enrollment = await this.prisma.enrollment.findUnique({ const enrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } }, where: { userId_courseId: { userId, courseId } },
include: { course: true, user: true }, include: { course: true, user: true },
@ -14,19 +18,27 @@ export class CertificatesService {
if (!enrollment) throw new NotFoundException('Not enrolled'); if (!enrollment) throw new NotFoundException('Not enrolled');
if (!enrollment.completedAt) throw new Error('Course not completed yet'); if (!enrollment.completedAt) throw new Error('Course not completed yet');
// Generate certificate HTML (in production, render to PDF using puppeteer or similar) return {
userName: enrollment.user.name || enrollment.user.email || 'Слушатель',
courseTitle: enrollment.course.title,
completedAt: new Date(enrollment.completedAt).toISOString(),
};
}
async generateCertificate(userId: string, courseId: string): Promise<any> {
const data = await this.getCertificateData(userId, courseId);
const completionDate = new Date(data.completedAt);
const certificateHtml = this.renderCertificateHTML( const certificateHtml = this.renderCertificateHTML(
enrollment.user.name || enrollment.user.email, data.userName,
enrollment.course.title, data.courseTitle,
new Date(enrollment.completedAt) completionDate
); );
// In production: save to S3/R2 and return URL
// For now, return inline HTML
const certificateUrl = `data:text/html;base64,${Buffer.from(certificateHtml).toString('base64')}`; const certificateUrl = `data:text/html;base64,${Buffer.from(certificateHtml).toString('base64')}`;
await this.prisma.enrollment.update({ await this.prisma.enrollment.update({
where: { id: enrollment.id }, where: { userId_courseId: { userId, courseId } },
data: { certificateUrl }, data: { certificateUrl },
}); });
@ -48,7 +60,7 @@ export class CertificatesService {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-center; justify-content: center;
} }
.certificate { .certificate {
background: white; background: white;

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

View File

@ -29,11 +29,8 @@ 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) => { const handleOpenCertificate = (courseId: string) => {
try { window.open(`/certificate/${courseId}`, '_blank');
const { certificateUrl } = await api.getCertificate(courseId);
window.open(certificateUrl, '_blank');
} catch { /* silent */ }
}; };
useEffect(() => { useEffect(() => {
@ -110,7 +107,7 @@ export default function LearningPage() {
size="sm" size="sm"
variant="outline" variant="outline"
className="w-full mt-3" 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" /> <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}`); 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 // Search
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) { async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
const searchParams = new URLSearchParams({ q: query }); const searchParams = new URLSearchParams({ q: query });