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:
@ -143,10 +143,10 @@ export class OpenRouterProvider {
|
||||
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
|
||||
чтобы лучше понять его потребности и создать максимально релевантный курс.
|
||||
|
||||
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса:
|
||||
- Короткий (2-4 главы, введение в тему)
|
||||
- Средний (4-7 глав, хорошее покрытие)
|
||||
- Длинный / полный (6-12 глав, глубокое погружение)
|
||||
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса (важно для длины):
|
||||
- Короткий (3-4 главы, по 2-4 урока — только введение в тему)
|
||||
- Средний (5-7 глав, по 4-6 уроков — полноценное покрытие)
|
||||
- Длинный / полный (7-12 глав, по 5-8 уроков — глубокое погружение, рекомендуемый вариант для серьёзного обучения)
|
||||
|
||||
Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
|
||||
|
||||
@ -198,13 +198,13 @@ export class OpenRouterProvider {
|
||||
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
|
||||
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
||||
|
||||
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
|
||||
- Если пользователь выбрал короткий курс / введение: 2–4 главы, в каждой 2–4 урока. estimatedTotalHours: 2–8.
|
||||
- Если средний курс: 4–7 глав, в каждой 3–5 уроков. estimatedTotalHours: 8–20.
|
||||
- Если длинный / полный курс: 6–12 глав, в каждой 4–8 уроков. estimatedTotalHours: 15–40.
|
||||
- Если объём не указан — предложи средний (4–6 глав по 3–5 уроков).
|
||||
ОБЪЁМ КУРСА (соблюдай по ответам; при сомнении выбирай более полный вариант):
|
||||
- Короткий / введение: не менее 3 глав, в каждой по 3–4 урока. estimatedTotalHours: 4–10.
|
||||
- Средний: 5–7 глав, в каждой по 4–6 уроков. estimatedTotalHours: 10–25.
|
||||
- Длинный / полный: 7–12 глав, в каждой по 5–8 уроков. estimatedTotalHours: 20–45.
|
||||
- Если объём не указан — делай средний или длинный: 5–7 глав, по 4–6 уроков в главе (не менее 25 уроков в курсе).
|
||||
|
||||
Укажи примерное время на каждый урок (estimatedMinutes: 10–45). estimatedTotalHours = сумма уроков.
|
||||
Укажи примерное время на каждый урок (estimatedMinutes: 15–45, чаще 20–35). estimatedTotalHours = сумма уроков.
|
||||
Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги.
|
||||
|
||||
Ответь в формате JSON со структурой:
|
||||
@ -281,32 +281,27 @@ ${Object.entries(answers)
|
||||
log.request('generateLessonContent', model);
|
||||
log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`);
|
||||
|
||||
const systemPrompt = `Ты - эксперт по созданию образовательного контента.
|
||||
Пиши содержание урока СРАЗУ в форматированном виде — в формате TipTap JSON. Каждый блок контента должен быть правильным узлом TipTap (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, image).
|
||||
const systemPrompt = `Ты - эксперт по созданию образовательного контента. Пиши ПОЛНЫЙ, ПОДРОБНЫЙ материал урока — не поверхностный обзор, а глубокое раскрытие темы с объяснениями и примерами.
|
||||
|
||||
ФОРМАТИРОВАНИЕ (используй обязательно):
|
||||
- Заголовки: { "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 нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
|
||||
ГЛАВНОЕ ТРЕБОВАНИЕ — СОДЕРЖАТЕЛЬНОСТЬ:
|
||||
- Материал должен быть полным и подробным: объясняй понятия по шагам, раскрывай причины и следствия, давай контекст.
|
||||
- Обязательно включай практические примеры: код, числа, сценарии использования. Без примеров урок считается неполным.
|
||||
- Описывай не только "что", но и "зачем" и "как": типичные ошибки, лучшие практики, нюансы.
|
||||
- Каждую важную мысль подкрепляй пояснением или примером. Избегай перечисления фактов без раскрытия.
|
||||
|
||||
ОБЪЁМ: зависит от темы. Короткий урок: 300–600 слов (3–5 блоков). Средний: 600–1200 слов. Длинный: 1200–2500 слов. Структура: заголовок, введение, 2–4 секции с подзаголовками, примеры/код/списки, резюме.
|
||||
СТРУКТУРА УРОКА (соблюдай):
|
||||
- Заголовок (h1), краткое введение в тему (2–3 абзаца).
|
||||
- 4–7 смысловых секций с подзаголовками (h2/h3). В каждой секции: развёрнутый текст, при необходимости списки, примеры, блоки кода.
|
||||
- Примеры и код: минимум 1–2 рабочих примера на урок (codeBlock с пояснением до/после). Для технических тем — больше.
|
||||
- Резюме или выводы в конце (что важно запомнить, как применить).
|
||||
|
||||
ФОРМАТ — TipTap JSON (heading, paragraph, bulletList, orderedList, blockquote, codeBlock с language). Mermaid — где уместно (схемы, процессы). Картинки не выдумывай.
|
||||
|
||||
ОБЪЁМ: не менее 1000–1500 слов на урок. Сложные темы — 1800–3000 слов. Короткие абзацы из 1–2 предложений без примеров запрещены.
|
||||
|
||||
Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''}
|
||||
|
||||
Ответь только валидным JSON:
|
||||
{
|
||||
"content": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{ "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Заголовок" }] },
|
||||
{ "type": "paragraph", "content": [{ "type": "text", "text": "Текст..." }] }
|
||||
]
|
||||
}
|
||||
}`;
|
||||
Ответь только валидным JSON: { "content": { "type": "doc", "content": [ ... ] } }`;
|
||||
|
||||
return this.withRetry(async () => {
|
||||
const response = await this.client.chat.completions.create({
|
||||
@ -319,7 +314,7 @@ ${Object.entries(answers)
|
||||
Глава: "${chapterTitle}"
|
||||
Урок: "${lessonTitle}"
|
||||
|
||||
Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`,
|
||||
Создай полный и подробный урок в TipTap JSON. Обязательно: развёрнутые объяснения, минимум 1–2 примера или блока кода с пояснениями, описание нюансов и практические советы. Не пиши поверхностно — материал должен быть глубоким и пригодным для самостоятельного изучения. Объём не менее 1000–1500 слов.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
|
||||
@ -10,6 +10,12 @@ import { User } from '@coursecraft/database';
|
||||
export class CertificatesController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Generate certificate for completed course' })
|
||||
async getCertificate(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
|
||||
@ -5,7 +5,11 @@ import { PrismaService } from '../common/prisma/prisma.service';
|
||||
export class CertificatesService {
|
||||
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({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
include: { course: true, user: true },
|
||||
@ -14,19 +18,27 @@ export class CertificatesService {
|
||||
if (!enrollment) throw new NotFoundException('Not enrolled');
|
||||
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(
|
||||
enrollment.user.name || enrollment.user.email,
|
||||
enrollment.course.title,
|
||||
new Date(enrollment.completedAt)
|
||||
data.userName,
|
||||
data.courseTitle,
|
||||
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')}`;
|
||||
|
||||
await this.prisma.enrollment.update({
|
||||
where: { id: enrollment.id },
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
data: { certificateUrl },
|
||||
});
|
||||
|
||||
@ -48,7 +60,7 @@ export class CertificatesService {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-center;
|
||||
justify-content: center;
|
||||
}
|
||||
.certificate {
|
||||
background: white;
|
||||
|
||||
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