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 вопросов. Обязательно включи вопрос про объём курса:
|
Сгенерируй 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 = `Ты - эксперт по созданию образовательных курсов.
|
||||||
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
||||||
|
|
||||||
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
|
ОБЪЁМ КУРСА (соблюдай по ответам; при сомнении выбирай более полный вариант):
|
||||||
- Если пользователь выбрал короткий курс / введение: 2–4 главы, в каждой 2–4 урока. estimatedTotalHours: 2–8.
|
- Короткий / введение: не менее 3 глав, в каждой по 3–4 урока. estimatedTotalHours: 4–10.
|
||||||
- Если средний курс: 4–7 глав, в каждой 3–5 уроков. estimatedTotalHours: 8–20.
|
- Средний: 5–7 глав, в каждой по 4–6 уроков. estimatedTotalHours: 10–25.
|
||||||
- Если длинный / полный курс: 6–12 глав, в каждой 4–8 уроков. estimatedTotalHours: 15–40.
|
- Длинный / полный: 7–12 глав, в каждой по 5–8 уроков. estimatedTotalHours: 20–45.
|
||||||
- Если объём не указан — предложи средний (4–6 глав по 3–5 уроков).
|
- Если объём не указан — делай средний или длинный: 5–7 глав, по 4–6 уроков в главе (не менее 25 уроков в курсе).
|
||||||
|
|
||||||
Укажи примерное время на каждый урок (estimatedMinutes: 10–45). estimatedTotalHours = сумма уроков.
|
Укажи примерное время на каждый урок (estimatedMinutes: 15–45, чаще 20–35). 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 нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
|
|
||||||
|
|
||||||
ОБЪЁМ: зависит от темы. Короткий урок: 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}` : ''}
|
Уровень: ${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. Обязательно: развёрнутые объяснения, минимум 1–2 примера или блока кода с пояснениями, описание нюансов и практические советы. Не пиши поверхностно — материал должен быть глубоким и пригодным для самостоятельного изучения. Объём не менее 1000–1500 слов.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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 [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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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" />
|
||||||
Скачать сертификат
|
Скачать сертификат
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user