Compare commits
11 Commits
a7fce5396d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 979adb9d3d | |||
| 4ca66ea896 | |||
| 3d488f22b7 | |||
| c809d049fe | |||
| 5241144bc5 | |||
| f39680d714 | |||
| bed7e440c1 | |||
| 5ddb3db1ac | |||
| 2ed65f5678 | |||
| dab726e8d1 | |||
| dd7c0afaf5 |
10
.env.example
10
.env.example
@ -37,6 +37,7 @@ AI_MODEL_DEFAULT="openai/gpt-4o-mini"
|
||||
STRIPE_SECRET_KEY="sk_test_..."
|
||||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
|
||||
PAYMENT_MODE="PROD" # DEV | PROD
|
||||
|
||||
# Stripe Price IDs
|
||||
STRIPE_PRICE_PREMIUM="price_..."
|
||||
@ -53,6 +54,15 @@ S3_SECRET_ACCESS_KEY="your-secret-key"
|
||||
S3_BUCKET_NAME="coursecraft"
|
||||
S3_REGION="auto"
|
||||
|
||||
# Cooperation form email (optional; if not set requests are still saved in DB)
|
||||
COOPERATION_EMAIL_TO="exbytestudios@gmail.com"
|
||||
COOPERATION_EMAIL_FROM="noreply@coursecraft.local"
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# App URLs (API на 3125; веб — свой порт, напр. 3000)
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3125"
|
||||
|
||||
165
IMPLEMENTATION.md
Normal file
165
IMPLEMENTATION.md
Normal file
@ -0,0 +1,165 @@
|
||||
# Реализация проекта CourseCraft
|
||||
|
||||
Документ описывает техническую реализацию платформы CourseCraft — создания образовательных курсов с помощью ИИ.
|
||||
|
||||
---
|
||||
|
||||
## 1. Обзор
|
||||
|
||||
**CourseCraft** — монорепозиторий с тремя приложениями:
|
||||
|
||||
| Приложение | Назначение |
|
||||
|-------------|------------|
|
||||
| **web** | Фронтенд на Next.js 14 (App Router): лендинг, авторизация, дашборд, каталог, прохождение курсов, сертификаты. |
|
||||
| **api** | Backend на NestJS: REST API, авторизация (Supabase + JWT), курсы, генерация, записи, прогресс, сертификаты, поиск. |
|
||||
| **ai-service** | Воркер на BullMQ: очередь задач генерации курсов, вызовы OpenRouter (структура курса, контент уроков). |
|
||||
|
||||
Дополнительно: **PostgreSQL** (Prisma), **Redis**, **Meilisearch**. Запуск через Docker Compose из корня проекта.
|
||||
|
||||
---
|
||||
|
||||
## 2. Стек и технологии
|
||||
|
||||
- **Frontend:** Next.js 14, TypeScript, Tailwind CSS, shadcn/ui, TipTap (редактор/просмотр контента), Supabase Client (auth).
|
||||
- **Backend:** NestJS, Prisma, JWT (обмен Supabase access token на бэкенд-токен), BullMQ, Stripe (платежи).
|
||||
- **AI:** OpenRouter API (GPT-4, Claude и др.), структурированный вывод (JSON), промпты для уточняющих вопросов, outline и контент уроков.
|
||||
- **Инфраструктура:** Docker Compose, pgvector, Meilisearch (поиск курсов).
|
||||
|
||||
---
|
||||
|
||||
## 3. База данных (Prisma)
|
||||
|
||||
Схема в `packages/database/prisma/schema.prisma`.
|
||||
|
||||
### Основные сущности
|
||||
|
||||
- **User** — пользователь (supabaseId, email, name, subscriptionTier, role). Связь с UserSettings (customAiModel, theme, language).
|
||||
- **Subscription** — подписка (FREE/PREMIUM/PRO), Stripe, лимиты (coursesCreatedThisMonth).
|
||||
- **Course** — курс (authorId, title, description, slug, status, tags, difficulty, estimatedHours). Связи: Chapter → Lesson.
|
||||
- **Chapter** — глава (courseId, title, description, order).
|
||||
- **Lesson** — урок (chapterId, title, content в TipTap JSON, order, durationMinutes). Опционально: Quiz (questions JSON).
|
||||
- **CourseGeneration** — процесс генерации (userId, initialPrompt, aiModel, status, progress, questions, answers, generatedOutline, courseId, jobId).
|
||||
- **Enrollment** — запись на курс (userId, courseId, progress, completedAt, certificateUrl).
|
||||
- **LessonProgress** — прогресс по уроку (userId, enrollmentId, lessonId, completedAt, quizScore).
|
||||
- **Review** — отзыв по курсу (userId, courseId, rating, content).
|
||||
- **Certificates** — не отдельная таблица: данные для сертификата берутся из Enrollment + User + Course; certificateUrl сохраняется в Enrollment.
|
||||
|
||||
Дополнительно: Category, Purchase, CourseGroup, GroupMember, GroupMessage, Homework, HomeworkSubmission, SupportTicket, TicketMessage.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend (API, NestJS)
|
||||
|
||||
### Авторизация
|
||||
|
||||
- **Supabase Auth** — вход/регистрация на фронте.
|
||||
- **Обмен токена:** `POST /api/auth/exchange` принимает Supabase access token, создаёт/обновляет пользователя в БД, возвращает JWT бэкенда.
|
||||
- Защищённые маршруты используют **JWT Guard**; текущий пользователь — `@CurrentUser() user: User`.
|
||||
|
||||
### Основные модули и маршруты
|
||||
|
||||
| Модуль | Назначение |
|
||||
|---------------|------------|
|
||||
| **auth** | exchange token, привязка к Supabase. |
|
||||
| **users** | профиль, настройки (в т.ч. customAiModel). |
|
||||
| **courses** | CRUD курсов, глав (chapters), уроков (lessons); отдача курса с главами и уроками. |
|
||||
| **generation**| Старт генерации (очередь BullMQ), ответы на уточняющие вопросы, продолжение генерации, статус/прогресс. |
|
||||
| **catalog** | Публичный каталог курсов (опубликованные), фильтры. |
|
||||
| **enrollment**| Запись на курс, прогресс (прохождение уроков, квизы), отзывы, рейтинги, список записей пользователя. |
|
||||
| **certificates** | `GET /certificates/:courseId` — генерация/возврат сертификата (certificateUrl, html); `GET /certificates/:courseId/data` — данные для страницы сертификата (userName, courseTitle, completedAt). |
|
||||
| **search** | Полнотекстовый поиск (Meilisearch). |
|
||||
| **payments** | Stripe: подписки, вебхуки. |
|
||||
|
||||
### Сертификаты
|
||||
|
||||
- Доступ только при завершённом курсе (enrollment.completedAt).
|
||||
- **getCertificateData(userId, courseId)** — возвращает `{ userName, courseTitle, completedAt }` для отображения на странице.
|
||||
- **generateCertificate** — формирует HTML сертификата, при необходимости сохраняет certificateUrl в Enrollment, возвращает certificateUrl и html (для обратной совместимости).
|
||||
|
||||
---
|
||||
|
||||
## 5. AI Service (BullMQ + OpenRouter)
|
||||
|
||||
### Очередь
|
||||
|
||||
- Очередь **course-generation** (Redis). API добавляет задачи, воркер в ai-service их обрабатывает.
|
||||
- Типы задач: **generate-course** (старт), **continue-generation** (после ответов на вопросы).
|
||||
|
||||
### Пайплайн генерации курса
|
||||
|
||||
1. **generate-course:** анализ запроса → генерация уточняющих вопросов (OpenRouter) → сохранение вопросов в CourseGeneration → статус WAITING_FOR_ANSWERS (ожидание ответов).
|
||||
2. Пользователь отвечает через API → ставится задача **continue-generation** с stage `after-questions`.
|
||||
3. **continue-generation:** исследование (симуляция) → генерация outline (название, описание, главы, уроки, estimatedTotalHours, difficulty, tags) → создание курса в БД (Course + Chapter + Lesson без контента) → для каждого урока вызов **generateLessonContent** → запись TipTap JSON в Lesson → обновление подписки (coursesCreatedThisMonth) → статус COMPLETED.
|
||||
|
||||
### OpenRouter (промпты)
|
||||
|
||||
- **generateClarifyingQuestions** — уточняющие вопросы, в т.ч. объём курса (короткий / средний / длинный).
|
||||
- **generateCourseOutline** — структура курса по ответам (количество глав и уроков, estimatedMinutes, estimatedTotalHours). При не указанном объёме — средний/длинный (5–7 глав, 4–6 уроков в главе, не менее 25 уроков).
|
||||
- **generateLessonContent** — контент урока в формате TipTap JSON (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, mermaid). Промпт требует полный, подробный материал (1000–1500+ слов), примеры и пояснения, минимум 1–2 примера/кода на урок.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend (Next.js)
|
||||
|
||||
### Маршруты
|
||||
|
||||
- **/** — лендинг.
|
||||
- **/(auth)/login, register, forgot-password** — авторизация Supabase.
|
||||
- **/(dashboard)/dashboard/** — дашборд (sidebar + header): главная, курсы (список, создание, просмотр/прохождение, редактирование), каталог, карточка курса каталога, мои обучения (learning), поиск, настройки, биллинг.
|
||||
- **/(certificate)/certificate/[courseId]** — страница сертификата (без сайдбара): загрузка данных через `getCertificateData(courseId)`, отображение ФИО, названия курса, даты; кнопка «Печать», стили для печати (только блок сертификата).
|
||||
|
||||
### Авторизация на фронте
|
||||
|
||||
- **AuthProvider:** Supabase session → при наличии сессии вызов `/api/auth/exchange` с retry → сохранение JWT в sessionStorage и в памяти; в контексте доступны `user`, `backendUser` (id, email, name, subscriptionTier).
|
||||
- Все запросы к API через общий клиент с заголовком `Authorization: Bearer <token>`.
|
||||
|
||||
### API-клиент (lib/api.ts)
|
||||
|
||||
- Базовый URL API через прокси Next.js или INTERNAL_API_URL на сервере.
|
||||
- Методы: auth (exchange), users, courses, chapters, lessons, generation (start, answerQuestions, continue, status), catalog, enrollment (запись, прогресс, отзывы, мои записи), certificates (getCertificate, getCertificateData), search и др.
|
||||
|
||||
### Ключевые сценарии
|
||||
|
||||
- **Создание курса:** форма с промптом → старт генерации → опрос уточняющих вопросов → ответы → продолжение генерации → опрос статуса/прогресса до завершения → переход к курсу.
|
||||
- **Просмотр/прохождение курса:** выбор урока, отображение контента (LessonContentViewer по TipTap JSON), отметка урока выполненным, квиз по уроку (LessonQuiz), обновление прогресса, при 100% — возможность открыть сертификат.
|
||||
- **Сертификат:** кнопки «Получить сертификат» (страница курса, мои обучения) открывают в новой вкладке `/certificate/[courseId]`; страница запрашивает данные и рендерит сертификат с кнопкой «Печать».
|
||||
|
||||
---
|
||||
|
||||
## 7. Инфраструктура и запуск
|
||||
|
||||
### Docker Compose (из корня)
|
||||
|
||||
- **postgres** — порт 5432, pgvector.
|
||||
- **redis** — порт 6395 (внешний).
|
||||
- **meilisearch** — порт 7700.
|
||||
- **api** — порт 3125, зависит от postgres, redis, meilisearch; переменные: DATABASE_URL, REDIS_URL, JWT_SECRET, Supabase, NEXT_PUBLIC_APP_URL и т.д.
|
||||
- **ai-service** — зависит от postgres, redis; OPENROUTER_API_KEY, DATABASE_URL, REDIS_URL.
|
||||
- **web** — порт 3080, INTERNAL_API_URL=http://api:3125, Next.js production build.
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
Перезапуск после изменений (например, промпты или API):
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env restart ai-service api web
|
||||
```
|
||||
|
||||
### Переменные окружения (.env)
|
||||
|
||||
- **База и очереди:** DATABASE_URL, REDIS_URL.
|
||||
- **Auth:** NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET.
|
||||
- **AI:** OPENROUTER_API_KEY (обязателен для ai-service).
|
||||
- **Приложение:** NEXT_PUBLIC_APP_URL (для ссылок и CORS).
|
||||
- **Платежи:** STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (при использовании Stripe).
|
||||
- **Поиск:** MEILISEARCH_API_KEY (в docker-compose для meilisearch).
|
||||
|
||||
---
|
||||
|
||||
## 8. Итог
|
||||
|
||||
Реализация покрывает: авторизацию через Supabase и JWT, создание курсов по промпту с уточняющими вопросами и генерацией структуры и контента через OpenRouter, каталог и запись на курсы, прохождение с прогрессом и квизами, выдачу сертификата с отдельной страницей для просмотра и печати, тарифы и настраиваемую AI-модель. Документация по быстрому старту и командам — в README.md.
|
||||
@ -235,6 +235,13 @@ export class CourseGenerationPipeline {
|
||||
difficulty: outline.difficulty,
|
||||
estimatedHours: outline.estimatedTotalHours,
|
||||
tags: outline.tags,
|
||||
groups: {
|
||||
create: {
|
||||
name: 'Основная группа',
|
||||
description: 'Обсуждение курса и вопросы преподавателю',
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
chapters: {
|
||||
create: outline.chapters.map((chapter, chapterIndex) => ({
|
||||
title: chapter.title,
|
||||
|
||||
@ -137,53 +137,50 @@ export class OpenRouterProvider {
|
||||
model: string
|
||||
): Promise<ClarifyingQuestions> {
|
||||
log.request('generateClarifyingQuestions', model);
|
||||
log.info(`User prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
||||
log.info(`Using structured onboarding quiz for prompt: "${prompt.substring(0, 120)}${prompt.length > 120 ? '...' : ''}"`);
|
||||
|
||||
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
|
||||
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
|
||||
чтобы лучше понять его потребности и создать максимально релевантный курс.
|
||||
|
||||
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса:
|
||||
- Короткий (2-4 главы, введение в тему)
|
||||
- Средний (4-7 глав, хорошее покрытие)
|
||||
- Длинный / полный (6-12 глав, глубокое погружение)
|
||||
|
||||
Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
|
||||
|
||||
Ответь в формате JSON.`;
|
||||
|
||||
return this.withRetry(async () => {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: `Запрос пользователя: "${prompt}"` },
|
||||
const structured = {
|
||||
questions: [
|
||||
{
|
||||
id: 'q_audience',
|
||||
question: 'Для кого курс?',
|
||||
type: 'single_choice',
|
||||
options: ['Новички', 'Middle', 'Продвинутые'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'q_format',
|
||||
question: 'Формат курса?',
|
||||
type: 'single_choice',
|
||||
options: ['Теория', 'Практика', 'Смешанный'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'q_goal',
|
||||
question: 'Основная цель курса?',
|
||||
type: 'single_choice',
|
||||
options: ['Освоить профессию', 'Подготовиться к экзамену', 'Для себя'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'q_volume',
|
||||
question: 'Какой объём курса нужен?',
|
||||
type: 'single_choice',
|
||||
options: ['Короткий (3-4 главы)', 'Средний (5-7 глав)', 'Полный (7-12 глав)'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'q_notes',
|
||||
question: 'Есть ли дополнительные пожелания по структуре, заданиям и кейсам?',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
log.response('generateClarifyingQuestions', {
|
||||
prompt: response.usage?.prompt_tokens,
|
||||
completion: response.usage?.completion_tokens,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
log.debug('Raw AI response:', content);
|
||||
|
||||
if (!content) {
|
||||
log.error('Empty response from AI');
|
||||
throw new Error('Empty response from AI');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
const validated = ClarifyingQuestionsSchema.parse(parsed);
|
||||
|
||||
log.success(`Generated ${validated.questions.length} clarifying questions`);
|
||||
log.info('Questions:', validated.questions.map(q => q.question));
|
||||
};
|
||||
|
||||
const validated = ClarifyingQuestionsSchema.parse(structured);
|
||||
log.success(`Generated ${validated.questions.length} structured onboarding questions`);
|
||||
return validated;
|
||||
}, 'generateClarifyingQuestions');
|
||||
}
|
||||
|
||||
async generateCourseOutline(
|
||||
@ -198,13 +195,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 со структурой:
|
||||
@ -225,9 +222,22 @@ export class OpenRouterProvider {
|
||||
"tags": ["тег1", "тег2"]
|
||||
}`;
|
||||
|
||||
const audience = String(answers.q_audience || '').trim();
|
||||
const format = String(answers.q_format || '').trim();
|
||||
const goal = String(answers.q_goal || '').trim();
|
||||
const volume = String(answers.q_volume || '').trim();
|
||||
const notes = String(answers.q_notes || '').trim();
|
||||
|
||||
const userMessage = `Запрос: "${prompt}"
|
||||
|
||||
Ответы пользователя на уточняющие вопросы:
|
||||
Структурированные ответы:
|
||||
- Аудитория: ${audience || 'не указано'}
|
||||
- Формат: ${format || 'не указано'}
|
||||
- Цель: ${goal || 'не указано'}
|
||||
- Объём: ${volume || 'не указано'}
|
||||
- Доп. пожелания: ${notes || 'нет'}
|
||||
|
||||
Сырой набор ответов:
|
||||
${Object.entries(answers)
|
||||
.map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
|
||||
.join('\n')}`;
|
||||
@ -281,32 +291,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 +324,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' },
|
||||
|
||||
@ -24,7 +24,9 @@
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/platform-socket.io": "^10.3.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/websockets": "^10.3.0",
|
||||
"@supabase/supabase-js": "^2.39.0",
|
||||
"bullmq": "^5.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -32,10 +34,13 @@
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"meilisearch": "^0.37.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^14.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -45,7 +50,9 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"jest": "^29.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.1.2",
|
||||
|
||||
43
apps/api/src/admin/admin.controller.ts
Normal file
43
apps/api/src/admin/admin.controller.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { PaymentMode, PaymentProvider, User, UserRole } from '@coursecraft/database';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
@ApiBearerAuth()
|
||||
export class AdminController {
|
||||
constructor(private adminService: AdminService) {}
|
||||
|
||||
@Get('users')
|
||||
async getUsers(
|
||||
@CurrentUser() user: User,
|
||||
@Query('search') search?: string,
|
||||
@Query('role') role?: UserRole,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.adminService.getUsers(user.id, { search, role, limit });
|
||||
}
|
||||
|
||||
@Patch('users/:id/role')
|
||||
async updateUserRole(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') targetUserId: string,
|
||||
@Body('role') role: UserRole,
|
||||
) {
|
||||
return this.adminService.updateUserRole(user.id, targetUserId, role);
|
||||
}
|
||||
|
||||
@Get('payments')
|
||||
async getPayments(
|
||||
@CurrentUser() user: User,
|
||||
@Query('mode') mode?: PaymentMode,
|
||||
@Query('provider') provider?: PaymentProvider,
|
||||
@Query('status') status?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<any> {
|
||||
return this.adminService.getPayments(user.id, { mode, provider, status, search, limit });
|
||||
}
|
||||
}
|
||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AccessModule, UsersModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
64
apps/api/src/admin/admin.service.ts
Normal file
64
apps/api/src/admin/admin.service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PaymentMode, PaymentProvider, UserRole } from '@coursecraft/database';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async getUsers(adminUserId: string, options?: { search?: string; role?: UserRole; limit?: number }) {
|
||||
await this.access.assertAdmin(adminUserId);
|
||||
return this.usersService.listUsers(options);
|
||||
}
|
||||
|
||||
async updateUserRole(adminUserId: string, targetUserId: string, role: UserRole) {
|
||||
await this.access.assertAdmin(adminUserId);
|
||||
return this.usersService.updateRole(targetUserId, role);
|
||||
}
|
||||
|
||||
async getPayments(
|
||||
adminUserId: string,
|
||||
filters?: {
|
||||
mode?: PaymentMode;
|
||||
provider?: PaymentProvider;
|
||||
status?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<any> {
|
||||
await this.access.assertAdmin(adminUserId);
|
||||
const limit = Math.min(300, Math.max(1, filters?.limit || 150));
|
||||
const where: any = {};
|
||||
if (filters?.mode) where.mode = filters.mode;
|
||||
if (filters?.provider) where.provider = filters.provider;
|
||||
if (filters?.status) where.status = filters.status;
|
||||
if (filters?.search?.trim()) {
|
||||
const term = filters.search.trim();
|
||||
where.OR = [
|
||||
{ user: { email: { contains: term, mode: 'insensitive' } } },
|
||||
{ user: { name: { contains: term, mode: 'insensitive' } } },
|
||||
{ course: { title: { contains: term, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
return this.prisma.purchase.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, name: true, avatarUrl: true },
|
||||
},
|
||||
course: {
|
||||
select: { id: true, title: true, slug: true, authorId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,18 @@ import { join } from 'path';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { CoursesModule } from './courses/courses.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { EnrollmentModule } from './enrollment/enrollment.module';
|
||||
import { CertificatesModule } from './certificates/certificates.module';
|
||||
import { GroupsModule } from './groups/groups.module';
|
||||
import { SupportModule } from './support/support.module';
|
||||
import { ModerationModule } from './moderation/moderation.module';
|
||||
import { GenerationModule } from './generation/generation.module';
|
||||
import { PaymentsModule } from './payments/payments.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { PrismaModule } from './common/prisma/prisma.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { CooperationModule } from './cooperation/cooperation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -38,9 +46,17 @@ import { PrismaModule } from './common/prisma/prisma.module';
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
CoursesModule,
|
||||
CatalogModule,
|
||||
EnrollmentModule,
|
||||
CertificatesModule,
|
||||
GroupsModule,
|
||||
SupportModule,
|
||||
ModerationModule,
|
||||
GenerationModule,
|
||||
PaymentsModule,
|
||||
SearchModule,
|
||||
AdminModule,
|
||||
CooperationModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -43,6 +43,6 @@ import { UsersModule } from '../users/users.module';
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SupabaseService, JwtAuthGuard],
|
||||
exports: [AuthService, SupabaseService, JwtAuthGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -55,6 +55,7 @@ export class AuthService {
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
subscriptionTier: user.subscriptionTier,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
54
apps/api/src/catalog/catalog.controller.ts
Normal file
54
apps/api/src/catalog/catalog.controller.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Controller, Get, Post, Patch, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller('catalog')
|
||||
export class CatalogController {
|
||||
constructor(private catalogService: CatalogService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Browse published courses (public)' })
|
||||
async browseCourses(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('search') search?: string,
|
||||
@Query('difficulty') difficulty?: string,
|
||||
): Promise<any> {
|
||||
return this.catalogService.getPublishedCourses({ page, limit, search, difficulty });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get public course details' })
|
||||
async getCourse(@Param('id') id: string): Promise<any> {
|
||||
return this.catalogService.getPublicCourse(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Submit course for moderation review' })
|
||||
async submitForReview(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.catalogService.submitForReview(id, user.id);
|
||||
}
|
||||
|
||||
@Patch(':id/verify')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Toggle author verification badge' })
|
||||
async toggleVerify(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.catalogService.toggleVerification(id, user.id);
|
||||
}
|
||||
|
||||
@Post(':id/checkout')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Create one-time checkout session for paid course' })
|
||||
async checkoutCourse(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.catalogService.createCourseCheckout(id, user.id);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/catalog/catalog.module.ts
Normal file
12
apps/api/src/catalog/catalog.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
|
||||
@Module({
|
||||
imports: [PaymentsModule],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
143
apps/api/src/catalog/catalog.service.ts
Normal file
143
apps/api/src/catalog/catalog.service.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { CourseStatus } from '@coursecraft/database';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private paymentsService: PaymentsService
|
||||
) {}
|
||||
|
||||
async getPublishedCourses(options?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
difficulty?: string;
|
||||
search?: string;
|
||||
}): Promise<any> {
|
||||
const page = options?.page || 1;
|
||||
const limit = options?.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {
|
||||
status: CourseStatus.PUBLISHED,
|
||||
isPublished: true,
|
||||
};
|
||||
if (options?.category) where.categoryId = options.category;
|
||||
if (options?.difficulty) where.difficulty = options.difficulty;
|
||||
if (options?.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: options.search, mode: 'insensitive' } },
|
||||
{ description: { contains: options.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [courses, total] = await Promise.all([
|
||||
this.prisma.course.findMany({
|
||||
where,
|
||||
include: {
|
||||
author: { select: { id: true, name: true, avatarUrl: true } },
|
||||
_count: { select: { chapters: true, reviews: true, enrollments: true } },
|
||||
chapters: { include: { _count: { select: { lessons: true } } } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.course.count({ where }),
|
||||
]);
|
||||
|
||||
const data = courses.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
slug: c.slug,
|
||||
coverImage: c.coverImage,
|
||||
price: c.price,
|
||||
currency: c.currency,
|
||||
difficulty: c.difficulty,
|
||||
estimatedHours: c.estimatedHours,
|
||||
tags: c.tags,
|
||||
isVerified: c.isVerified,
|
||||
averageRating: c.averageRating,
|
||||
enrollmentCount: c._count.enrollments,
|
||||
reviewCount: c._count.reviews,
|
||||
chaptersCount: c._count.chapters,
|
||||
lessonsCount: c.chapters.reduce((acc, ch) => acc + ch._count.lessons, 0),
|
||||
author: c.author,
|
||||
publishedAt: c.publishedAt,
|
||||
}));
|
||||
|
||||
return { data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } };
|
||||
}
|
||||
|
||||
async getPublicCourse(courseId: string): Promise<any> {
|
||||
return this.prisma.course.findFirst({
|
||||
where: { id: courseId, status: CourseStatus.PUBLISHED, isPublished: true },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, avatarUrl: true } },
|
||||
chapters: {
|
||||
include: {
|
||||
lessons: { select: { id: true, title: true, order: true, durationMinutes: true }, orderBy: { order: 'asc' } },
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
reviews: {
|
||||
where: { isApproved: true },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
},
|
||||
_count: { select: { reviews: true, enrollments: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async submitForReview(courseId: string, userId: string): Promise<any> {
|
||||
const course = await this.prisma.course.findUnique({ where: { id: courseId } });
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.authorId !== userId) {
|
||||
throw new ForbiddenException('Only course author can submit for moderation');
|
||||
}
|
||||
|
||||
const fromStatus = course.status;
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
status: CourseStatus.PENDING_MODERATION,
|
||||
isPublished: false,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId,
|
||||
fromStatus,
|
||||
toStatus: CourseStatus.PENDING_MODERATION,
|
||||
changedById: userId,
|
||||
note: 'Submitted for moderation',
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async createCourseCheckout(courseId: string, userId: string): Promise<any> {
|
||||
return this.paymentsService.createCourseCheckoutSession(userId, courseId);
|
||||
}
|
||||
|
||||
async toggleVerification(courseId: string, userId: string): Promise<any> {
|
||||
const course = await this.prisma.course.findFirst({
|
||||
where: { id: courseId, authorId: userId },
|
||||
});
|
||||
if (!course) return null;
|
||||
return this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: { isVerified: !course.isVerified },
|
||||
});
|
||||
}
|
||||
}
|
||||
24
apps/api/src/certificates/certificates.controller.ts
Normal file
24
apps/api/src/certificates/certificates.controller.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CertificatesService } from './certificates.service';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
|
||||
@ApiTags('certificates')
|
||||
@Controller('certificates')
|
||||
@ApiBearerAuth()
|
||||
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> {
|
||||
return this.certificatesService.generateCertificate(user.id, courseId);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/certificates/certificates.module.ts
Normal file
10
apps/api/src/certificates/certificates.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CertificatesController } from './certificates.controller';
|
||||
import { CertificatesService } from './certificates.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CertificatesController],
|
||||
providers: [CertificatesService],
|
||||
exports: [CertificatesService],
|
||||
})
|
||||
export class CertificatesModule {}
|
||||
148
apps/api/src/certificates/certificates.service.ts
Normal file
148
apps/api/src/certificates/certificates.service.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CertificatesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
if (!enrollment) throw new NotFoundException('Not enrolled');
|
||||
if (!enrollment.completedAt) throw new Error('Course not completed yet');
|
||||
|
||||
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(
|
||||
data.userName,
|
||||
data.courseTitle,
|
||||
completionDate
|
||||
);
|
||||
|
||||
const certificateUrl = `data:text/html;base64,${Buffer.from(certificateHtml).toString('base64')}`;
|
||||
|
||||
await this.prisma.enrollment.update({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
data: { certificateUrl },
|
||||
});
|
||||
|
||||
return { certificateUrl, html: certificateHtml };
|
||||
}
|
||||
|
||||
private renderCertificateHTML(userName: string, courseTitle: string, completionDate: Date): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Сертификат - ${courseTitle}</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Georgia', serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.certificate {
|
||||
background: white;
|
||||
width: 1000px;
|
||||
padding: 80px 100px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
border: 12px solid #f8f9fa;
|
||||
position: relative;
|
||||
}
|
||||
.certificate::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 30px;
|
||||
border: 2px solid #667eea;
|
||||
pointer-events: none;
|
||||
}
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
color: #667eea;
|
||||
margin: 0 0 20px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin: 0 0 40px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.recipient {
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
color: #1a202c;
|
||||
margin: 40px 0;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
.course-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
color: #4a5568;
|
||||
margin: 40px 0;
|
||||
}
|
||||
.date {
|
||||
text-align: center;
|
||||
color: #718096;
|
||||
margin-top: 60px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate">
|
||||
<div class="logo">✨ CourseCraft</div>
|
||||
<h1>Сертификат</h1>
|
||||
<h2>о прохождении курса</h2>
|
||||
<div style="text-align: center; margin: 40px 0;">
|
||||
<div style="font-size: 18px; color: #718096; margin-bottom: 10px;">Настоящий сертификат подтверждает, что</div>
|
||||
<div class="recipient">${userName}</div>
|
||||
</div>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<div style="font-size: 18px; color: #718096; margin-bottom: 10px;">успешно завершил(а) курс</div>
|
||||
<div class="course-title">${courseTitle}</div>
|
||||
</div>
|
||||
<div class="date">
|
||||
Дата выдачи: ${completionDate.toLocaleDateString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
11
apps/api/src/common/access/access.module.ts
Normal file
11
apps/api/src/common/access/access.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AccessService],
|
||||
exports: [AccessService],
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
||||
61
apps/api/src/common/access/access.service.ts
Normal file
61
apps/api/src/common/access/access.service.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { UserRole } from '@coursecraft/database';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getUserRole(userId: string): Promise<UserRole> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return user.role;
|
||||
}
|
||||
|
||||
async assertStaff(userId: string): Promise<void> {
|
||||
const role = await this.getUserRole(userId);
|
||||
if (role !== UserRole.MODERATOR && role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Staff access only');
|
||||
}
|
||||
}
|
||||
|
||||
async assertAdmin(userId: string): Promise<void> {
|
||||
const role = await this.getUserRole(userId);
|
||||
if (role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Admin access only');
|
||||
}
|
||||
}
|
||||
|
||||
async assertCourseOwner(courseId: string, userId: string): Promise<void> {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.authorId !== userId) {
|
||||
throw new ForbiddenException('Only course author can perform this action');
|
||||
}
|
||||
}
|
||||
|
||||
async assertCourseOwnerOrStaff(courseId: string, userId: string): Promise<void> {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.authorId === userId) {
|
||||
return;
|
||||
}
|
||||
await this.assertStaff(userId);
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/api/src/common/course-status.ts
Normal file
11
apps/api/src/common/course-status.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { CourseStatus } from '@coursecraft/database';
|
||||
|
||||
export const COURSE_PENDING_STATUSES: CourseStatus[] = [
|
||||
CourseStatus.PENDING_MODERATION,
|
||||
CourseStatus.PENDING_REVIEW, // backward compatibility
|
||||
];
|
||||
|
||||
export function isPendingModeration(status: CourseStatus): boolean {
|
||||
return COURSE_PENDING_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
19
apps/api/src/cooperation/cooperation.controller.ts
Normal file
19
apps/api/src/cooperation/cooperation.controller.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { CooperationService } from './cooperation.service';
|
||||
import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto';
|
||||
|
||||
@ApiTags('cooperation')
|
||||
@Controller('cooperation')
|
||||
export class CooperationController {
|
||||
constructor(private cooperationService: CooperationService) {}
|
||||
|
||||
@Public()
|
||||
@Post('requests')
|
||||
@ApiOperation({ summary: 'Create cooperation request from landing page' })
|
||||
async createRequest(@Body() dto: CreateCooperationRequestDto) {
|
||||
return this.cooperationService.createRequest(dto);
|
||||
}
|
||||
}
|
||||
|
||||
10
apps/api/src/cooperation/cooperation.module.ts
Normal file
10
apps/api/src/cooperation/cooperation.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CooperationController } from './cooperation.controller';
|
||||
import { CooperationService } from './cooperation.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CooperationController],
|
||||
providers: [CooperationService],
|
||||
})
|
||||
export class CooperationModule {}
|
||||
|
||||
98
apps/api/src/cooperation/cooperation.service.ts
Normal file
98
apps/api/src/cooperation/cooperation.service.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CooperationService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async createRequest(dto: CreateCooperationRequestDto) {
|
||||
const created = await this.prisma.cooperationRequest.create({
|
||||
data: {
|
||||
organization: dto.organization,
|
||||
contactName: dto.contactName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
role: dto.role,
|
||||
organizationType: dto.organizationType,
|
||||
message: dto.message,
|
||||
source: 'landing',
|
||||
status: 'new',
|
||||
},
|
||||
});
|
||||
|
||||
const emailError = await this.trySendEmail(created).catch((error) => error as Error);
|
||||
if (emailError) {
|
||||
await this.prisma.cooperationRequest.update({
|
||||
where: { id: created.id },
|
||||
data: {
|
||||
status: 'stored_email_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
status: emailError ? 'stored_email_failed' : 'stored_and_sent',
|
||||
};
|
||||
}
|
||||
|
||||
private async trySendEmail(request: {
|
||||
id: string;
|
||||
organization: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
role: string | null;
|
||||
organizationType: string | null;
|
||||
message: string;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
const host = this.config.get<string>('SMTP_HOST');
|
||||
const portRaw = this.config.get<string>('SMTP_PORT');
|
||||
const user = this.config.get<string>('SMTP_USER');
|
||||
const pass = this.config.get<string>('SMTP_PASS');
|
||||
const secureRaw = this.config.get<string>('SMTP_SECURE');
|
||||
const to = this.config.get<string>('COOPERATION_EMAIL_TO') || 'exbytestudios@gmail.com';
|
||||
const from = this.config.get<string>('COOPERATION_EMAIL_FROM') || user;
|
||||
|
||||
if (!host || !portRaw || !user || !pass || !from) {
|
||||
throw new Error('SMTP is not configured');
|
||||
}
|
||||
|
||||
const nodemailer = await import('nodemailer');
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: Number(portRaw),
|
||||
secure: String(secureRaw || '').toLowerCase() === 'true',
|
||||
auth: { user, pass },
|
||||
});
|
||||
|
||||
const text = [
|
||||
'Новая заявка на сотрудничество (CourseCraft)',
|
||||
`ID: ${request.id}`,
|
||||
`Организация: ${request.organization}`,
|
||||
`Контакт: ${request.contactName}`,
|
||||
`Email: ${request.email}`,
|
||||
`Телефон: ${request.phone || '—'}`,
|
||||
`Роль: ${request.role || '—'}`,
|
||||
`Тип организации: ${request.organizationType || '—'}`,
|
||||
`Создано: ${request.createdAt.toISOString()}`,
|
||||
'',
|
||||
'Сообщение:',
|
||||
request.message,
|
||||
].join('\n');
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: `[CourseCraft] Cooperation request: ${request.organization}`,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateCooperationRequestDto {
|
||||
@ApiProperty({ example: 'Tech College #17' })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(160)
|
||||
organization: string;
|
||||
|
||||
@ApiProperty({ example: 'Иван Петров' })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
contactName: string;
|
||||
|
||||
@ApiProperty({ example: 'partner@example.edu' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+7 900 000-00-00' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Руководитель цифрового обучения' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
role?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'college' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
organizationType?: string;
|
||||
|
||||
@ApiProperty({ example: 'Хотим пилот на 300 студентов и интеграцию с LMS.' })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
}
|
||||
|
||||
38
apps/api/src/courses/course-sources.controller.ts
Normal file
38
apps/api/src/courses/course-sources.controller.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
import { CourseSourcesService } from './course-sources.service';
|
||||
|
||||
@ApiTags('course-sources')
|
||||
@Controller('courses/:courseId/sources')
|
||||
@ApiBearerAuth()
|
||||
export class CourseSourcesController {
|
||||
constructor(private sourcesService: CourseSourcesService) {}
|
||||
|
||||
@Post('upload')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 25 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async upload(
|
||||
@Param('courseId') courseId: string,
|
||||
@UploadedFile() file: any,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.sourcesService.uploadSource(courseId, user.id, file);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.sourcesService.getSources(courseId, user.id);
|
||||
}
|
||||
|
||||
@Get('outline-hints')
|
||||
async getOutlineHints(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.sourcesService.buildOutlineHints(courseId, user.id);
|
||||
}
|
||||
}
|
||||
158
apps/api/src/courses/course-sources.service.ts
Normal file
158
apps/api/src/courses/course-sources.service.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CourseSourceParseStatus, CourseSourceType } from '@coursecraft/database';
|
||||
import { basename, extname, join } from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import pdfParse from 'pdf-parse';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
|
||||
@Injectable()
|
||||
export class CourseSourcesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async uploadSource(courseId: string, userId: string, file: any): Promise<any> {
|
||||
if (!file) {
|
||||
throw new NotFoundException('File is required');
|
||||
}
|
||||
|
||||
await this.access.assertCourseOwner(courseId, userId);
|
||||
|
||||
const sourceType = this.resolveSourceType(file.originalname, file.mimetype);
|
||||
const storageDir = join('/tmp', 'coursecraft_uploads', courseId);
|
||||
await fs.mkdir(storageDir, { recursive: true });
|
||||
const timestamp = Date.now();
|
||||
const safeName = `${timestamp}-${basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const storagePath = join(storageDir, safeName);
|
||||
|
||||
await fs.writeFile(storagePath, file.buffer);
|
||||
|
||||
let parseStatus: CourseSourceParseStatus = CourseSourceParseStatus.SKIPPED;
|
||||
let extractedText: string | null = null;
|
||||
let extractedMeta: any = null;
|
||||
|
||||
try {
|
||||
if (sourceType === CourseSourceType.TXT) {
|
||||
extractedText = file.buffer.toString('utf8');
|
||||
parseStatus = CourseSourceParseStatus.PARSED;
|
||||
extractedMeta = { method: 'utf8', chars: extractedText?.length || 0 };
|
||||
} else if (sourceType === CourseSourceType.PDF) {
|
||||
const parsed = await pdfParse(file.buffer);
|
||||
extractedText = (parsed.text || '').trim();
|
||||
parseStatus = CourseSourceParseStatus.PARSED;
|
||||
extractedMeta = {
|
||||
method: 'pdf-parse',
|
||||
pages: parsed.numpages || null,
|
||||
chars: extractedText?.length || 0,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
parseStatus = CourseSourceParseStatus.FAILED;
|
||||
extractedMeta = {
|
||||
method: sourceType === CourseSourceType.PDF ? 'pdf-parse' : 'unknown',
|
||||
error: error?.message || 'Parse failed',
|
||||
};
|
||||
}
|
||||
|
||||
const created = await this.prisma.courseSourceFile.create({
|
||||
data: {
|
||||
courseId,
|
||||
uploadedById: userId,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
fileSize: file.size || file.buffer.length,
|
||||
sourceType,
|
||||
storagePath,
|
||||
parseStatus,
|
||||
extractedText,
|
||||
extractedMeta,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
fileSize: true,
|
||||
sourceType: true,
|
||||
parseStatus: true,
|
||||
extractedMeta: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...created,
|
||||
extractedPreview:
|
||||
parseStatus === CourseSourceParseStatus.PARSED && extractedText
|
||||
? extractedText.slice(0, 500)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getSources(courseId: string, userId: string): Promise<any> {
|
||||
await this.access.assertCourseOwnerOrStaff(courseId, userId);
|
||||
return this.prisma.courseSourceFile.findMany({
|
||||
where: { courseId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
fileSize: true,
|
||||
sourceType: true,
|
||||
parseStatus: true,
|
||||
extractedMeta: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async buildOutlineHints(courseId: string, userId: string): Promise<any> {
|
||||
await this.access.assertCourseOwnerOrStaff(courseId, userId);
|
||||
|
||||
const files = await this.prisma.courseSourceFile.findMany({
|
||||
where: { courseId, parseStatus: CourseSourceParseStatus.PARSED },
|
||||
select: { id: true, fileName: true, sourceType: true, extractedText: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const mergedText = files.map((f) => f.extractedText || '').join('\n\n').trim();
|
||||
if (!mergedText) {
|
||||
return { filesCount: files.length, hints: [], summary: null };
|
||||
}
|
||||
|
||||
const lines = mergedText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 8);
|
||||
const headingCandidates = lines
|
||||
.filter((line) => /^(\\d+[.)]\\s+|[A-ZА-ЯЁ][^.!?]{8,80}$)/.test(line))
|
||||
.slice(0, 24);
|
||||
|
||||
const hints = headingCandidates.slice(0, 8).map((line, idx) => ({
|
||||
id: `hint_${idx + 1}`,
|
||||
title: line.replace(/^\\d+[.)]\\s+/, ''),
|
||||
}));
|
||||
|
||||
return {
|
||||
filesCount: files.length,
|
||||
summary: `Найдено ${files.length} источников с текстом. Сформированы рекомендации по структуре.`,
|
||||
hints,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSourceType(fileName: string, mimeType: string): CourseSourceType {
|
||||
const ext = extname(fileName).toLowerCase();
|
||||
if (mimeType.startsWith('image/') || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'].includes(ext)) {
|
||||
return CourseSourceType.IMAGE;
|
||||
}
|
||||
if (ext === '.pdf' || mimeType.includes('pdf')) return CourseSourceType.PDF;
|
||||
if (ext === '.docx') return CourseSourceType.DOCX;
|
||||
if (ext === '.txt' || mimeType.includes('text/plain')) return CourseSourceType.TXT;
|
||||
if (ext === '.pptx') return CourseSourceType.PPTX;
|
||||
if (ext === '.zip' || mimeType.includes('zip')) return CourseSourceType.ZIP;
|
||||
return CourseSourceType.OTHER;
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User, CourseStatus } from '@coursecraft/database';
|
||||
import { CreateCourseDto } from './dto/create-course.dto';
|
||||
import { UpdateCourseDto } from './dto/update-course.dto';
|
||||
import { ReviewHomeworkDto } from './dto/review-homework.dto';
|
||||
|
||||
@ApiTags('courses')
|
||||
@Controller('courses')
|
||||
@ -75,4 +76,24 @@ export class CoursesController {
|
||||
): Promise<any> {
|
||||
return this.coursesService.updateStatus(id, user.id, status);
|
||||
}
|
||||
|
||||
@Post(':id/homework-submissions/:submissionId/review')
|
||||
@ApiOperation({ summary: 'Review homework submission as course author' })
|
||||
async reviewHomework(
|
||||
@Param('id') id: string,
|
||||
@Param('submissionId') submissionId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body() dto: ReviewHomeworkDto
|
||||
): Promise<any> {
|
||||
return this.coursesService.reviewHomeworkSubmission(id, submissionId, user.id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/homework-submissions')
|
||||
@ApiOperation({ summary: 'Get homework submissions for course author review' })
|
||||
async getHomeworkSubmissions(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: User
|
||||
): Promise<any> {
|
||||
return this.coursesService.getHomeworkSubmissionsForAuthor(id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,14 @@ import { ChaptersController } from './chapters.controller';
|
||||
import { ChaptersService } from './chapters.service';
|
||||
import { LessonsController } from './lessons.controller';
|
||||
import { LessonsService } from './lessons.service';
|
||||
import { CourseSourcesController } from './course-sources.controller';
|
||||
import { CourseSourcesService } from './course-sources.service';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
controllers: [CoursesController, ChaptersController, LessonsController],
|
||||
providers: [CoursesService, ChaptersService, LessonsService],
|
||||
exports: [CoursesService, ChaptersService, LessonsService],
|
||||
imports: [AccessModule],
|
||||
controllers: [CoursesController, ChaptersController, LessonsController, CourseSourcesController],
|
||||
providers: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
|
||||
exports: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
|
||||
})
|
||||
export class CoursesModule {}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { Course, CourseStatus } from '@coursecraft/database';
|
||||
import { Course, CourseStatus, Prisma } from '@coursecraft/database';
|
||||
import { generateUniqueSlug } from '@coursecraft/shared';
|
||||
import { CreateCourseDto } from './dto/create-course.dto';
|
||||
import { UpdateCourseDto } from './dto/update-course.dto';
|
||||
@ -12,13 +12,20 @@ export class CoursesService {
|
||||
async create(authorId: string, dto: CreateCourseDto): Promise<Course> {
|
||||
const slug = generateUniqueSlug(dto.title);
|
||||
|
||||
return this.prisma.course.create({
|
||||
const created = await this.prisma.course.create({
|
||||
data: {
|
||||
authorId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
slug,
|
||||
status: CourseStatus.DRAFT,
|
||||
groups: {
|
||||
create: {
|
||||
name: 'Основная группа',
|
||||
description: 'Обсуждение курса и вопросы преподавателю',
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
chapters: {
|
||||
@ -29,6 +36,18 @@ export class CoursesService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId: created.id,
|
||||
fromStatus: null,
|
||||
toStatus: CourseStatus.DRAFT,
|
||||
changedById: authorId,
|
||||
note: 'Course created',
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findAllByAuthor(
|
||||
@ -117,6 +136,7 @@ export class CoursesService {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
category: true,
|
||||
groups: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -167,6 +187,12 @@ export class CoursesService {
|
||||
estimatedHours: dto.estimatedHours,
|
||||
metaTitle: dto.metaTitle,
|
||||
metaDescription: dto.metaDescription,
|
||||
...(dto.price !== undefined
|
||||
? {
|
||||
price: dto.price > 0 ? new Prisma.Decimal(dto.price) : null,
|
||||
}
|
||||
: {}),
|
||||
...(dto.currency ? { currency: dto.currency.toUpperCase() } : {}),
|
||||
},
|
||||
include: {
|
||||
chapters: {
|
||||
@ -208,16 +234,28 @@ export class CoursesService {
|
||||
throw new ForbiddenException('You can only edit your own courses');
|
||||
}
|
||||
|
||||
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status };
|
||||
|
||||
if (status === CourseStatus.PUBLISHED && !course.publishedAt) {
|
||||
updateData.publishedAt = new Date();
|
||||
if (status === CourseStatus.PUBLISHED || status === CourseStatus.APPROVED) {
|
||||
throw new ForbiddenException('Course can be published only by moderation');
|
||||
}
|
||||
|
||||
return this.prisma.course.update({
|
||||
const nextStatus = status === CourseStatus.PENDING_REVIEW ? CourseStatus.PENDING_MODERATION : status;
|
||||
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status: nextStatus };
|
||||
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId: id,
|
||||
fromStatus: course.status,
|
||||
toStatus: nextStatus,
|
||||
changedById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async checkOwnership(courseId: string, userId: string): Promise<boolean> {
|
||||
@ -228,4 +266,91 @@ export class CoursesService {
|
||||
|
||||
return course?.authorId === userId;
|
||||
}
|
||||
|
||||
async reviewHomeworkSubmission(
|
||||
courseId: string,
|
||||
submissionId: string,
|
||||
authorId: string,
|
||||
dto: { teacherScore: number; teacherFeedback?: string }
|
||||
) {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.authorId !== authorId) {
|
||||
throw new ForbiddenException('Only course author can review homework');
|
||||
}
|
||||
|
||||
const submission = await this.prisma.homeworkSubmission.findUnique({
|
||||
where: { id: submissionId },
|
||||
include: {
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
include: {
|
||||
chapter: {
|
||||
select: { courseId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!submission || submission.homework.lesson.chapter.courseId !== courseId) {
|
||||
throw new NotFoundException('Homework submission not found');
|
||||
}
|
||||
|
||||
return this.prisma.homeworkSubmission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
teacherScore: dto.teacherScore,
|
||||
teacherFeedback: dto.teacherFeedback,
|
||||
reviewStatus: 'TEACHER_REVIEWED',
|
||||
gradedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getHomeworkSubmissionsForAuthor(courseId: string, authorId: string) {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.authorId !== authorId) {
|
||||
throw new ForbiddenException('Only course author can view submissions');
|
||||
}
|
||||
|
||||
return this.prisma.homeworkSubmission.findMany({
|
||||
where: {
|
||||
homework: {
|
||||
lesson: {
|
||||
chapter: {
|
||||
courseId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true, avatarUrl: true },
|
||||
},
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, title: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ reviewStatus: 'asc' }, { submittedAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
15
apps/api/src/courses/dto/review-homework.dto.ts
Normal file
15
apps/api/src/courses/dto/review-homework.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ReviewHomeworkDto {
|
||||
@ApiProperty({ description: 'Teacher score from 1 to 5', minimum: 1, maximum: 5 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
teacherScore: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Teacher feedback' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teacherFeedback?: string;
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CourseStatus } from '@coursecraft/database';
|
||||
@ -68,4 +69,17 @@ export class UpdateCourseDto {
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
metaDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Course price. Null or 0 means free' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Currency (ISO code)', example: 'USD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
@IsIn(['USD', 'EUR', 'RUB'])
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
@ -64,4 +64,21 @@ export class LessonsController {
|
||||
): Promise<any> {
|
||||
return this.lessonsService.reorder(chapterId, user.id, lessonIds);
|
||||
}
|
||||
|
||||
@Get('lessons/:lessonId/quiz')
|
||||
@ApiOperation({ summary: 'Generate quiz for a lesson' })
|
||||
async generateQuiz(@Param('lessonId') lessonId: string): Promise<any> {
|
||||
return this.lessonsService.generateQuiz(lessonId);
|
||||
}
|
||||
|
||||
@Post('lessons/:lessonId/homework/generate')
|
||||
@ApiOperation({ summary: 'Generate homework task for lesson (author only)' })
|
||||
async generateHomework(
|
||||
@Param('courseId') courseId: string,
|
||||
@Param('lessonId') lessonId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body('type') type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'
|
||||
): Promise<any> {
|
||||
return this.lessonsService.generateHomework(courseId, lessonId, user.id, type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { Lesson } from '@coursecraft/database';
|
||||
import { HomeworkType, Lesson } from '@coursecraft/database';
|
||||
import { CoursesService } from './courses.service';
|
||||
import { ChaptersService } from './chapters.service';
|
||||
import { CreateLessonDto } from './dto/create-lesson.dto';
|
||||
@ -138,4 +138,222 @@ export class LessonsService {
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async generateQuiz(lessonId: string): Promise<any> {
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: { quiz: true },
|
||||
});
|
||||
if (!lesson) throw new NotFoundException('Lesson not found');
|
||||
|
||||
// Return cached quiz if exists
|
||||
if (lesson.quiz) {
|
||||
return { questions: lesson.quiz.questions };
|
||||
}
|
||||
|
||||
// Generate quiz using AI
|
||||
const questions = await this.generateQuizWithAI(lesson.content, lesson.title);
|
||||
|
||||
// Save to database
|
||||
await this.prisma.quiz.create({
|
||||
data: { lessonId, questions: questions as any },
|
||||
});
|
||||
|
||||
return { questions };
|
||||
}
|
||||
|
||||
private async generateQuizWithAI(content: any, lessonTitle: string): Promise<any[]> {
|
||||
try {
|
||||
// Extract text from TipTap JSON content
|
||||
const textContent = this.extractTextFromContent(content);
|
||||
if (!textContent || textContent.length < 50) {
|
||||
// Not enough content, return simple quiz
|
||||
return this.getDefaultQuiz();
|
||||
}
|
||||
|
||||
// Call OpenRouter to generate quiz
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const baseUrl = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
||||
const model = process.env.AI_MODEL_FREE || 'openai/gpt-4o-mini';
|
||||
|
||||
if (!apiKey) {
|
||||
return this.getDefaultQuiz();
|
||||
}
|
||||
|
||||
const prompt = `На основе следующего содержания урока "${lessonTitle}" сгенерируй 3-5 вопросов с вариантами ответов для теста.
|
||||
|
||||
Содержание урока:
|
||||
${textContent.slice(0, 3000)}
|
||||
|
||||
Верни JSON массив вопросов в формате:
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"question": "Вопрос?",
|
||||
"options": ["Вариант А", "Вариант Б", "Вариант В", "Вариант Г"],
|
||||
"correctAnswer": 0
|
||||
}
|
||||
]
|
||||
|
||||
Только JSON, без markdown.`;
|
||||
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return this.getDefaultQuiz();
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
const aiResponse = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
// Parse JSON from response
|
||||
const jsonMatch = aiResponse.match(/\[[\s\S]*\]/);
|
||||
if (jsonMatch) {
|
||||
const questions = JSON.parse(jsonMatch[0]);
|
||||
return questions;
|
||||
}
|
||||
|
||||
return this.getDefaultQuiz();
|
||||
} catch {
|
||||
return this.getDefaultQuiz();
|
||||
}
|
||||
}
|
||||
|
||||
private extractTextFromContent(content: any): string {
|
||||
if (!content || typeof content !== 'object') return '';
|
||||
|
||||
let text = '';
|
||||
const traverse = (node: any) => {
|
||||
if (node.type === 'text' && node.text) {
|
||||
text += node.text + ' ';
|
||||
}
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content.forEach(traverse);
|
||||
}
|
||||
};
|
||||
traverse(content);
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
private getDefaultQuiz(): any[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
question: 'Вы изучили материал урока?',
|
||||
options: ['Да, всё понятно', 'Частично', 'Нужно повторить', 'Нет'],
|
||||
correctAnswer: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
question: 'Какие основные темы были рассмотрены?',
|
||||
options: ['Теория и практика', 'Только теория', 'Только примеры', 'Не помню'],
|
||||
correctAnswer: 0,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
question: 'Готовы ли вы применить полученные знания?',
|
||||
options: ['Да, готов', 'Нужна практика', 'Нужно повторить', 'Нет'],
|
||||
correctAnswer: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async generateHomework(
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
userId: string,
|
||||
type: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB' = 'TEXT'
|
||||
): Promise<any> {
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
chapter: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
courseId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!lesson || lesson.chapter.courseId !== courseId) {
|
||||
throw new NotFoundException('Lesson not found');
|
||||
}
|
||||
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('Only course author can generate homework');
|
||||
}
|
||||
|
||||
const homeworkType = HomeworkType[type] ? (type as HomeworkType) : HomeworkType.TEXT;
|
||||
const template = this.buildHomeworkTemplate(lesson.title, homeworkType);
|
||||
|
||||
return this.prisma.homework.upsert({
|
||||
where: { lessonId },
|
||||
create: {
|
||||
lessonId,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
type: homeworkType,
|
||||
config: template.config as any,
|
||||
},
|
||||
update: {
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
type: homeworkType,
|
||||
config: template.config as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private buildHomeworkTemplate(lessonTitle: string, type: HomeworkType): {
|
||||
title: string;
|
||||
description: string;
|
||||
config: Record<string, unknown>;
|
||||
} {
|
||||
if (type === HomeworkType.FILE) {
|
||||
return {
|
||||
title: `Практическая работа (файл): ${lessonTitle}`,
|
||||
description: 'Подготовьте файл с выполненным заданием и приложите ссылку/файл в ответе.',
|
||||
config: { acceptedFormats: ['pdf', 'docx', 'txt', 'zip'] },
|
||||
};
|
||||
}
|
||||
if (type === HomeworkType.PROJECT) {
|
||||
return {
|
||||
title: `Мини-проект: ${lessonTitle}`,
|
||||
description: 'Сделайте небольшой проект по материалу урока. Опишите архитектуру и результат.',
|
||||
config: { rubric: ['Понимание темы', 'Практичность', 'Качество решения'] },
|
||||
};
|
||||
}
|
||||
if (type === HomeworkType.GITHUB) {
|
||||
return {
|
||||
title: `GitHub задача: ${lessonTitle}`,
|
||||
description: 'Выполните задачу и приложите ссылку на публичный GitHub-репозиторий.',
|
||||
config: { requireGithubUrl: true },
|
||||
};
|
||||
}
|
||||
if (type === HomeworkType.QUIZ) {
|
||||
return {
|
||||
title: `Тест-кейс: ${lessonTitle}`,
|
||||
description: 'Ответьте на контрольные вопросы и приложите обоснование решений.',
|
||||
config: { requireExplanation: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: `Письменное домашнее задание: ${lessonTitle}`,
|
||||
description:
|
||||
'Опишите, как вы примените изученную тему на практике. Приведите примеры, обоснования и собственные выводы.',
|
||||
config: { minLength: 200 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal file
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateReviewDto {
|
||||
@ApiProperty({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Review title' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Review content' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
content?: string;
|
||||
}
|
||||
33
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal file
33
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { HomeworkType } from '@coursecraft/database';
|
||||
import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SubmitHomeworkDto {
|
||||
@ApiProperty({
|
||||
description: 'Written homework answer',
|
||||
minLength: 1,
|
||||
maxLength: 20000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20000)
|
||||
content?: string;
|
||||
|
||||
@ApiProperty({ enum: HomeworkType, default: HomeworkType.TEXT })
|
||||
@IsOptional()
|
||||
@IsEnum(HomeworkType)
|
||||
type?: HomeworkType;
|
||||
|
||||
@ApiProperty({ required: false, description: 'File URL for FILE/PROJECT submission' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
attachmentUrl?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'GitHub repository URL' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
githubUrl?: string;
|
||||
}
|
||||
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal file
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class SubmitQuizDto {
|
||||
@ApiProperty({
|
||||
description: 'Selected option index for each quiz question',
|
||||
type: [Number],
|
||||
example: [0, 2, 1],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
answers: number[];
|
||||
}
|
||||
109
apps/api/src/enrollment/enrollment.controller.ts
Normal file
109
apps/api/src/enrollment/enrollment.controller.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { EnrollmentService } from './enrollment.service';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
import { SubmitQuizDto } from './dto/submit-quiz.dto';
|
||||
import { CreateReviewDto } from './dto/create-review.dto';
|
||||
import { SubmitHomeworkDto } from './dto/submit-homework.dto';
|
||||
|
||||
@ApiTags('enrollment')
|
||||
@Controller('enrollment')
|
||||
@ApiBearerAuth()
|
||||
export class EnrollmentController {
|
||||
constructor(private enrollmentService: EnrollmentService) {}
|
||||
|
||||
@Post(':courseId/enroll')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Enroll in a course' })
|
||||
async enroll(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.enrollmentService.enroll(user.id, courseId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get my enrolled courses' })
|
||||
async myEnrollments(@CurrentUser() user: User): Promise<any> {
|
||||
return this.enrollmentService.getUserEnrollments(user.id);
|
||||
}
|
||||
|
||||
@Get(':courseId/progress')
|
||||
@ApiOperation({ summary: 'Get my progress for a course' })
|
||||
async getProgress(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.enrollmentService.getProgress(user.id, courseId);
|
||||
}
|
||||
|
||||
@Post(':courseId/lessons/:lessonId/complete')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Mark a lesson as completed' })
|
||||
async completeLesson(
|
||||
@Param('courseId') courseId: string,
|
||||
@Param('lessonId') lessonId: string,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.enrollmentService.completeLesson(user.id, courseId, lessonId);
|
||||
}
|
||||
|
||||
@Post(':courseId/lessons/:lessonId/quiz')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Submit quiz score' })
|
||||
async submitQuiz(
|
||||
@Param('courseId') courseId: string,
|
||||
@Param('lessonId') lessonId: string,
|
||||
@Body() dto: SubmitQuizDto,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.enrollmentService.submitQuiz(user.id, courseId, lessonId, dto.answers);
|
||||
}
|
||||
|
||||
@Get(':courseId/lessons/:lessonId/homework')
|
||||
@ApiOperation({ summary: 'Get (or lazy-create) homework for lesson' })
|
||||
async getHomework(
|
||||
@Param('courseId') courseId: string,
|
||||
@Param('lessonId') lessonId: string,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.enrollmentService.getHomework(user.id, courseId, lessonId);
|
||||
}
|
||||
|
||||
@Post(':courseId/lessons/:lessonId/homework')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Submit written homework for lesson' })
|
||||
async submitHomework(
|
||||
@Param('courseId') courseId: string,
|
||||
@Param('lessonId') lessonId: string,
|
||||
@Body() dto: SubmitHomeworkDto,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto);
|
||||
}
|
||||
|
||||
@Post(':courseId/review')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Leave a review' })
|
||||
async createReview(
|
||||
@Param('courseId') courseId: string,
|
||||
@Body() body: CreateReviewDto,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content);
|
||||
}
|
||||
|
||||
@Get(':courseId/reviews')
|
||||
@ApiOperation({ summary: 'Get course reviews' })
|
||||
async getReviews(
|
||||
@Param('courseId') courseId: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<any> {
|
||||
return this.enrollmentService.getCourseReviews(courseId, page, limit);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/enrollment/enrollment.module.ts
Normal file
10
apps/api/src/enrollment/enrollment.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EnrollmentController } from './enrollment.controller';
|
||||
import { EnrollmentService } from './enrollment.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController],
|
||||
providers: [EnrollmentService],
|
||||
exports: [EnrollmentService],
|
||||
})
|
||||
export class EnrollmentModule {}
|
||||
403
apps/api/src/enrollment/enrollment.service.ts
Normal file
403
apps/api/src/enrollment/enrollment.service.ts
Normal file
@ -0,0 +1,403 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { HomeworkReviewStatus, HomeworkType } from '@coursecraft/database';
|
||||
|
||||
const QUIZ_PASS_THRESHOLD = 70;
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async enroll(userId: string, courseId: string): Promise<any> {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, price: true, title: true, slug: true },
|
||||
});
|
||||
if (!course) throw new NotFoundException('Course not found');
|
||||
|
||||
if (course.price) {
|
||||
const purchase = await this.prisma.purchase.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
if (!purchase || purchase.status !== 'completed') {
|
||||
throw new ForbiddenException('Purchase is required for paid course');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
if (existing) throw new ConflictException('Already enrolled');
|
||||
|
||||
const enrollment = await this.prisma.enrollment.create({
|
||||
data: { userId, courseId },
|
||||
include: { course: { select: { id: true, title: true, slug: true } } },
|
||||
});
|
||||
|
||||
await this.addUserToDefaultCourseGroup(courseId, userId);
|
||||
return enrollment;
|
||||
}
|
||||
|
||||
async getUserEnrollments(userId: string): Promise<any> {
|
||||
return this.prisma.enrollment.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
course: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, avatarUrl: true } },
|
||||
_count: { select: { chapters: true } },
|
||||
chapters: { include: { _count: { select: { lessons: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async completeLesson(userId: string, courseId: string, lessonId: string): Promise<any> {
|
||||
const enrollment = await this.requireEnrollment(userId, courseId);
|
||||
await this.assertLessonUnlocked(userId, courseId, lessonId);
|
||||
|
||||
const progress = await this.prisma.lessonProgress.findUnique({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
});
|
||||
if (!progress?.quizPassed || !progress?.homeworkSubmitted) {
|
||||
throw new BadRequestException('Lesson can be completed only after quiz and homework');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.lessonProgress.update({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
data: { completedAt: new Date() },
|
||||
});
|
||||
await this.recalculateProgress(enrollment.id, courseId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async submitQuiz(userId: string, courseId: string, lessonId: string, answers: number[]): Promise<any> {
|
||||
const enrollment = await this.requireEnrollment(userId, courseId);
|
||||
await this.assertLessonUnlocked(userId, courseId, lessonId);
|
||||
|
||||
const quiz = await this.prisma.quiz.findUnique({
|
||||
where: { lessonId },
|
||||
});
|
||||
if (!quiz) {
|
||||
throw new NotFoundException('Quiz not found for this lesson');
|
||||
}
|
||||
|
||||
const questions = Array.isArray(quiz.questions) ? (quiz.questions as any[]) : [];
|
||||
if (questions.length === 0) {
|
||||
throw new BadRequestException('Quiz has no questions');
|
||||
}
|
||||
if (!Array.isArray(answers) || answers.length !== questions.length) {
|
||||
throw new BadRequestException('Answers count must match questions count');
|
||||
}
|
||||
|
||||
const correctAnswers = questions.reduce((acc, question, index) => {
|
||||
const expected = Number(question.correctAnswer);
|
||||
const actual = Number(answers[index]);
|
||||
return acc + (expected === actual ? 1 : 0);
|
||||
}, 0);
|
||||
const score = Math.round((correctAnswers / questions.length) * 100);
|
||||
const passed = score >= QUIZ_PASS_THRESHOLD;
|
||||
|
||||
const existing = await this.prisma.lessonProgress.findUnique({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
});
|
||||
const finalPassed = Boolean(existing?.quizPassed || passed);
|
||||
const completedAt = finalPassed && existing?.homeworkSubmitted ? new Date() : existing?.completedAt;
|
||||
|
||||
const progress = await this.prisma.lessonProgress.upsert({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
create: {
|
||||
userId,
|
||||
enrollmentId: enrollment.id,
|
||||
lessonId,
|
||||
quizScore: score,
|
||||
quizPassed: finalPassed,
|
||||
quizPassedAt: finalPassed ? new Date() : null,
|
||||
completedAt: completedAt || null,
|
||||
},
|
||||
update: {
|
||||
quizScore: score,
|
||||
quizPassed: finalPassed,
|
||||
quizPassedAt: finalPassed ? existing?.quizPassedAt || new Date() : null,
|
||||
completedAt: completedAt || null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.recalculateProgress(enrollment.id, courseId);
|
||||
return {
|
||||
score,
|
||||
passed,
|
||||
passThreshold: QUIZ_PASS_THRESHOLD,
|
||||
totalQuestions: questions.length,
|
||||
correctAnswers,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
async getHomework(userId: string, courseId: string, lessonId: string): Promise<any> {
|
||||
await this.requireEnrollment(userId, courseId);
|
||||
await this.assertLessonUnlocked(userId, courseId, lessonId);
|
||||
|
||||
const lesson = await this.prisma.lesson.findFirst({
|
||||
where: { id: lessonId, chapter: { courseId } },
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('Lesson not found');
|
||||
}
|
||||
|
||||
const homework = await this.prisma.homework.upsert({
|
||||
where: { lessonId: lesson.id },
|
||||
create: {
|
||||
lessonId: lesson.id,
|
||||
title: `Письменное домашнее задание: ${lesson.title}`,
|
||||
description:
|
||||
'Опишите, как вы примените изученную тему на практике. Приведите примеры, обоснования и собственные выводы.',
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const submission = await this.prisma.homeworkSubmission.findUnique({
|
||||
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
|
||||
});
|
||||
|
||||
return { homework, submission };
|
||||
}
|
||||
|
||||
async submitHomework(
|
||||
userId: string,
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
dto: { content?: string; type?: HomeworkType; attachmentUrl?: string; githubUrl?: string }
|
||||
): Promise<any> {
|
||||
const enrollment = await this.requireEnrollment(userId, courseId);
|
||||
await this.assertLessonUnlocked(userId, courseId, lessonId);
|
||||
|
||||
const lessonProgress = await this.prisma.lessonProgress.findUnique({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
});
|
||||
if (!lessonProgress?.quizPassed) {
|
||||
throw new BadRequestException('Pass the quiz before submitting homework');
|
||||
}
|
||||
|
||||
const { homework } = await this.getHomework(userId, courseId, lessonId);
|
||||
const submissionType = dto.type || homework.type || HomeworkType.TEXT;
|
||||
const normalizedContent = (dto.content || '').trim();
|
||||
if (!normalizedContent && !dto.attachmentUrl && !dto.githubUrl) {
|
||||
throw new BadRequestException('Provide content, attachment URL, or GitHub URL');
|
||||
}
|
||||
const fallbackContent =
|
||||
normalizedContent ||
|
||||
dto.githubUrl ||
|
||||
dto.attachmentUrl ||
|
||||
`Submission type: ${submissionType}`;
|
||||
const aiResult = this.gradeHomeworkWithAI(fallbackContent);
|
||||
|
||||
const submission = await this.prisma.homeworkSubmission.upsert({
|
||||
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
|
||||
create: {
|
||||
homeworkId: homework.id,
|
||||
userId,
|
||||
content: fallbackContent,
|
||||
answerType: submissionType,
|
||||
attachmentUrl: dto.attachmentUrl || null,
|
||||
githubUrl: dto.githubUrl || null,
|
||||
aiScore: aiResult.score,
|
||||
aiFeedback: aiResult.feedback,
|
||||
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
|
||||
},
|
||||
update: {
|
||||
content: fallbackContent,
|
||||
answerType: submissionType,
|
||||
attachmentUrl: dto.attachmentUrl || null,
|
||||
githubUrl: dto.githubUrl || null,
|
||||
aiScore: aiResult.score,
|
||||
aiFeedback: aiResult.feedback,
|
||||
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
|
||||
submittedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.lessonProgress.upsert({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
create: {
|
||||
userId,
|
||||
enrollmentId: enrollment.id,
|
||||
lessonId,
|
||||
quizScore: lessonProgress.quizScore,
|
||||
quizPassed: true,
|
||||
quizPassedAt: lessonProgress.quizPassedAt || new Date(),
|
||||
homeworkSubmitted: true,
|
||||
homeworkSubmittedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
homeworkSubmitted: true,
|
||||
homeworkSubmittedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.recalculateProgress(enrollment.id, courseId);
|
||||
return submission;
|
||||
}
|
||||
|
||||
async getProgress(userId: string, courseId: string): Promise<any> {
|
||||
return this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
include: {
|
||||
lessons: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createReview(userId: string, courseId: string, rating: number, title?: string, content?: string): Promise<any> {
|
||||
await this.requireEnrollment(userId, courseId);
|
||||
|
||||
const review = await this.prisma.review.upsert({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
create: { userId, courseId, rating, title, content, isApproved: true },
|
||||
update: { rating, title, content, isApproved: true },
|
||||
});
|
||||
|
||||
await this.recalculateAverageRating(courseId);
|
||||
return review;
|
||||
}
|
||||
|
||||
async getCourseReviews(courseId: string, page = 1, limit = 20): Promise<any> {
|
||||
const safePage = Math.max(1, Number(page) || 1);
|
||||
const safeLimit = Math.min(100, Math.max(1, Number(limit) || 20));
|
||||
const skip = (safePage - 1) * safeLimit;
|
||||
const [reviews, total] = await Promise.all([
|
||||
this.prisma.review.findMany({
|
||||
where: { courseId, isApproved: true },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: safeLimit,
|
||||
}),
|
||||
this.prisma.review.count({ where: { courseId, isApproved: true } }),
|
||||
]);
|
||||
return { data: reviews, meta: { page: safePage, limit: safeLimit, total } };
|
||||
}
|
||||
|
||||
async recalculateAverageRating(courseId: string): Promise<void> {
|
||||
const result = await this.prisma.review.aggregate({
|
||||
where: { courseId, isApproved: true },
|
||||
_avg: { rating: true },
|
||||
});
|
||||
await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: { averageRating: result._avg.rating ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
private async requireEnrollment(userId: string, courseId: string) {
|
||||
const enrollment = await this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
if (!enrollment) {
|
||||
throw new NotFoundException('Not enrolled in this course');
|
||||
}
|
||||
return enrollment;
|
||||
}
|
||||
|
||||
private async assertLessonUnlocked(userId: string, courseId: string, lessonId: string): Promise<void> {
|
||||
const orderedLessons = await this.prisma.lesson.findMany({
|
||||
where: { chapter: { courseId } },
|
||||
orderBy: [{ chapter: { order: 'asc' } }, { order: 'asc' }],
|
||||
select: { id: true },
|
||||
});
|
||||
const targetIndex = orderedLessons.findIndex((lesson) => lesson.id === lessonId);
|
||||
if (targetIndex === -1) {
|
||||
throw new NotFoundException('Lesson not found in this course');
|
||||
}
|
||||
if (targetIndex === 0) return;
|
||||
|
||||
const prevLessonId = orderedLessons[targetIndex - 1].id;
|
||||
const prevProgress = await this.prisma.lessonProgress.findUnique({
|
||||
where: { userId_lessonId: { userId, lessonId: prevLessonId } },
|
||||
select: { quizPassed: true, homeworkSubmitted: true },
|
||||
});
|
||||
if (!prevProgress?.quizPassed || !prevProgress?.homeworkSubmitted) {
|
||||
throw new ForbiddenException('Complete previous lesson quiz and homework first');
|
||||
}
|
||||
}
|
||||
|
||||
private async addUserToDefaultCourseGroup(courseId: string, userId: string): Promise<void> {
|
||||
let group = await this.prisma.courseGroup.findFirst({
|
||||
where: { courseId, isDefault: true },
|
||||
});
|
||||
if (!group) {
|
||||
group = await this.prisma.courseGroup.create({
|
||||
data: {
|
||||
courseId,
|
||||
name: 'Основная группа',
|
||||
description: 'Обсуждение курса и вопросы преподавателю',
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.groupMember.upsert({
|
||||
where: { groupId_userId: { groupId: group.id, userId } },
|
||||
create: { groupId: group.id, userId, role: 'student' },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
private async recalculateProgress(enrollmentId: string, courseId: string): Promise<void> {
|
||||
const totalLessons = await this.prisma.lesson.count({
|
||||
where: { chapter: { courseId } },
|
||||
});
|
||||
const completedLessons = await this.prisma.lessonProgress.count({
|
||||
where: {
|
||||
enrollmentId,
|
||||
quizPassed: true,
|
||||
homeworkSubmitted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const progress = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
|
||||
|
||||
await this.prisma.enrollment.update({
|
||||
where: { id: enrollmentId },
|
||||
data: {
|
||||
progress,
|
||||
completedAt: progress >= 100 ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private gradeHomeworkWithAI(content: string): { score: number; feedback: string } {
|
||||
const normalized = content.trim();
|
||||
const words = normalized.split(/\s+/).filter(Boolean).length;
|
||||
const hasStructure = /(^|\n)\s*[-*•]|\d+\./.test(normalized);
|
||||
const hasExamples = /например|пример|кейc|case/i.test(normalized);
|
||||
|
||||
let score = 3;
|
||||
if (words >= 250) score = 4;
|
||||
if (words >= 450 && hasStructure && hasExamples) score = 5;
|
||||
if (words < 120) score = 2;
|
||||
if (words < 60) score = 1;
|
||||
|
||||
let feedback = 'Хорошая работа. Раскройте больше практических деталей и аргументов.';
|
||||
if (score === 5) {
|
||||
feedback = 'Отличная работа: есть структура, примеры и практическая применимость.';
|
||||
} else if (score === 4) {
|
||||
feedback = 'Сильная работа. Добавьте ещё один практический кейс для максимальной оценки.';
|
||||
} else if (score <= 2) {
|
||||
feedback = 'Ответ слишком краткий. Раскройте тему глубже и добавьте практические примеры.';
|
||||
}
|
||||
|
||||
return { score, feedback };
|
||||
}
|
||||
}
|
||||
60
apps/api/src/groups/groups.controller.ts
Normal file
60
apps/api/src/groups/groups.controller.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Controller, Post, Get, Param, Body, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
|
||||
@ApiTags('groups')
|
||||
@Controller('groups')
|
||||
@ApiBearerAuth()
|
||||
export class GroupsController {
|
||||
constructor(private groupsService: GroupsService) {}
|
||||
|
||||
@Post()
|
||||
async createGroup(@Body() body: { courseId: string; name: string; description?: string }, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.createGroup(body.courseId, user.id, body.name, body.description);
|
||||
}
|
||||
|
||||
@Post(':groupId/members')
|
||||
async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.addMember(groupId, user.id, userId);
|
||||
}
|
||||
|
||||
@Get('course/:courseId/default')
|
||||
async getDefaultGroup(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.getDefaultGroup(courseId, user.id);
|
||||
}
|
||||
|
||||
@Get(':groupId/members')
|
||||
async getMembers(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.getGroupMembers(groupId, user.id);
|
||||
}
|
||||
|
||||
@Get(':groupId/messages')
|
||||
async getMessages(
|
||||
@Param('groupId') groupId: string,
|
||||
@Query('lessonId') lessonId: string | undefined,
|
||||
@CurrentUser() user: User
|
||||
): Promise<any> {
|
||||
return this.groupsService.getGroupMessages(groupId, user.id, lessonId);
|
||||
}
|
||||
|
||||
@Post(':groupId/messages')
|
||||
async sendMessage(
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() body: { content: string; lessonId?: string },
|
||||
@CurrentUser() user: User
|
||||
): Promise<any> {
|
||||
return this.groupsService.sendMessage(groupId, user.id, body.content, body.lessonId);
|
||||
}
|
||||
|
||||
@Post(':groupId/invite-link')
|
||||
async createInviteLink(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.createInviteLink(groupId, user.id);
|
||||
}
|
||||
|
||||
@Post('join/:groupId')
|
||||
async joinByInvite(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.joinByInvite(groupId, user.id);
|
||||
}
|
||||
}
|
||||
113
apps/api/src/groups/groups.gateway.ts
Normal file
113
apps/api/src/groups/groups.gateway.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { SupabaseService } from '../auth/supabase.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { GroupsService } from './groups.service';
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/ws/course-groups',
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class GroupsGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private authService: AuthService,
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService,
|
||||
private groupsService: GroupsService
|
||||
) {}
|
||||
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
try {
|
||||
const user = await this.resolveUser(client);
|
||||
if (!user) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
client.data.user = user;
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('groups:join')
|
||||
async joinGroup(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { groupId: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.groupId) return { ok: false };
|
||||
|
||||
const canJoin = await this.groupsService.isMember(body.groupId, user.id);
|
||||
if (!canJoin) {
|
||||
await this.groupsService.joinByInvite(body.groupId, user.id);
|
||||
}
|
||||
await client.join(this.room(body.groupId));
|
||||
const messages = await this.groupsService.getGroupMessages(body.groupId, user.id);
|
||||
return { ok: true, messages };
|
||||
}
|
||||
|
||||
@SubscribeMessage('groups:send')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { groupId: string; content: string; lessonId?: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.groupId || !body?.content?.trim()) return { ok: false };
|
||||
|
||||
const message = await this.groupsService.sendMessage(body.groupId, user.id, body.content.trim(), body.lessonId);
|
||||
this.server.to(this.room(body.groupId)).emit('groups:new-message', message);
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
private room(groupId: string): string {
|
||||
return `group:${groupId}`;
|
||||
}
|
||||
|
||||
private async resolveUser(client: Socket) {
|
||||
const authToken =
|
||||
(client.handshake.auth?.token as string | undefined) ||
|
||||
((client.handshake.headers.authorization as string | undefined)?.replace(/^Bearer\s+/i, '')) ||
|
||||
undefined;
|
||||
if (!authToken) return null;
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; email: string; supabaseId: string }>(authToken);
|
||||
const user = await this.authService.validateJwtPayload(payload);
|
||||
if (user) return user;
|
||||
} catch {
|
||||
// Fallback to Supabase access token
|
||||
}
|
||||
|
||||
const supabaseUser = await this.supabaseService.verifyToken(authToken);
|
||||
if (!supabaseUser) return null;
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
if (!user) {
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name:
|
||||
supabaseUser.user_metadata?.full_name ||
|
||||
supabaseUser.user_metadata?.name ||
|
||||
null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
14
apps/api/src/groups/groups.module.ts
Normal file
14
apps/api/src/groups/groups.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GroupsController } from './groups.controller';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { GroupsGateway } from './groups.gateway';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, AccessModule],
|
||||
controllers: [GroupsController],
|
||||
providers: [GroupsService, GroupsGateway],
|
||||
exports: [GroupsService],
|
||||
})
|
||||
export class GroupsModule {}
|
||||
188
apps/api/src/groups/groups.service.ts
Normal file
188
apps/api/src/groups/groups.service.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async createGroup(courseId: string, userId: string, name: string, description?: string): Promise<any> {
|
||||
await this.access.assertCourseOwner(courseId, userId);
|
||||
|
||||
return this.prisma.courseGroup.create({
|
||||
data: { courseId, name, description },
|
||||
});
|
||||
}
|
||||
|
||||
async ensureDefaultGroup(courseId: string): Promise<any> {
|
||||
const existing = await this.prisma.courseGroup.findFirst({
|
||||
where: { courseId, isDefault: true },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.prisma.courseGroup.create({
|
||||
data: {
|
||||
courseId,
|
||||
name: 'Основная группа',
|
||||
description: 'Обсуждение курса и вопросы преподавателю',
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getDefaultGroup(courseId: string, userId: string): Promise<any> {
|
||||
const group = await this.ensureDefaultGroup(courseId);
|
||||
await this.assertCanReadGroup(group.id, userId);
|
||||
|
||||
const [messages, members] = await Promise.all([
|
||||
this.getGroupMessages(group.id, userId),
|
||||
this.getGroupMembers(group.id, userId),
|
||||
]);
|
||||
return { group, messages, members };
|
||||
}
|
||||
|
||||
async addMember(groupId: string, requesterId: string, targetUserId: string, role = 'student'): Promise<any> {
|
||||
const group = await this.prisma.courseGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { course: { select: { authorId: true } } },
|
||||
});
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
if (group.course.authorId !== requesterId) {
|
||||
throw new ForbiddenException('Only course author can add members manually');
|
||||
}
|
||||
|
||||
return this.prisma.groupMember.upsert({
|
||||
where: { groupId_userId: { groupId, userId: targetUserId } },
|
||||
create: { groupId, userId: targetUserId, role },
|
||||
update: { role },
|
||||
});
|
||||
}
|
||||
|
||||
async getGroupMembers(groupId: string, userId: string): Promise<any> {
|
||||
await this.assertCanReadGroup(groupId, userId);
|
||||
return this.prisma.groupMember.findMany({
|
||||
where: { groupId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true, avatarUrl: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getGroupMessages(groupId: string, userId: string, lessonId?: string): Promise<any> {
|
||||
await this.assertCanReadGroup(groupId, userId);
|
||||
|
||||
return this.prisma.groupMessage.findMany({
|
||||
where: {
|
||||
groupId,
|
||||
...(lessonId ? { lessonId } : {}),
|
||||
},
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(groupId: string, userId: string, content: string, lessonId?: string): Promise<any> {
|
||||
await this.assertCanReadGroup(groupId, userId);
|
||||
|
||||
return this.prisma.groupMessage.create({
|
||||
data: { groupId, userId, content, lessonId: lessonId || null },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async createInviteLink(groupId: string, userId: string): Promise<any> {
|
||||
const group = await this.prisma.courseGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { course: { select: { authorId: true } } },
|
||||
});
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
if (group.course.authorId !== userId) throw new ForbiddenException('Only course author can create invite links');
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3080';
|
||||
return {
|
||||
groupId,
|
||||
inviteUrl: `${appUrl}/dashboard/groups/${groupId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async joinByInvite(groupId: string, userId: string): Promise<any> {
|
||||
const group = await this.prisma.courseGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: {
|
||||
course: {
|
||||
select: { id: true, authorId: true, price: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
|
||||
const hasEnrollment = await this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId: group.course.id } },
|
||||
});
|
||||
const hasPurchase = await this.prisma.purchase.findUnique({
|
||||
where: { userId_courseId: { userId, courseId: group.course.id } },
|
||||
});
|
||||
|
||||
if (!hasEnrollment && !hasPurchase && group.course.authorId !== userId) {
|
||||
throw new ForbiddenException('Enroll or purchase course first');
|
||||
}
|
||||
|
||||
return this.prisma.groupMember.upsert({
|
||||
where: { groupId_userId: { groupId, userId } },
|
||||
create: { groupId, userId, role: group.course.authorId === userId ? 'teacher' : 'student' },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async isMember(groupId: string, userId: string): Promise<boolean> {
|
||||
const member = await this.prisma.groupMember.findUnique({
|
||||
where: { groupId_userId: { groupId, userId } },
|
||||
select: { id: true },
|
||||
});
|
||||
return Boolean(member);
|
||||
}
|
||||
|
||||
private async assertCanReadGroup(groupId: string, userId: string): Promise<void> {
|
||||
const group = await this.prisma.courseGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: {
|
||||
course: {
|
||||
select: { id: true, authorId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException('Group not found');
|
||||
}
|
||||
|
||||
if (group.course.authorId === userId) return;
|
||||
|
||||
const member = await this.prisma.groupMember.findUnique({
|
||||
where: { groupId_userId: { groupId, userId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (member) return;
|
||||
|
||||
const enrollment = await this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId: group.course.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (enrollment) {
|
||||
await this.prisma.groupMember.upsert({
|
||||
where: { groupId_userId: { groupId, userId } },
|
||||
create: { groupId, userId, role: 'student' },
|
||||
update: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('No access to this group');
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './common/all-exceptions.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
66
apps/api/src/moderation/moderation.controller.ts
Normal file
66
apps/api/src/moderation/moderation.controller.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Controller, Get, Post, Param, Body, Query, Delete, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ModerationService } from './moderation.service';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
|
||||
@ApiTags('moderation')
|
||||
@Controller('moderation')
|
||||
@ApiBearerAuth()
|
||||
export class ModerationController {
|
||||
constructor(private moderationService: ModerationService) {}
|
||||
|
||||
@Get('pending')
|
||||
async getPendingCourses(@CurrentUser() user: User): Promise<any> {
|
||||
return this.moderationService.getPendingCourses(user.id);
|
||||
}
|
||||
|
||||
@Get('courses')
|
||||
async getCourses(
|
||||
@CurrentUser() user: User,
|
||||
@Query('status') status?: string,
|
||||
@Query('search') search?: string,
|
||||
): Promise<any> {
|
||||
return this.moderationService.getCourses(user.id, { status, search });
|
||||
}
|
||||
|
||||
@Post(':courseId/approve')
|
||||
async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.moderationService.approveCourse(user.id, courseId, note);
|
||||
}
|
||||
|
||||
@Post(':courseId/reject')
|
||||
async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.moderationService.rejectCourse(user.id, courseId, reason);
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/hide')
|
||||
async hideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.moderationService.hideReview(user.id, reviewId);
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/unhide')
|
||||
async unhideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.moderationService.unhideReview(user.id, reviewId);
|
||||
}
|
||||
|
||||
@Delete(':courseId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<void> {
|
||||
await this.moderationService.deleteCourse(user.id, courseId);
|
||||
}
|
||||
|
||||
@Get(':courseId/preview')
|
||||
async previewCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.moderationService.previewCourse(user.id, courseId);
|
||||
}
|
||||
|
||||
@Post(':courseId/quiz-preview')
|
||||
async previewQuiz(
|
||||
@Param('courseId') courseId: string,
|
||||
@Body() body: { lessonId: string; answers?: number[] },
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.moderationService.previewQuiz(user.id, courseId, body);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/moderation/moderation.module.ts
Normal file
12
apps/api/src/moderation/moderation.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModerationController } from './moderation.controller';
|
||||
import { ModerationService } from './moderation.service';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccessModule],
|
||||
controllers: [ModerationController],
|
||||
providers: [ModerationService],
|
||||
exports: [ModerationService],
|
||||
})
|
||||
export class ModerationModule {}
|
||||
278
apps/api/src/moderation/moderation.service.ts
Normal file
278
apps/api/src/moderation/moderation.service.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { CourseStatus } from '@coursecraft/database';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
import { COURSE_PENDING_STATUSES, isPendingModeration } from '../common/course-status';
|
||||
|
||||
@Injectable()
|
||||
export class ModerationService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async getPendingCourses(userId: string): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
return this.prisma.course.findMany({
|
||||
where: { status: { in: COURSE_PENDING_STATUSES } },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { chapters: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getCourses(
|
||||
userId: string,
|
||||
options?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
}
|
||||
): Promise<any[]> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const allowedStatuses = Object.values(CourseStatus);
|
||||
const where: any = {};
|
||||
|
||||
if (options?.status && allowedStatuses.includes(options.status as CourseStatus)) {
|
||||
const inputStatus = options.status as CourseStatus;
|
||||
if (inputStatus === CourseStatus.PENDING_MODERATION) {
|
||||
where.status = { in: COURSE_PENDING_STATUSES };
|
||||
} else {
|
||||
where.status = inputStatus;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.search?.trim()) {
|
||||
const term = options.search.trim();
|
||||
where.OR = [
|
||||
{ title: { contains: term, mode: 'insensitive' } },
|
||||
{ description: { contains: term, mode: 'insensitive' } },
|
||||
{ author: { name: { contains: term, mode: 'insensitive' } } },
|
||||
{ author: { email: { contains: term, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
return this.prisma.course.findMany({
|
||||
where,
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true } },
|
||||
_count: {
|
||||
select: {
|
||||
chapters: true,
|
||||
enrollments: true,
|
||||
reviews: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (!isPendingModeration(course.status)) {
|
||||
throw new ForbiddenException('Only courses pending moderation can be approved');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
status: CourseStatus.PUBLISHED,
|
||||
isPublished: true,
|
||||
publishedAt: now,
|
||||
moderationNote: note,
|
||||
moderatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.createMany({
|
||||
data: [
|
||||
{
|
||||
courseId,
|
||||
fromStatus: course.status,
|
||||
toStatus: CourseStatus.APPROVED,
|
||||
note: note || 'Approved by moderation',
|
||||
changedById: userId,
|
||||
},
|
||||
{
|
||||
courseId,
|
||||
fromStatus: CourseStatus.APPROVED,
|
||||
toStatus: CourseStatus.PUBLISHED,
|
||||
note: 'Auto publish after approve',
|
||||
changedById: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async rejectCourse(userId: string, courseId: string, reason: string): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (!isPendingModeration(course.status)) {
|
||||
throw new ForbiddenException('Only courses pending moderation can be rejected');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
status: CourseStatus.REJECTED,
|
||||
moderationNote: reason,
|
||||
moderatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId,
|
||||
fromStatus: course.status,
|
||||
toStatus: CourseStatus.REJECTED,
|
||||
note: reason,
|
||||
changedById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async previewCourse(userId: string, courseId: string): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true } },
|
||||
chapters: {
|
||||
include: {
|
||||
lessons: {
|
||||
include: {
|
||||
quiz: true,
|
||||
homework: true,
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
|
||||
if (!isPendingModeration(course.status) && course.status !== CourseStatus.REJECTED) {
|
||||
throw new ForbiddenException('Preview is available only for moderation flow courses');
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
async previewQuiz(
|
||||
userId: string,
|
||||
courseId: string,
|
||||
dto: { lessonId: string; answers?: number[] }
|
||||
): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const lesson = await this.prisma.lesson.findFirst({
|
||||
where: { id: dto.lessonId, chapter: { courseId } },
|
||||
include: { quiz: true },
|
||||
});
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('Lesson not found in this course');
|
||||
}
|
||||
if (!lesson.quiz) {
|
||||
return { questions: [], score: null, passed: null, preview: true };
|
||||
}
|
||||
|
||||
const questions = Array.isArray(lesson.quiz.questions) ? (lesson.quiz.questions as any[]) : [];
|
||||
if (!dto.answers) {
|
||||
return { questions, score: null, passed: null, preview: true };
|
||||
}
|
||||
|
||||
const correct = questions.reduce((acc, question, idx) => {
|
||||
const expected = Number(question.correctAnswer);
|
||||
const actual = Number(dto.answers?.[idx]);
|
||||
return acc + (expected === actual ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const total = questions.length || 1;
|
||||
const score = Math.round((correct / total) * 100);
|
||||
|
||||
return {
|
||||
questions,
|
||||
score,
|
||||
passed: score >= 70,
|
||||
totalQuestions: questions.length,
|
||||
correctAnswers: correct,
|
||||
preview: true,
|
||||
};
|
||||
}
|
||||
|
||||
async hideReview(userId: string, reviewId: string): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
const review = await this.prisma.review.update({
|
||||
where: { id: reviewId },
|
||||
data: { isApproved: false },
|
||||
});
|
||||
await this.recalculateAverageRating(review.courseId);
|
||||
return review;
|
||||
}
|
||||
|
||||
async unhideReview(userId: string, reviewId: string): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
const review = await this.prisma.review.update({
|
||||
where: { id: reviewId },
|
||||
data: { isApproved: true },
|
||||
});
|
||||
await this.recalculateAverageRating(review.courseId);
|
||||
return review;
|
||||
}
|
||||
|
||||
async deleteCourse(userId: string, courseId: string): Promise<void> {
|
||||
await this.access.assertStaff(userId);
|
||||
const existing = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
|
||||
await this.prisma.course.delete({ where: { id: courseId } });
|
||||
}
|
||||
|
||||
private async recalculateAverageRating(courseId: string): Promise<void> {
|
||||
const result = await this.prisma.review.aggregate({
|
||||
where: { courseId, isApproved: true },
|
||||
_avg: { rating: true },
|
||||
});
|
||||
await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: { averageRating: result._avg.rating ?? null },
|
||||
});
|
||||
}
|
||||
}
|
||||
22
apps/api/src/payments/dev-payments.controller.ts
Normal file
22
apps/api/src/payments/dev-payments.controller.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller('payments')
|
||||
@ApiBearerAuth()
|
||||
export class DevPaymentsController {
|
||||
constructor(private paymentsService: PaymentsService) {}
|
||||
|
||||
@Post('dev/yoomoney/complete')
|
||||
@ApiOperation({ summary: 'Complete DEV YooMoney payment (mock flow)' })
|
||||
async completeDevYoomoneyPayment(
|
||||
@CurrentUser() user: User,
|
||||
@Body('courseId') courseId: string,
|
||||
) {
|
||||
return this.paymentsService.completeDevYoomoneyPayment(user.id, courseId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { StripeService } from './stripe.service';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { DevPaymentsController } from './dev-payments.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PaymentsController, WebhooksController],
|
||||
controllers: [PaymentsController, WebhooksController, DevPaymentsController],
|
||||
providers: [PaymentsService, StripeService],
|
||||
exports: [PaymentsService, StripeService],
|
||||
})
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { StripeService } from './stripe.service';
|
||||
import { SubscriptionTier } from '@coursecraft/database';
|
||||
import { PaymentMode, PaymentProvider, SubscriptionTier } from '@coursecraft/database';
|
||||
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
|
||||
|
||||
@Injectable()
|
||||
@ -28,34 +28,7 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { subscription: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Get or create Stripe customer
|
||||
let stripeCustomerId = user.subscription?.stripeCustomerId;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await this.stripeService.createCustomer(user.email, user.name || undefined);
|
||||
stripeCustomerId = customer.id;
|
||||
|
||||
await this.prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
tier: SubscriptionTier.FREE,
|
||||
stripeCustomerId: customer.id,
|
||||
},
|
||||
update: {
|
||||
stripeCustomerId: customer.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
|
||||
|
||||
// Get price ID for tier
|
||||
const priceId =
|
||||
@ -83,6 +56,94 @@ export class PaymentsService {
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
async createCourseCheckoutSession(userId: string, courseId: string) {
|
||||
const [course, existingPurchase] = await Promise.all([
|
||||
this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
isPublished: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.purchase.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (!course.isPublished) {
|
||||
throw new ForbiddenException('Course is not available for purchase');
|
||||
}
|
||||
if (!course.price) {
|
||||
throw new Error('Course is free, checkout is not required');
|
||||
}
|
||||
if (existingPurchase?.status === 'completed') {
|
||||
throw new Error('Course is already purchased');
|
||||
}
|
||||
|
||||
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
|
||||
const paymentMode = this.getPaymentMode();
|
||||
|
||||
if (paymentMode === PaymentMode.DEV) {
|
||||
await this.handleCoursePurchaseCompleted({
|
||||
userId,
|
||||
courseId,
|
||||
provider: PaymentProvider.YOOMONEY,
|
||||
mode: PaymentMode.DEV,
|
||||
eventCode: 'DEV_PAYMENT_SUCCESS',
|
||||
});
|
||||
console.log('DEV_PAYMENT_SUCCESS', { userId, courseId, provider: 'YOOMONEY' });
|
||||
return {
|
||||
url: `${appUrl}/courses/${courseId}?purchase=success&devPayment=1`,
|
||||
mode: 'DEV',
|
||||
provider: 'YOOMONEY',
|
||||
};
|
||||
}
|
||||
|
||||
const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
|
||||
const unitAmount = Math.round(Number(course.price) * 100);
|
||||
|
||||
const session = await this.stripeService.createOneTimeCheckoutSession({
|
||||
customerId: stripeCustomerId,
|
||||
currency: course.currency || 'USD',
|
||||
unitAmount,
|
||||
productName: course.title,
|
||||
productDescription: course.description || undefined,
|
||||
successUrl: `${appUrl}/dashboard/catalog/${courseId}?purchase=success`,
|
||||
cancelUrl: `${appUrl}/dashboard/catalog/${courseId}?purchase=canceled`,
|
||||
metadata: {
|
||||
type: 'course_purchase',
|
||||
userId,
|
||||
courseId,
|
||||
},
|
||||
});
|
||||
|
||||
return { url: session.url, mode: 'PROD', provider: 'STRIPE' };
|
||||
}
|
||||
|
||||
async completeDevYoomoneyPayment(userId: string, courseId: string) {
|
||||
await this.handleCoursePurchaseCompleted({
|
||||
userId,
|
||||
courseId,
|
||||
provider: PaymentProvider.YOOMONEY,
|
||||
mode: PaymentMode.DEV,
|
||||
eventCode: 'DEV_PAYMENT_SUCCESS',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
eventCode: 'DEV_PAYMENT_SUCCESS',
|
||||
provider: 'YOOMONEY',
|
||||
mode: 'DEV',
|
||||
};
|
||||
}
|
||||
|
||||
async createPortalSession(userId: string) {
|
||||
const subscription = await this.prisma.subscription.findUnique({
|
||||
where: { userId },
|
||||
@ -107,8 +168,8 @@ export class PaymentsService {
|
||||
case 'checkout.session.completed':
|
||||
await this.handleCheckoutCompleted(event.data.object as {
|
||||
customer: string;
|
||||
subscription: string;
|
||||
metadata: { userId: string; tier: string };
|
||||
subscription?: string;
|
||||
metadata: { userId?: string; tier?: string; type?: string; courseId?: string };
|
||||
});
|
||||
break;
|
||||
|
||||
@ -133,10 +194,29 @@ export class PaymentsService {
|
||||
|
||||
private async handleCheckoutCompleted(session: {
|
||||
customer: string;
|
||||
subscription: string;
|
||||
metadata: { userId: string; tier: string };
|
||||
subscription?: string;
|
||||
metadata: { userId?: string; tier?: string; type?: string; courseId?: string };
|
||||
}) {
|
||||
const { customer, subscription: subscriptionId, metadata } = session;
|
||||
if (!metadata?.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.type === 'course_purchase') {
|
||||
await this.handleCoursePurchaseCompleted({
|
||||
userId: metadata.userId,
|
||||
courseId: metadata.courseId || '',
|
||||
provider: PaymentProvider.STRIPE,
|
||||
mode: PaymentMode.PROD,
|
||||
eventCode: 'STRIPE_PAYMENT_SUCCESS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscriptionId || !metadata.tier) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tier = metadata.tier as SubscriptionTier;
|
||||
|
||||
const stripeSubscription = await this.stripeService.getSubscription(subscriptionId);
|
||||
@ -161,6 +241,122 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCoursePurchaseCompleted(params: {
|
||||
userId: string;
|
||||
courseId: string;
|
||||
provider?: PaymentProvider;
|
||||
mode?: PaymentMode;
|
||||
eventCode?: string;
|
||||
}) {
|
||||
const { userId, courseId } = params;
|
||||
if (!courseId) return;
|
||||
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, price: true, currency: true, authorId: true },
|
||||
});
|
||||
if (!course || !course.price) return;
|
||||
|
||||
await this.prisma.purchase.upsert({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
create: {
|
||||
userId,
|
||||
courseId,
|
||||
amount: course.price,
|
||||
currency: course.currency,
|
||||
status: 'completed',
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
eventCode: params.eventCode || null,
|
||||
metadata: {
|
||||
eventCode: params.eventCode || null,
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
status: 'completed',
|
||||
amount: course.price,
|
||||
currency: course.currency,
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
eventCode: params.eventCode || null,
|
||||
metadata: {
|
||||
eventCode: params.eventCode || null,
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.enrollment.upsert({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
create: { userId, courseId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const defaultGroup = await this.ensureDefaultCourseGroup(courseId);
|
||||
await this.prisma.groupMember.upsert({
|
||||
where: { groupId_userId: { groupId: defaultGroup.id, userId } },
|
||||
create: { groupId: defaultGroup.id, userId, role: 'student' },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
private getPaymentMode(): PaymentMode {
|
||||
const raw = (this.configService.get<string>('PAYMENT_MODE') || 'PROD').toUpperCase();
|
||||
return raw === PaymentMode.DEV ? PaymentMode.DEV : PaymentMode.PROD;
|
||||
}
|
||||
|
||||
private async getOrCreateStripeCustomer(userId: string): Promise<{ stripeCustomerId: string; email: string; name: string | null }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { subscription: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
let stripeCustomerId = user.subscription?.stripeCustomerId;
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await this.stripeService.createCustomer(user.email, user.name || undefined);
|
||||
stripeCustomerId = customer.id;
|
||||
await this.prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
tier: SubscriptionTier.FREE,
|
||||
stripeCustomerId: customer.id,
|
||||
},
|
||||
update: {
|
||||
stripeCustomerId: customer.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
stripeCustomerId,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureDefaultCourseGroup(courseId: string) {
|
||||
const existing = await this.prisma.courseGroup.findFirst({
|
||||
where: { courseId, isDefault: true },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.prisma.courseGroup.create({
|
||||
data: {
|
||||
courseId,
|
||||
name: 'Основная группа',
|
||||
description: 'Обсуждение курса и вопросы преподавателю',
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSubscriptionUpdated(subscription: {
|
||||
id: string;
|
||||
customer: string;
|
||||
|
||||
@ -45,6 +45,38 @@ export class StripeService {
|
||||
});
|
||||
}
|
||||
|
||||
async createOneTimeCheckoutSession(params: {
|
||||
customerId: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
metadata?: Record<string, string>;
|
||||
currency: string;
|
||||
unitAmount: number;
|
||||
productName: string;
|
||||
productDescription?: string;
|
||||
}): Promise<Stripe.Checkout.Session> {
|
||||
return this.stripe.checkout.sessions.create({
|
||||
customer: params.customerId,
|
||||
mode: 'payment',
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: params.currency.toLowerCase(),
|
||||
unit_amount: params.unitAmount,
|
||||
product_data: {
|
||||
name: params.productName,
|
||||
description: params.productDescription,
|
||||
},
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: params.successUrl,
|
||||
cancel_url: params.cancelUrl,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async createPortalSession(customerId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session> {
|
||||
return this.stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
|
||||
55
apps/api/src/support/support.controller.ts
Normal file
55
apps/api/src/support/support.controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { SupportService } from './support.service';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
|
||||
@ApiTags('support')
|
||||
@Controller('support')
|
||||
@ApiBearerAuth()
|
||||
export class SupportController {
|
||||
constructor(private supportService: SupportService) {}
|
||||
|
||||
@Post('tickets')
|
||||
async createTicket(
|
||||
@Body() body: { title: string; initialMessage?: string; priority?: string },
|
||||
@CurrentUser() user: User
|
||||
): Promise<any> {
|
||||
return this.supportService.createTicket(user.id, body.title, body.initialMessage, body.priority);
|
||||
}
|
||||
|
||||
@Get('tickets')
|
||||
async getMyTickets(@CurrentUser() user: User): Promise<any> {
|
||||
return this.supportService.getUserTickets(user.id);
|
||||
}
|
||||
|
||||
@Get('tickets/:id/messages')
|
||||
async getMessages(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.supportService.getTicketMessages(id, user.id);
|
||||
}
|
||||
|
||||
@Post('tickets/:id/messages')
|
||||
async sendMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.supportService.sendMessage(id, user.id, content);
|
||||
}
|
||||
|
||||
@Get('admin/tickets')
|
||||
async getAllTickets(@CurrentUser() user: User): Promise<any> {
|
||||
return this.supportService.getAllTickets(user.id);
|
||||
}
|
||||
|
||||
@Get('admin/tickets/:id/messages')
|
||||
async getMessagesForStaff(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.supportService.getTicketMessagesForStaff(id, user.id);
|
||||
}
|
||||
|
||||
@Post('admin/tickets/:id/messages')
|
||||
async sendStaffMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.supportService.sendStaffMessage(id, user.id, content);
|
||||
}
|
||||
|
||||
@Post('admin/tickets/:id/status')
|
||||
async updateStatus(@Param('id') id: string, @Body('status') status: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.supportService.updateTicketStatus(id, user.id, status);
|
||||
}
|
||||
}
|
||||
125
apps/api/src/support/support.gateway.ts
Normal file
125
apps/api/src/support/support.gateway.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { SupabaseService } from '../auth/supabase.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/ws/support',
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class SupportGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private authService: AuthService,
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService,
|
||||
private supportService: SupportService
|
||||
) {}
|
||||
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
try {
|
||||
const user = await this.resolveUser(client);
|
||||
if (!user) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
client.data.user = user;
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('support:join')
|
||||
async joinTicket(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { ticketId: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.ticketId) return { ok: false };
|
||||
|
||||
const canAccess = await this.supportService.canAccessTicket(body.ticketId, user.id);
|
||||
if (!canAccess) return { ok: false };
|
||||
|
||||
await client.join(this.room(body.ticketId));
|
||||
const messages =
|
||||
(await this.supportService.getTicketMessagesForStaff(body.ticketId, user.id).catch(async () =>
|
||||
this.supportService.getTicketMessages(body.ticketId, user.id)
|
||||
)) || [];
|
||||
return { ok: true, messages };
|
||||
}
|
||||
|
||||
@SubscribeMessage('support:send')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { ticketId: string; content: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.ticketId || !body?.content?.trim()) return { ok: false };
|
||||
|
||||
const canAccess = await this.supportService.canAccessTicket(body.ticketId, user.id);
|
||||
if (!canAccess) return { ok: false };
|
||||
|
||||
const message =
|
||||
(await this.supportService.sendStaffMessage(body.ticketId, user.id, body.content.trim()).catch(async () =>
|
||||
this.supportService.sendMessage(body.ticketId, user.id, body.content.trim())
|
||||
)) || null;
|
||||
|
||||
if (message) {
|
||||
this.server.to(this.room(body.ticketId)).emit('support:new-message', message);
|
||||
return { ok: true, message };
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
private room(ticketId: string): string {
|
||||
return `ticket:${ticketId}`;
|
||||
}
|
||||
|
||||
private async resolveUser(client: Socket) {
|
||||
const authToken =
|
||||
(client.handshake.auth?.token as string | undefined) ||
|
||||
((client.handshake.headers.authorization as string | undefined)?.replace(/^Bearer\s+/i, '')) ||
|
||||
undefined;
|
||||
if (!authToken) return null;
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; email: string; supabaseId: string }>(authToken);
|
||||
const user = await this.authService.validateJwtPayload(payload);
|
||||
if (user) return user;
|
||||
} catch {
|
||||
// Fallback to Supabase access token
|
||||
}
|
||||
|
||||
const supabaseUser = await this.supabaseService.verifyToken(authToken);
|
||||
if (!supabaseUser) return null;
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
if (!user) {
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name:
|
||||
supabaseUser.user_metadata?.full_name ||
|
||||
supabaseUser.user_metadata?.name ||
|
||||
null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
14
apps/api/src/support/support.module.ts
Normal file
14
apps/api/src/support/support.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, AccessModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportGateway],
|
||||
exports: [SupportService],
|
||||
})
|
||||
export class SupportModule {}
|
||||
159
apps/api/src/support/support.service.ts
Normal file
159
apps/api/src/support/support.service.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { UserRole } from '@coursecraft/database';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async createTicket(
|
||||
userId: string,
|
||||
title: string,
|
||||
initialMessage?: string,
|
||||
priority: string = 'normal'
|
||||
): Promise<any> {
|
||||
const ticket = await this.prisma.supportTicket.create({
|
||||
data: { userId, title, priority, status: 'open' },
|
||||
});
|
||||
|
||||
if (initialMessage?.trim()) {
|
||||
await this.prisma.ticketMessage.create({
|
||||
data: {
|
||||
ticketId: ticket.id,
|
||||
userId,
|
||||
content: initialMessage.trim(),
|
||||
isStaff: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.supportTicket.findUnique({
|
||||
where: { id: ticket.id },
|
||||
include: {
|
||||
messages: {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserTickets(userId: string): Promise<any> {
|
||||
return this.prisma.supportTicket.findMany({
|
||||
where: { userId },
|
||||
include: { messages: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getTicketMessages(ticketId: string, userId: string): Promise<any> {
|
||||
const ticket = await this.prisma.supportTicket.findFirst({ where: { id: ticketId, userId } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
return this.prisma.ticketMessage.findMany({
|
||||
where: { ticketId },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(ticketId: string, userId: string, content: string): Promise<any> {
|
||||
const ticket = await this.prisma.supportTicket.findFirst({
|
||||
where: { id: ticketId, userId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
const message = await this.prisma.ticketMessage.create({
|
||||
data: { ticketId, userId, content, isStaff: false },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
});
|
||||
await this.prisma.supportTicket.update({
|
||||
where: { id: ticketId },
|
||||
data: {
|
||||
status: ticket.status === 'resolved' ? 'in_progress' : ticket.status,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return message;
|
||||
}
|
||||
|
||||
async getAllTickets(staffUserId: string): Promise<any> {
|
||||
await this.assertStaff(staffUserId);
|
||||
return this.prisma.supportTicket.findMany({
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, avatarUrl: true } },
|
||||
messages: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async getTicketMessagesForStaff(ticketId: string, staffUserId: string): Promise<any> {
|
||||
await this.assertStaff(staffUserId);
|
||||
const ticket = await this.prisma.supportTicket.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
return this.prisma.ticketMessage.findMany({
|
||||
where: { ticketId },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async sendStaffMessage(ticketId: string, staffUserId: string, content: string): Promise<any> {
|
||||
await this.assertStaff(staffUserId);
|
||||
const ticket = await this.prisma.supportTicket.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
const message = await this.prisma.ticketMessage.create({
|
||||
data: { ticketId, userId: staffUserId, content, isStaff: true },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
});
|
||||
await this.prisma.supportTicket.update({
|
||||
where: { id: ticketId },
|
||||
data: { status: 'in_progress', updatedAt: new Date() },
|
||||
});
|
||||
return message;
|
||||
}
|
||||
|
||||
async updateTicketStatus(ticketId: string, staffUserId: string, status: string): Promise<any> {
|
||||
await this.assertStaff(staffUserId);
|
||||
const allowed = ['open', 'in_progress', 'resolved', 'closed'];
|
||||
if (!allowed.includes(status)) {
|
||||
throw new ForbiddenException('Invalid status');
|
||||
}
|
||||
return this.prisma.supportTicket.update({
|
||||
where: { id: ticketId },
|
||||
data: { status, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async canAccessTicket(ticketId: string, userId: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!user) return false;
|
||||
if (user.role === UserRole.ADMIN || user.role === UserRole.MODERATOR) return true;
|
||||
const ticket = await this.prisma.supportTicket.findFirst({
|
||||
where: { id: ticketId, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
return Boolean(ticket);
|
||||
}
|
||||
|
||||
private async assertStaff(userId: string): Promise<void> {
|
||||
await this.access.assertStaff(userId);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { User, UserSettings, SubscriptionTier } from '@coursecraft/database';
|
||||
import { User, UserRole, UserSettings, SubscriptionTier } from '@coursecraft/database';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UpdateSettingsDto } from './dto/update-settings.dto';
|
||||
@ -147,4 +147,42 @@ export class UsersService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listUsers(options?: { search?: string; role?: UserRole; limit?: number }) {
|
||||
const limit = Math.min(200, Math.max(1, options?.limit || 100));
|
||||
const where: any = {};
|
||||
if (options?.role) {
|
||||
where.role = options.role;
|
||||
}
|
||||
if (options?.search?.trim()) {
|
||||
const term = options.search.trim();
|
||||
where.OR = [
|
||||
{ email: { contains: term, mode: 'insensitive' } },
|
||||
{ name: { contains: term, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
return this.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
subscriptionTier: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async updateRole(userId: string, role: UserRole): Promise<User> {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
include: { settings: true, subscription: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4",
|
||||
|
||||
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" />
|
||||
Сохранить в PDF / Печать
|
||||
</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}</>;
|
||||
}
|
||||
5
apps/web/src/app/(dashboard)/dashboard/admin/page.tsx
Normal file
5
apps/web/src/app/(dashboard)/dashboard/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DashboardAdminRedirectPage() {
|
||||
redirect('/admin');
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DashboardAdminSupportRedirectPage() {
|
||||
redirect('/admin/support');
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DashboardCatalogCourseRedirectPage({ params }: { params: { id: string } }) {
|
||||
redirect(`/courses/${params.id}`);
|
||||
}
|
||||
5
apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx
Normal file
5
apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DashboardCatalogRedirectPage() {
|
||||
redirect('/courses');
|
||||
}
|
||||
@ -1,10 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil } from 'lucide-react';
|
||||
import {
|
||||
CheckSquare,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
FileArchive,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
ListChecks,
|
||||
Layers3,
|
||||
Lock,
|
||||
Save,
|
||||
Settings2,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CourseEditor } from '@/components/editor/course-editor';
|
||||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
||||
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
|
||||
@ -14,12 +33,38 @@ import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
type Lesson = { id: string; title: string };
|
||||
type Chapter = { id: string; title: string; lessons: Lesson[] };
|
||||
type CourseData = { id: string; title: string; chapters: Chapter[] };
|
||||
type CourseData = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
coverImage?: string | null;
|
||||
price?: number | null;
|
||||
currency?: string;
|
||||
status: 'DRAFT' | 'PENDING_MODERATION' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
|
||||
moderationNote?: string | null;
|
||||
difficulty?: string | null;
|
||||
estimatedHours?: number | null;
|
||||
tags?: string[];
|
||||
chapters: Chapter[];
|
||||
};
|
||||
|
||||
type EditTab = 'general' | 'content' | 'quiz' | 'homework' | 'materials' | 'pricing' | 'settings' | 'access';
|
||||
|
||||
const emptyDoc = { type: 'doc', content: [] };
|
||||
const tabs: { key: EditTab; label: string; icon: any }[] = [
|
||||
{ key: 'general', label: 'Общая информация', icon: FileText },
|
||||
{ key: 'content', label: 'Контент', icon: Layers3 },
|
||||
{ key: 'quiz', label: 'Тест', icon: CheckSquare },
|
||||
{ key: 'homework', label: 'Домашнее задание', icon: ListChecks },
|
||||
{ key: 'materials', label: 'Доп. материалы', icon: FolderOpen },
|
||||
{ key: 'pricing', label: 'Цены', icon: Wallet },
|
||||
{ key: 'settings', label: 'Настройки', icon: Settings2 },
|
||||
{ key: 'access', label: 'Доступ', icon: Lock },
|
||||
];
|
||||
|
||||
export default function CourseEditPage() {
|
||||
const params = useParams();
|
||||
const { toast } = useToast();
|
||||
const { loading: authLoading } = useAuth();
|
||||
const courseId = params?.id as string;
|
||||
|
||||
@ -27,46 +72,81 @@ export default function CourseEditPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EditTab>('general');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null);
|
||||
const [content, setContent] = useState<Record<string, unknown>>(emptyDoc);
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [savingLesson, setSavingLesson] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [savingMeta, setSavingMeta] = useState(false);
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
|
||||
const [courseTitle, setCourseTitle] = useState('');
|
||||
const [courseDescription, setCourseDescription] = useState('');
|
||||
const [courseCover, setCourseCover] = useState('');
|
||||
const [coursePrice, setCoursePrice] = useState('');
|
||||
const [courseCurrency, setCourseCurrency] = useState('USD');
|
||||
const [courseDifficulty, setCourseDifficulty] = useState('');
|
||||
const [courseEstimatedHours, setCourseEstimatedHours] = useState('');
|
||||
const [courseTags, setCourseTags] = useState('');
|
||||
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
|
||||
const [quizGenerated, setQuizGenerated] = useState(false);
|
||||
const [materials, setMaterials] = useState<any[]>([]);
|
||||
const [outlineHints, setOutlineHints] = useState<any[]>([]);
|
||||
const [uploadingSource, setUploadingSource] = useState(false);
|
||||
const [generatingHomework, setGeneratingHomework] = useState(false);
|
||||
const [generatingQuiz, setGeneratingQuiz] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!courseId || authLoading) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getCourse(courseId);
|
||||
if (!cancelled) {
|
||||
if (cancelled) return;
|
||||
|
||||
setCourse(data);
|
||||
setCourseTitle(data.title || '');
|
||||
setCourseDescription(data.description || '');
|
||||
setCourseCover(data.coverImage || '');
|
||||
setCoursePrice(data.price ? String(data.price) : '');
|
||||
setCourseCurrency(data.currency || 'USD');
|
||||
setCourseDifficulty(data.difficulty || '');
|
||||
setCourseEstimatedHours(data.estimatedHours ? String(data.estimatedHours) : '');
|
||||
setCourseTags(Array.isArray(data.tags) ? data.tags.join(', ') : '');
|
||||
|
||||
const firstChapter = data.chapters?.[0];
|
||||
const firstLesson = firstChapter?.lessons?.[0];
|
||||
if (firstChapter && firstLesson) {
|
||||
setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id });
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [courseId, authLoading]);
|
||||
|
||||
// Load lesson content when active lesson changes
|
||||
useEffect(() => {
|
||||
if (!courseId || !activeLesson) {
|
||||
setContent(emptyDoc);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setContentLoading(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const lessonData = await api.getLesson(courseId, activeLesson.lessonId);
|
||||
@ -85,35 +165,156 @@ export default function CourseEditPage() {
|
||||
if (!cancelled) setContentLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [courseId, activeLesson?.lessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'materials') return;
|
||||
loadMaterials();
|
||||
}, [activeTab, courseId]);
|
||||
|
||||
const handleSelectLesson = (lessonId: string) => {
|
||||
if (!course) return;
|
||||
for (const ch of course.chapters) {
|
||||
const lesson = ch.lessons.find((l) => l.id === lessonId);
|
||||
for (const chapter of course.chapters) {
|
||||
const lesson = chapter.lessons.find((item) => item.id === lessonId);
|
||||
if (lesson) {
|
||||
setActiveLesson({ chapterId: ch.id, lessonId: lesson.id });
|
||||
setActiveLesson({ chapterId: chapter.id, lessonId: lesson.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!courseId || !activeLesson || saving) return;
|
||||
setSaving(true);
|
||||
const handleSaveLesson = async () => {
|
||||
if (!courseId || !activeLesson || savingLesson) return;
|
||||
setSavingLesson(true);
|
||||
try {
|
||||
await api.updateLesson(courseId, activeLesson.lessonId, { content });
|
||||
} catch (e: any) {
|
||||
console.error('Save failed:', e);
|
||||
toast({ title: 'Сохранено', description: 'Контент урока сохранён' });
|
||||
} catch {
|
||||
toast({ title: 'Ошибка', description: 'Не удалось сохранить контент', variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingLesson(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMeta = async () => {
|
||||
if (!courseId || savingMeta) return;
|
||||
setSavingMeta(true);
|
||||
try {
|
||||
await api.updateCourse(courseId, {
|
||||
title: courseTitle,
|
||||
description: courseDescription || undefined,
|
||||
coverImage: courseCover || undefined,
|
||||
price: coursePrice ? Number(coursePrice) : 0,
|
||||
currency: courseCurrency,
|
||||
difficulty: courseDifficulty || undefined,
|
||||
estimatedHours: courseEstimatedHours ? Number(courseEstimatedHours) : undefined,
|
||||
tags: courseTags
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
toast({ title: 'Сохранено', description: 'Параметры курса обновлены' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setSavingMeta(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!courseId || publishing) return;
|
||||
setPublishing(true);
|
||||
try {
|
||||
await api.publishCourse(courseId);
|
||||
toast({ title: 'Отправлено', description: 'Курс отправлен на модерацию' });
|
||||
window.location.reload();
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVerify = async () => {
|
||||
if (!courseId || verifying) return;
|
||||
setVerifying(true);
|
||||
try {
|
||||
await api.toggleCourseVerification(courseId);
|
||||
toast({ title: 'Готово', description: 'Статус верификации обновлён' });
|
||||
window.location.reload();
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateQuiz = async () => {
|
||||
if (!courseId || !activeLesson) return;
|
||||
setGeneratingQuiz(true);
|
||||
try {
|
||||
await api.getLessonQuiz(courseId, activeLesson.lessonId);
|
||||
setQuizGenerated(true);
|
||||
toast({ title: 'Готово', description: 'Тест для урока сгенерирован или обновлён' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message || 'Не удалось сгенерировать тест', variant: 'destructive' });
|
||||
} finally {
|
||||
setGeneratingQuiz(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateHomework = async () => {
|
||||
if (!courseId || !activeLesson) return;
|
||||
setGeneratingHomework(true);
|
||||
try {
|
||||
await api.generateLessonHomework(courseId, activeLesson.lessonId, homeworkType);
|
||||
toast({ title: 'Готово', description: 'Домашнее задание обновлено' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message || 'Не удалось сгенерировать ДЗ', variant: 'destructive' });
|
||||
} finally {
|
||||
setGeneratingHomework(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMaterials = async () => {
|
||||
if (!courseId) return;
|
||||
try {
|
||||
const [files, hints] = await Promise.all([
|
||||
api.getCourseSources(courseId).catch(() => []),
|
||||
api.getCourseSourceOutlineHints(courseId).catch(() => ({ hints: [] })),
|
||||
]);
|
||||
setMaterials(files || []);
|
||||
setOutlineHints(hints?.hints || []);
|
||||
} catch {
|
||||
setMaterials([]);
|
||||
setOutlineHints([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSource = async (event: any) => {
|
||||
if (!courseId) return;
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingSource(true);
|
||||
try {
|
||||
await api.uploadCourseSource(courseId, file);
|
||||
toast({ title: 'Файл загружен', description: 'Источник добавлен в анализ курса' });
|
||||
await loadMaterials();
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Ошибка', description: e.message || 'Не удалось загрузить файл', variant: 'destructive' });
|
||||
} finally {
|
||||
setUploadingSource(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<p className="text-muted-foreground">Загрузка курса...</p>
|
||||
</div>
|
||||
);
|
||||
@ -121,88 +322,126 @@ export default function CourseEditPage() {
|
||||
|
||||
if (error || !course) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<p className="text-destructive">{error || 'Курс не найден'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const flatLessons = course.chapters.flatMap((ch) =>
|
||||
ch.lessons.map((l) => ({ ...l, chapterId: ch.id }))
|
||||
);
|
||||
const activeLessonMeta = activeLesson
|
||||
? flatLessons.find((l) => l.id === activeLesson.lessonId)
|
||||
: null;
|
||||
const flatLessons = course.chapters.flatMap((chapter) => chapter.lessons.map((lesson) => ({ ...lesson, chapterId: chapter.id })));
|
||||
const activeLessonMeta = activeLesson ? flatLessons.find((lesson) => lesson.id === activeLesson.lessonId) : null;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[calc(100vh-4rem)] -m-6">
|
||||
<div
|
||||
className={cn(
|
||||
'border-r bg-muted/30 transition-all duration-300',
|
||||
sidebarOpen ? 'w-72' : 'w-0'
|
||||
)}
|
||||
>
|
||||
{sidebarOpen && (
|
||||
<LessonSidebar
|
||||
course={course}
|
||||
activeLesson={activeLesson?.lessonId ?? ''}
|
||||
onSelectLesson={handleSelectLesson}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">Редактор курса</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{course.title} • Статус: {course.status}
|
||||
{course.moderationNote ? ` • Заметка модерации: ${course.moderationNote}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-colors"
|
||||
style={{ left: sidebarOpen ? '288px' : '0px' }}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
|
||||
<h2 className="font-medium truncate">
|
||||
{activeLessonMeta?.title ?? 'Выберите урок'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{readOnly ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/dashboard/courses/${courseId}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Просмотр курса
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setReadOnly(false)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Редактировать
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
variant={activeTab === tab.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<tab.icon className="mr-2 h-4 w-4" />
|
||||
{tab.label}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setReadOnly(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Режим просмотра
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
AI помощник
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !activeLesson}>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Общая информация</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<input
|
||||
value={courseTitle}
|
||||
onChange={(e) => setCourseTitle(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Название курса"
|
||||
/>
|
||||
<input
|
||||
value={courseCover}
|
||||
onChange={(e) => setCourseCover(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="URL обложки"
|
||||
/>
|
||||
<textarea
|
||||
value={courseDescription}
|
||||
onChange={(e) => setCourseDescription(e.target.value)}
|
||||
className="w-full rounded-md border bg-background p-3 text-sm"
|
||||
rows={4}
|
||||
placeholder="Описание курса"
|
||||
/>
|
||||
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
{savingMeta ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex h-[70vh]">
|
||||
<div
|
||||
className={cn(
|
||||
'border-r bg-muted/30 transition-all duration-300',
|
||||
sidebarOpen ? 'w-72' : 'w-14'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 items-center justify-between border-b px-2">
|
||||
<span className={cn('text-xs text-muted-foreground', !sidebarOpen && 'hidden')}>Уроки</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarOpen((prev) => !prev)}>
|
||||
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{sidebarOpen ? (
|
||||
<LessonSidebar
|
||||
course={course}
|
||||
activeLesson={activeLesson?.lessonId ?? ''}
|
||||
onSelectLesson={handleSelectLesson}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium truncate">{activeLessonMeta?.title ?? 'Выберите урок'}</p>
|
||||
<p className="text-xs text-muted-foreground">Редактирование контента урока</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setReadOnly((prev) => !prev)}>
|
||||
{readOnly ? 'Редактировать' : 'Режим просмотра'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveLesson} disabled={savingLesson || !activeLesson || readOnly}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{savingLesson ? 'Сохранение...' : 'Сохранить урок'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6">
|
||||
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{contentLoading ? (
|
||||
<p className="text-muted-foreground">Загрузка контента...</p>
|
||||
) : readOnly ? (
|
||||
@ -213,6 +452,220 @@ export default function CourseEditPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'quiz' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Тест урока</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Выберите урок во вкладке «Контент», затем сгенерируйте тест в один клик.
|
||||
</p>
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
<p className="font-medium">Текущий урок:</p>
|
||||
<p className="text-muted-foreground">{activeLessonMeta?.title || 'Урок не выбран'}</p>
|
||||
</div>
|
||||
<Button onClick={handleGenerateQuiz} disabled={!activeLesson || generatingQuiz}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{generatingQuiz ? 'Генерация...' : 'Сгенерировать тест'}
|
||||
</Button>
|
||||
{quizGenerated ? (
|
||||
<p className="text-sm text-emerald-600">Тест доступен студентам в режиме обучения.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'homework' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Домашнее задание</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Типы: Текстовый ответ, Файл, Проект, Тест, GitHub ссылка.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={homeworkType}
|
||||
onChange={(e) => setHomeworkType(e.target.value as any)}
|
||||
className="h-10 rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="TEXT">Текстовый ответ</option>
|
||||
<option value="FILE">Файл</option>
|
||||
<option value="PROJECT">Проект</option>
|
||||
<option value="QUIZ">Тест</option>
|
||||
<option value="GITHUB">GitHub ссылка</option>
|
||||
</select>
|
||||
<Button onClick={handleGenerateHomework} disabled={!activeLesson || generatingHomework}>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
{generatingHomework ? 'Генерация...' : '➕ Добавить ДЗ'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ДЗ создаётся для выбранного урока: {activeLessonMeta?.title || 'урок не выбран'}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'materials' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Дополнительные материалы и источники</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
|
||||
<p className="font-medium">Поддержка форматов</p>
|
||||
<p className="text-muted-foreground mt-1">PDF, DOCX, TXT, PPTX, изображения, ZIP</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
В фазе 1: PDF/TXT анализируются для структуры, остальные сохраняются как вложения.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingSource ? 'Загрузка...' : 'Загрузить источник'}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.docx,.txt,.pptx,.zip,image/*"
|
||||
onChange={handleUploadSource}
|
||||
disabled={uploadingSource}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Загруженные файлы</p>
|
||||
{materials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Пока нет загруженных материалов.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{materials.map((file) => (
|
||||
<div key={file.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">{file.fileName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{file.sourceType} • {file.parseStatus}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Предложенная структура из источников</p>
|
||||
{outlineHints.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Пока нет рекомендаций. Добавьте PDF/TXT.</p>
|
||||
) : (
|
||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||
{outlineHints.map((hint: any) => (
|
||||
<li key={hint.id}>{hint.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'pricing' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Цены</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2 max-w-md">
|
||||
<input
|
||||
value={coursePrice}
|
||||
onChange={(e) => setCoursePrice(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Цена (пусто или 0 = бесплатно)"
|
||||
/>
|
||||
<select
|
||||
value={courseCurrency}
|
||||
onChange={(e) => setCourseCurrency(e.target.value)}
|
||||
className="rounded-md border bg-background px-2 py-2 text-sm"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="RUB">RUB</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{savingMeta ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Настройки</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select
|
||||
value={courseDifficulty}
|
||||
onChange={(e) => setCourseDifficulty(e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Уровень сложности</option>
|
||||
<option value="beginner">Начинающий</option>
|
||||
<option value="intermediate">Средний</option>
|
||||
<option value="advanced">Продвинутый</option>
|
||||
</select>
|
||||
<input
|
||||
value={courseEstimatedHours}
|
||||
onChange={(e) => setCourseEstimatedHours(e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Оценка длительности (часы)"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
value={courseTags}
|
||||
onChange={(e) => setCourseTags(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Теги через запятую"
|
||||
/>
|
||||
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{savingMeta ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'access' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Доступ и публикация</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
|
||||
<p><span className="font-medium">Статус:</span> {course.status}</p>
|
||||
{course.moderationNote ? (
|
||||
<p className="mt-1 text-muted-foreground">Комментарий модерации: {course.moderationNote}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleToggleVerify} disabled={verifying}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
{verifying ? 'Обработка...' : 'Верификация автора'}
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={publishing}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{publishing ? 'Отправка...' : 'Отправить на модерацию'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { CheckCircle2, Link as LinkIcon, Send, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function CourseGroupPage() {
|
||||
const params = useParams();
|
||||
const courseId = params?.id as string;
|
||||
const { backendUser } = useAuth();
|
||||
|
||||
const [group, setGroup] = useState<any>(null);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [submissions, setSubmissions] = useState<any[]>([]);
|
||||
const [reviewState, setReviewState] = useState<Record<string, { score: number; feedback: string }>>({});
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isAuthor = useMemo(() => {
|
||||
if (!backendUser || !group?.courseId) return false;
|
||||
return group?.courseAuthorId === backendUser.id;
|
||||
}, [backendUser, group]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!courseId) return;
|
||||
const [groupData, courseData] = await Promise.all([
|
||||
api.getDefaultCourseGroup(courseId),
|
||||
api.getCourse(courseId).catch(() => null),
|
||||
]);
|
||||
setGroup({
|
||||
...(groupData.group || {}),
|
||||
courseAuthorId: courseData?.authorId,
|
||||
});
|
||||
setMessages(groupData.messages || []);
|
||||
setMembers(groupData.members || []);
|
||||
if (courseData?.authorId && backendUser?.id === courseData.authorId) {
|
||||
const list = await api.getHomeworkSubmissions(courseId).catch(() => []);
|
||||
setSubmissions(list);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [courseId, backendUser?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group?.id) return;
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/course-groups`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('groups:join', { groupId: group.id });
|
||||
socket.on('groups:new-message', (msg: any) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
});
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [group?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!group?.id || !message.trim()) return;
|
||||
setSending(true);
|
||||
await api.sendGroupMessage(group.id, message.trim());
|
||||
setMessage('');
|
||||
const latest = await api.getGroupMessages(group.id).catch(() => []);
|
||||
setMessages(latest);
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const createInvite = async () => {
|
||||
if (!group?.id) return;
|
||||
const res = await api.createGroupInviteLink(group.id);
|
||||
setInviteLink(res.inviteUrl);
|
||||
};
|
||||
|
||||
const submitReview = async (submissionId: string) => {
|
||||
const state = reviewState[submissionId];
|
||||
if (!state) return;
|
||||
await api.reviewHomeworkSubmission(courseId, submissionId, {
|
||||
teacherScore: state.score,
|
||||
teacherFeedback: state.feedback || undefined,
|
||||
});
|
||||
const list = await api.getHomeworkSubmissions(courseId).catch(() => []);
|
||||
setSubmissions(list);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Комьюнити курса</h1>
|
||||
<p className="text-sm text-muted-foreground">Общение студентов, приглашения и проверка домашних заданий</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="border-b bg-muted/20">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{group?.name || 'Основная группа'}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{messages.length} сообщений</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[460px] flex-col p-4">
|
||||
<div className="flex-1 space-y-2 overflow-auto pr-2">
|
||||
{messages.map((msg) => {
|
||||
const own = msg.user?.id === backendUser?.id;
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[78%] rounded-2xl px-3 py-2 text-sm',
|
||||
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md border bg-card'
|
||||
)}
|
||||
>
|
||||
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Участник'}</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2 border-t pt-3">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Сообщение в чат"
|
||||
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
|
||||
/>
|
||||
<Button onClick={sendMessage} disabled={sending || !message.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4" />
|
||||
Участники ({members.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="rounded-xl border p-2 text-sm">
|
||||
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}</p>
|
||||
</div>
|
||||
))}
|
||||
{isAuthor ? (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Button variant="outline" onClick={createInvite} className="w-full">
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Invite-ссылка
|
||||
</Button>
|
||||
{inviteLink ? <p className="break-all text-xs text-muted-foreground">{inviteLink}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isAuthor ? (
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle>Проверка домашних заданий</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{submissions.map((submission) => (
|
||||
<div key={submission.id} className="space-y-2 rounded-xl border p-3">
|
||||
<p className="text-sm font-medium">
|
||||
{submission.user?.name || submission.user?.email} • {submission.homework?.lesson?.title}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{submission.content}</p>
|
||||
{submission.aiScore ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
AI-предоценка: {submission.aiScore}/5 {submission.aiFeedback ? `• ${submission.aiFeedback}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-9 rounded-lg border bg-background px-2 text-sm"
|
||||
value={reviewState[submission.id]?.score ?? submission.teacherScore ?? 5}
|
||||
onChange={(e) =>
|
||||
setReviewState((prev) => ({
|
||||
...prev,
|
||||
[submission.id]: {
|
||||
score: Number(e.target.value),
|
||||
feedback: prev[submission.id]?.feedback || submission.teacherFeedback || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="h-9 flex-1 rounded-lg border bg-background px-3 text-sm"
|
||||
value={reviewState[submission.id]?.feedback ?? submission.teacherFeedback ?? ''}
|
||||
onChange={(e) =>
|
||||
setReviewState((prev) => ({
|
||||
...prev,
|
||||
[submission.id]: {
|
||||
score: prev[submission.id]?.score ?? submission.teacherScore ?? 5,
|
||||
feedback: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Комментарий преподавателя"
|
||||
/>
|
||||
<Button size="sm" onClick={() => submitReview(submission.id)}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{submissions.length === 0 ? <p className="text-sm text-muted-foreground">Пока нет отправленных ДЗ</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Edit,
|
||||
FilePlus2,
|
||||
GraduationCap,
|
||||
Lock,
|
||||
Play,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -16,56 +31,170 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
||||
import { LessonQuiz } from '@/components/dashboard/lesson-quiz';
|
||||
import { LessonChatPanel } from '@/components/dashboard/lesson-chat-panel';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
||||
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
|
||||
type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] };
|
||||
type Chapter = { id: string; title: string; order: number; lessons: Lesson[] };
|
||||
type CourseData = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
authorId: string;
|
||||
chapters: Chapter[];
|
||||
};
|
||||
|
||||
type LessonProgressRow = {
|
||||
lessonId: string;
|
||||
quizPassed?: boolean;
|
||||
homeworkSubmitted?: boolean;
|
||||
quizScore?: number | null;
|
||||
};
|
||||
|
||||
type HomeworkState = {
|
||||
homework: { id: string; title: string; description: string; type?: string; config?: any } | null;
|
||||
submission: { id: string; content: string; aiScore?: number | null; aiFeedback?: string | null } | null;
|
||||
};
|
||||
|
||||
export default function CoursePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { loading: authLoading } = useAuth();
|
||||
const id = params?.id as string;
|
||||
const { loading: authLoading, backendUser } = useAuth();
|
||||
|
||||
const [course, setCourse] = useState<CourseData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||||
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
|
||||
const [lessonContentLoading, setLessonContentLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [progressRows, setProgressRows] = useState<Record<string, LessonProgressRow>>({});
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
const [quizQuestions, setQuizQuestions] = useState<any[]>([]);
|
||||
const [quizLoading, setQuizLoading] = useState(false);
|
||||
const [homework, setHomework] = useState<HomeworkState>({ homework: null, submission: null });
|
||||
const [homeworkLoading, setHomeworkLoading] = useState(false);
|
||||
const [homeworkSubmitting, setHomeworkSubmitting] = useState(false);
|
||||
const [homeworkContent, setHomeworkContent] = useState('');
|
||||
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
|
||||
const [groupId, setGroupId] = useState<string | null>(null);
|
||||
const [activeLessonPanel, setActiveLessonPanel] = useState<'content' | 'quiz' | 'homework' | 'materials'>('content');
|
||||
|
||||
const flatLessons = useMemo(() => {
|
||||
if (!course) return [];
|
||||
return course.chapters
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.flatMap((chapter) =>
|
||||
chapter.lessons
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((lesson) => ({
|
||||
...lesson,
|
||||
chapterId: chapter.id,
|
||||
chapterTitle: chapter.title,
|
||||
}))
|
||||
);
|
||||
}, [course]);
|
||||
|
||||
const currentLessonIndex = flatLessons.findIndex((lesson) => lesson.id === selectedLessonId);
|
||||
const totalLessons = flatLessons.length;
|
||||
const isAuthor = Boolean(course && backendUser && course.authorId === backendUser.id);
|
||||
|
||||
const isLessonDone = (lessonId: string): boolean => {
|
||||
const row = progressRows[lessonId];
|
||||
return Boolean(row?.quizPassed && row?.homeworkSubmitted);
|
||||
};
|
||||
|
||||
const isLessonUnlocked = (lessonId: string): boolean => {
|
||||
if (isAuthor) return true;
|
||||
const idx = flatLessons.findIndex((lesson) => lesson.id === lessonId);
|
||||
if (idx <= 0) return true;
|
||||
const prevLesson = flatLessons[idx - 1];
|
||||
return isLessonDone(prevLesson.id);
|
||||
};
|
||||
|
||||
const completedCount = flatLessons.filter((lesson) => isLessonDone(lesson.id)).length;
|
||||
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
|
||||
const activeLesson = selectedLessonId ? flatLessons.find((lesson) => lesson.id === selectedLessonId) : null;
|
||||
const activeProgress = selectedLessonId ? progressRows[selectedLessonId] : undefined;
|
||||
const courseCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
||||
|
||||
const refreshProgress = async (courseId: string) => {
|
||||
const progressData = await api.getEnrollmentProgress(courseId).catch(() => null);
|
||||
const map: Record<string, LessonProgressRow> = {};
|
||||
(progressData?.lessons || []).forEach((row: any) => {
|
||||
map[String(row.lessonId)] = row;
|
||||
});
|
||||
setProgressRows(map);
|
||||
return map;
|
||||
};
|
||||
|
||||
const loadHomework = async (courseId: string, lessonId: string) => {
|
||||
setHomeworkLoading(true);
|
||||
try {
|
||||
const data = await api.getLessonHomework(courseId, lessonId);
|
||||
setHomework({ homework: data.homework || null, submission: data.submission || null });
|
||||
setHomeworkContent(data.submission?.content || '');
|
||||
} catch {
|
||||
setHomework({ homework: null, submission: null });
|
||||
setHomeworkContent('');
|
||||
} finally {
|
||||
setHomeworkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || authLoading) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getCourse(id);
|
||||
if (!cancelled) {
|
||||
setCourse(data);
|
||||
const first = data.chapters?.[0]?.lessons?.[0];
|
||||
if (first) setSelectedLessonId(first.id);
|
||||
const courseData = await api.getCourse(id);
|
||||
if (cancelled) return;
|
||||
setCourse(courseData);
|
||||
setExpandedChapters((courseData.chapters || []).map((ch: Chapter) => ch.id));
|
||||
|
||||
const [map, groupData] = await Promise.all([
|
||||
refreshProgress(id),
|
||||
api.getDefaultCourseGroup(id).catch(() => null),
|
||||
]);
|
||||
if (groupData?.group?.id) {
|
||||
setGroupId(groupData.group.id);
|
||||
}
|
||||
|
||||
const ordered = (courseData.chapters || [])
|
||||
.sort((a: Chapter, b: Chapter) => a.order - b.order)
|
||||
.flatMap((chapter: Chapter) =>
|
||||
(chapter.lessons || [])
|
||||
.sort((a: Lesson, b: Lesson) => a.order - b.order)
|
||||
.map((lesson: Lesson) => lesson.id)
|
||||
);
|
||||
const firstUnlocked = ordered.find((lessonId: string) => {
|
||||
if (isAuthor) return true;
|
||||
const idx = ordered.indexOf(lessonId);
|
||||
if (idx <= 0) return true;
|
||||
const prevLessonId = ordered[idx - 1];
|
||||
const prevRow = map[prevLessonId];
|
||||
return Boolean(prevRow?.quizPassed && prevRow?.homeworkSubmitted);
|
||||
});
|
||||
setSelectedLessonId(firstUnlocked || ordered[0] || null);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -75,25 +204,44 @@ export default function CoursePage() {
|
||||
}
|
||||
let cancelled = false;
|
||||
setLessonContentLoading(true);
|
||||
setShowQuiz(false);
|
||||
setQuizQuestions([]);
|
||||
setActiveLessonPanel('content');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.getLesson(id, selectedLessonId);
|
||||
const content = data?.content;
|
||||
if (!cancelled)
|
||||
if (!cancelled) {
|
||||
setLessonContent(
|
||||
typeof content === 'object' && content !== null ? (content as Record<string, unknown>) : null
|
||||
typeof data?.content === 'object' && data.content !== null
|
||||
? (data.content as Record<string, unknown>)
|
||||
: null
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setLessonContent(null);
|
||||
} finally {
|
||||
if (!cancelled) setLessonContentLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, selectedLessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !selectedLessonId) return;
|
||||
if (activeProgress?.quizPassed && !activeProgress?.homeworkSubmitted) {
|
||||
loadHomework(id, selectedLessonId);
|
||||
return;
|
||||
}
|
||||
setHomework({ homework: null, submission: null });
|
||||
setHomeworkContent('');
|
||||
}, [id, selectedLessonId, activeProgress?.quizPassed, activeProgress?.homeworkSubmitted]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!course?.id || deleting) return;
|
||||
if (!course || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteCourse(course.id);
|
||||
@ -106,59 +254,132 @@ export default function CoursePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartQuiz = async () => {
|
||||
if (!id || !selectedLessonId || quizLoading) return;
|
||||
setQuizLoading(true);
|
||||
try {
|
||||
const data = await api.getLessonQuiz(id, selectedLessonId);
|
||||
setQuizQuestions(Array.isArray(data?.questions) ? data.questions : []);
|
||||
setShowQuiz(true);
|
||||
} finally {
|
||||
setQuizLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuizComplete = async (answers: number[]) => {
|
||||
if (!id || !selectedLessonId) {
|
||||
return { score: 0, passed: false };
|
||||
}
|
||||
const result = await api.submitQuizAnswers(id, selectedLessonId, answers);
|
||||
setProgressRows((prev) => ({
|
||||
...prev,
|
||||
[selectedLessonId]: result.progress || {
|
||||
lessonId: selectedLessonId,
|
||||
quizPassed: Boolean(result.passed),
|
||||
},
|
||||
}));
|
||||
|
||||
if (result.passed) {
|
||||
await loadHomework(id, selectedLessonId);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleSubmitHomework = async () => {
|
||||
if (!id || !selectedLessonId || !homeworkContent.trim() || homeworkSubmitting) return;
|
||||
setHomeworkSubmitting(true);
|
||||
try {
|
||||
const submission = await api.submitLessonHomework(id, selectedLessonId, {
|
||||
content: homeworkContent.trim(),
|
||||
type: homeworkType,
|
||||
});
|
||||
setHomework((prev) => ({ ...prev, submission }));
|
||||
await refreshProgress(id);
|
||||
} finally {
|
||||
setHomeworkSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateHomework = async () => {
|
||||
if (!id || !selectedLessonId) return;
|
||||
await api.generateLessonHomework(id, selectedLessonId, homeworkType).catch(() => null);
|
||||
await loadHomework(id, selectedLessonId);
|
||||
};
|
||||
|
||||
const goToPrevLesson = () => {
|
||||
if (currentLessonIndex <= 0) return;
|
||||
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
|
||||
};
|
||||
|
||||
const goToNextLesson = () => {
|
||||
if (currentLessonIndex < 0 || currentLessonIndex >= flatLessons.length - 1) return;
|
||||
if (!selectedLessonId || !isLessonDone(selectedLessonId)) return;
|
||||
const nextLesson = flatLessons[currentLessonIndex + 1];
|
||||
if (!isLessonUnlocked(nextLesson.id)) return;
|
||||
setSelectedLessonId(nextLesson.id);
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||
<p className="text-muted-foreground">Загрузка курса...</p>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !course) {
|
||||
if (!course || error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="p-6">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/dashboard"><ArrowLeft className="mr-2 h-4 w-4" />Назад к курсам</Link>
|
||||
<Link href="/dashboard">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Назад
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-destructive">{error || 'Курс не найден'}</p>
|
||||
<p className="text-destructive mt-4">{error || 'Курс не найден'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeLessonTitle = selectedLessonId
|
||||
? (() => {
|
||||
for (const ch of course.chapters) {
|
||||
const lesson = ch.lessons.find((l) => l.id === selectedLessonId);
|
||||
if (lesson) return lesson.title;
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-3">
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-2.5 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/dashboard">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
К курсам
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Мои курсы
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="font-medium truncate max-w-[200px] sm:max-w-xs">{course.title}</span>
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium truncate max-w-[280px]">{course.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" asChild>
|
||||
<div className="hidden sm:flex items-center gap-2 mr-2 px-3 py-1.5 bg-muted rounded-full">
|
||||
<span className="text-xs font-medium">{progressPercent}% пройдено</span>
|
||||
</div>
|
||||
{courseCompleted && (
|
||||
<Button size="sm" onClick={() => window.open(`/certificate/${id}`, '_blank')}>
|
||||
Получить сертификат
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/dashboard/courses/${id}/group`}>Группа курса</Link>
|
||||
</Button>
|
||||
{isAuthor && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/dashboard/courses/${course.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Edit className="mr-1.5 h-3.5 w-3.5" />
|
||||
Редактировать
|
||||
</Link>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="text-destructive hover:text-destructive hover:bg-destructive/10">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
@ -172,7 +393,10 @@ export default function CoursePage() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => { e.preventDefault(); handleDelete(); }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={deleting}
|
||||
>
|
||||
@ -181,55 +405,306 @@ export default function CoursePage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 min-h-0">
|
||||
{/* Left: list of lessons (paragraphs) */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-r bg-muted/20 flex flex-col transition-[width] duration-200',
|
||||
sidebarOpen ? 'w-72 shrink-0' : 'w-0 overflow-hidden'
|
||||
'border-r bg-muted/10 flex flex-col transition-all duration-300 ease-in-out',
|
||||
sidebarOpen ? 'w-80 shrink-0' : 'w-0 overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{sidebarOpen && (
|
||||
<LessonSidebar
|
||||
course={course}
|
||||
activeLesson={selectedLessonId ?? ''}
|
||||
onSelectLesson={setSelectedLessonId}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Прогресс курса</span>
|
||||
<span className="text-xs font-bold text-primary">{completedCount}/{totalLessons}</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{course.chapters
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((chapter) => {
|
||||
const isExpanded = expandedChapters.includes(chapter.id);
|
||||
return (
|
||||
<div key={chapter.id} className="mb-1">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
onClick={() =>
|
||||
setExpandedChapters((prev) =>
|
||||
prev.includes(chapter.id) ? prev.filter((id) => id !== chapter.id) : [...prev, chapter.id]
|
||||
)
|
||||
}
|
||||
>
|
||||
<BookOpen className="h-4 w-4 text-primary" />
|
||||
<span className="flex-1 text-left font-medium truncate">{chapter.title}</span>
|
||||
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', isExpanded && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-4 border-l-2 border-muted pl-2 mb-2">
|
||||
{chapter.lessons
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((lesson) => {
|
||||
const isActive = selectedLessonId === lesson.id;
|
||||
const isDone = isLessonDone(lesson.id);
|
||||
const unlocked = isLessonUnlocked(lesson.id);
|
||||
const isLocked = !unlocked;
|
||||
const row = progressRows[lesson.id];
|
||||
const quizOnly = row?.quizPassed && !row?.homeworkSubmitted;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 w-full rounded-lg px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: isLocked
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: 'hover:bg-muted/80 text-foreground/80'
|
||||
)}
|
||||
onClick={() => !isLocked && setSelectedLessonId(lesson.id)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary-foreground' : 'text-green-500')} />
|
||||
) : isLocked ? (
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
) : isActive ? (
|
||||
<Play className="h-4 w-4 shrink-0 fill-current" />
|
||||
) : quizOnly ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-left">{lesson.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-colors"
|
||||
style={{ left: sidebarOpen ? '17rem' : 0 }}
|
||||
className="absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-all"
|
||||
style={{ left: sidebarOpen ? '19.9rem' : 0 }}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
{/* Center: lesson content (read-only) */}
|
||||
<main className="flex-1 flex flex-col min-h-0 overflow-auto">
|
||||
<div className="max-w-3xl mx-auto w-full px-6 py-8">
|
||||
{activeLessonTitle && (
|
||||
<h1 className="text-2xl font-bold mb-6 text-foreground">{activeLessonTitle}</h1>
|
||||
<main className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-3xl mx-auto w-full px-8 py-10">
|
||||
{activeLesson && (
|
||||
<div className="mb-8">
|
||||
<p className="text-sm text-primary font-medium mb-1">{activeLesson.chapterTitle}</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">{activeLesson.title}</h1>
|
||||
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Урок {currentLessonIndex + 1} из {totalLessons}
|
||||
</span>
|
||||
{activeLesson.durationMinutes && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{activeLesson.durationMinutes} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 h-px bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lessonContentLoading ? (
|
||||
<p className="text-muted-foreground">Загрузка...</p>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : selectedLessonId ? (
|
||||
<LessonContentViewer
|
||||
content={lessonContent}
|
||||
className="prose-reader min-h-[400px] [&_.ProseMirror]:min-h-[300px]"
|
||||
/>
|
||||
<>
|
||||
<div className="mb-6 flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeLessonPanel === 'content' ? 'default' : 'ghost'}
|
||||
onClick={() => setActiveLessonPanel('content')}
|
||||
>
|
||||
Контент
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeLessonPanel === 'quiz' ? 'default' : 'ghost'}
|
||||
onClick={() => setActiveLessonPanel('quiz')}
|
||||
>
|
||||
Тест
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeLessonPanel === 'homework' ? 'default' : 'ghost'}
|
||||
onClick={() => setActiveLessonPanel('homework')}
|
||||
>
|
||||
Домашнее задание
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeLessonPanel === 'materials' ? 'default' : 'ghost'}
|
||||
onClick={() => setActiveLessonPanel('materials')}
|
||||
>
|
||||
Доп. материалы
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeLessonPanel === 'content' ? (
|
||||
<LessonContentViewer content={lessonContent} className="min-h-[320px]" />
|
||||
) : null}
|
||||
|
||||
{activeLessonPanel === 'quiz' ? (
|
||||
<div className="space-y-4">
|
||||
{!activeProgress?.quizPassed ? (
|
||||
<div className="p-6 border rounded-xl bg-muted/20 text-center">
|
||||
<h3 className="font-semibold mb-2">Шаг 1 из 2: тест</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Для открытия следующего урока пройдите тест.
|
||||
</p>
|
||||
<Button onClick={handleStartQuiz} disabled={quizLoading}>
|
||||
{quizLoading ? 'Загрузка теста...' : 'Начать тест'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Выберите урок в списке слева.</p>
|
||||
<div className="p-4 rounded-xl border bg-emerald-50 text-emerald-800">
|
||||
Тест уже пройден. Можно переходить к домашнему заданию.
|
||||
</div>
|
||||
)}
|
||||
{showQuiz ? (
|
||||
<LessonQuiz
|
||||
courseId={id}
|
||||
lessonId={selectedLessonId}
|
||||
questions={quizQuestions}
|
||||
onComplete={handleQuizComplete}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeLessonPanel === 'homework' ? (
|
||||
<div className="p-6 border rounded-xl bg-muted/20">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-semibold">Домашнее задание</h3>
|
||||
{isAuthor ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={homeworkType}
|
||||
onChange={(e) => setHomeworkType(e.target.value as any)}
|
||||
className="h-9 rounded-lg border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="TEXT">Текстовый ответ</option>
|
||||
<option value="FILE">Файл</option>
|
||||
<option value="PROJECT">Проект</option>
|
||||
<option value="QUIZ">Тест</option>
|
||||
<option value="GITHUB">GitHub ссылка</option>
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={handleGenerateHomework}>
|
||||
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||
Добавить ДЗ
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!activeProgress?.quizPassed ? (
|
||||
<p className="text-sm text-muted-foreground">Сначала пройдите тест этого урока.</p>
|
||||
) : homeworkLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Подготовка задания...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{homework.homework?.title || 'Домашнее задание'}
|
||||
</p>
|
||||
<p className="text-sm mb-4">
|
||||
{homework.homework?.description ||
|
||||
'Опишите применение материала урока на практике и приведите минимум один пример.'}
|
||||
</p>
|
||||
<textarea
|
||||
value={homeworkContent}
|
||||
onChange={(e) => setHomeworkContent(e.target.value)}
|
||||
className="w-full min-h-[180px] rounded-md border bg-background p-3 text-sm"
|
||||
placeholder="Ваш ответ..."
|
||||
disabled={Boolean(activeProgress?.homeworkSubmitted)}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Рекомендуется подробный ответ и примеры</p>
|
||||
<Button
|
||||
onClick={handleSubmitHomework}
|
||||
disabled={homeworkSubmitting || Boolean(activeProgress?.homeworkSubmitted)}
|
||||
>
|
||||
{activeProgress?.homeworkSubmitted
|
||||
? 'ДЗ отправлено'
|
||||
: homeworkSubmitting
|
||||
? 'Отправка...'
|
||||
: 'Отправить ДЗ'}
|
||||
</Button>
|
||||
</div>
|
||||
{homework.submission?.aiScore ? (
|
||||
<div className="mt-3 rounded-md border bg-background p-3 text-sm">
|
||||
<p className="font-medium">AI-предоценка: {homework.submission.aiScore}/5</p>
|
||||
{homework.submission.aiFeedback ? (
|
||||
<p className="text-muted-foreground mt-1">{homework.submission.aiFeedback}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeLessonPanel === 'materials' ? (
|
||||
<div className="p-6 rounded-xl border bg-muted/20 text-sm text-muted-foreground">
|
||||
Дополнительные материалы для урока можно добавить в редакторе курса во вкладке
|
||||
{' '}<span className="font-medium text-foreground">«Доп. материалы»</span>.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">Выберите урок</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t bg-background px-6 py-3">
|
||||
<div className="max-w-3xl mx-auto flex items-center justify-between">
|
||||
<Button variant="outline" size="sm" onClick={goToPrevLesson} disabled={currentLessonIndex <= 0}>
|
||||
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
||||
Предыдущий
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedLessonId && isLessonDone(selectedLessonId)
|
||||
? 'Урок завершён'
|
||||
: 'Для следующего урока нужны тест + ДЗ'}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={goToNextLesson}
|
||||
disabled={currentLessonIndex >= flatLessons.length - 1 || !selectedLessonId || !isLessonDone(selectedLessonId)}
|
||||
>
|
||||
Следующий урок
|
||||
<ChevronRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<LessonChatPanel groupId={groupId} lessonId={selectedLessonId} userId={backendUser?.id || null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import { cn } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
type Step = 'prompt' | 'questions' | 'generating' | 'complete' | 'error';
|
||||
type Step = 'prompt' | 'questions' | 'recommendations' | 'generating' | 'complete' | 'error';
|
||||
|
||||
interface ClarifyingQuestion {
|
||||
id: string;
|
||||
@ -34,6 +34,12 @@ export default function NewCoursePage() {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [courseId, setCourseId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [aiRecommendation, setAiRecommendation] = useState<{
|
||||
modules: number;
|
||||
lessonFormat: string;
|
||||
assignmentTypes: string[];
|
||||
suggestedStructure: string[];
|
||||
} | null>(null);
|
||||
|
||||
// Poll for generation status
|
||||
const pollStatus = useCallback(async () => {
|
||||
@ -74,7 +80,7 @@ export default function NewCoursePage() {
|
||||
break;
|
||||
default:
|
||||
// Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT)
|
||||
if (step !== 'questions') {
|
||||
if (step !== 'questions' && step !== 'recommendations') {
|
||||
setStep('generating');
|
||||
}
|
||||
}
|
||||
@ -85,7 +91,7 @@ export default function NewCoursePage() {
|
||||
|
||||
// Start polling when we have a generation ID
|
||||
useEffect(() => {
|
||||
if (!generationId || step === 'complete' || step === 'error' || step === 'questions') {
|
||||
if (!generationId || step === 'complete' || step === 'error' || step === 'questions' || step === 'recommendations') {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -132,13 +138,44 @@ export default function NewCoursePage() {
|
||||
|
||||
const handleSubmitAnswers = async () => {
|
||||
if (!generationId || isSubmitting) return;
|
||||
const audience = String(answers.q_audience || '');
|
||||
const format = String(answers.q_format || '');
|
||||
const goal = String(answers.q_goal || '');
|
||||
const volume = String(answers.q_volume || '');
|
||||
|
||||
const modules =
|
||||
volume.includes('Короткий') ? 4 : volume.includes('Полный') ? 9 : 6;
|
||||
const lessonFormat =
|
||||
format === 'Практика' ? '70% практика / 30% теория' : format === 'Теория' ? '80% теория / 20% практика' : 'Смешанный 50/50';
|
||||
const assignmentTypes =
|
||||
goal === 'Подготовиться к экзамену'
|
||||
? ['Тесты', 'Контрольные кейсы', 'Проверка на время']
|
||||
: goal === 'Освоить профессию'
|
||||
? ['Практика', 'Мини-проекты', 'Портфолио задания']
|
||||
: ['Практика', 'Тест', 'Домашнее задание'];
|
||||
const suggestedStructure = [
|
||||
`Введение для уровня: ${audience || 'не указан'}`,
|
||||
'Базовые принципы и инструменты',
|
||||
'Практические модули по задачам',
|
||||
'Финальный блок с закреплением',
|
||||
];
|
||||
|
||||
setAiRecommendation({
|
||||
modules,
|
||||
lessonFormat,
|
||||
assignmentTypes,
|
||||
suggestedStructure,
|
||||
});
|
||||
setStep('recommendations');
|
||||
};
|
||||
|
||||
const handleConfirmGeneration = async () => {
|
||||
if (!generationId || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await api.answerQuestions(generationId, answers);
|
||||
setStep('generating');
|
||||
|
||||
// Resume polling
|
||||
setTimeout(pollStatus, 1000);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
@ -171,6 +208,7 @@ export default function NewCoursePage() {
|
||||
setCurrentStepText('');
|
||||
setErrorMessage('');
|
||||
setCourseId(null);
|
||||
setAiRecommendation(null);
|
||||
};
|
||||
|
||||
const allRequiredAnswered = questions
|
||||
@ -392,6 +430,68 @@ export default function NewCoursePage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Generating */}
|
||||
{step === 'recommendations' && (
|
||||
<motion.div
|
||||
key="recommendations"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">AI-рекомендации перед генерацией</h3>
|
||||
</div>
|
||||
{aiRecommendation ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p><span className="font-medium">Рекомендуемое число модулей:</span> {aiRecommendation.modules}</p>
|
||||
<p><span className="font-medium">Формат уроков:</span> {aiRecommendation.lessonFormat}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="font-medium mb-1">Типы заданий:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{aiRecommendation.assignmentTypes.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="font-medium mb-1">Рекомендованная структура:</p>
|
||||
<ul className="list-decimal pl-5 space-y-1">
|
||||
{aiRecommendation.suggestedStructure.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button variant="outline" onClick={() => setStep('questions')}>
|
||||
Назад к вопросам
|
||||
</Button>
|
||||
<Button onClick={handleConfirmGeneration} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Запуск...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Подтвердить и создать курс
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Generating */}
|
||||
{step === 'generating' && (
|
||||
<motion.div
|
||||
|
||||
132
apps/web/src/app/(dashboard)/dashboard/groups/[groupId]/page.tsx
Normal file
132
apps/web/src/app/(dashboard)/dashboard/groups/[groupId]/page.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Send, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
export default function InviteGroupPage() {
|
||||
const params = useParams();
|
||||
const groupId = params?.groupId as string;
|
||||
const { backendUser } = useAuth();
|
||||
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
(async () => {
|
||||
await api.joinGroupByInvite(groupId).catch(() => null);
|
||||
const [msgs, mbrs] = await Promise.all([
|
||||
api.getGroupMessages(groupId).catch(() => []),
|
||||
api.getGroupMembers(groupId).catch(() => []),
|
||||
]);
|
||||
setMessages(msgs);
|
||||
setMembers(mbrs);
|
||||
})();
|
||||
}, [groupId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/course-groups`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('groups:join', { groupId });
|
||||
socket.on('groups:new-message', (msg: any) => setMessages((prev) => [...prev, msg]));
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
if (!groupId || !message.trim()) return;
|
||||
await api.sendGroupMessage(groupId, message.trim());
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Группа курса</h1>
|
||||
<p className="text-sm text-muted-foreground">Вы подключены по invite-ссылке</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="border-b bg-muted/20">
|
||||
<CardTitle>Чат участников</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[460px] flex-col p-4">
|
||||
<div className="flex-1 space-y-2 overflow-auto pr-2">
|
||||
{messages.map((msg) => {
|
||||
const own = msg.user?.id === backendUser?.id;
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[78%] rounded-2xl px-3 py-2 text-sm',
|
||||
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md border bg-card'
|
||||
)}
|
||||
>
|
||||
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Участник'}</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2 border-t pt-3">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
className="h-10 flex-1 rounded-lg border bg-background px-3 text-sm"
|
||||
placeholder="Сообщение"
|
||||
/>
|
||||
<Button onClick={send}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4" />
|
||||
Участники ({members.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="rounded-xl border p-2 text-sm">
|
||||
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/app/(dashboard)/dashboard/learning/page.tsx
Normal file
125
apps/web/src/app/(dashboard)/dashboard/learning/page.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { GraduationCap, BookOpen, Loader2, Trophy, Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
interface EnrollmentData {
|
||||
id: string;
|
||||
progress: number;
|
||||
completedAt: string | null;
|
||||
course: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
coverImage: string | null;
|
||||
author: { name: string | null };
|
||||
_count: { chapters: number };
|
||||
chapters: { _count: { lessons: number } }[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function LearningPage() {
|
||||
const { loading: authLoading, user } = useAuth();
|
||||
const [enrollments, setEnrollments] = useState<EnrollmentData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleOpenCertificate = (courseId: string) => {
|
||||
window.open(`/certificate/${courseId}`, '_blank');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || !user) { setLoading(false); return; }
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.getMyEnrollments();
|
||||
setEnrollments(data);
|
||||
} catch { /* silent */ }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
}, [authLoading, user]);
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Мои обучения</h1>
|
||||
<p className="text-muted-foreground mt-1">Курсы, на которые вы записаны</p>
|
||||
</div>
|
||||
|
||||
{enrollments.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<GraduationCap className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-lg font-medium">Пока нет записей</p>
|
||||
<p className="text-muted-foreground mb-4">Найдите курсы в каталоге</p>
|
||||
<Link href="/courses" className="text-primary hover:underline">
|
||||
Открыть каталог
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{enrollments.map((enrollment) => {
|
||||
const lessonsCount = enrollment.course.chapters.reduce(
|
||||
(acc, ch) => acc + ch._count.lessons, 0
|
||||
);
|
||||
return (
|
||||
<Link key={enrollment.id} href={`/dashboard/courses/${enrollment.course.id}`}>
|
||||
<Card className="group overflow-hidden transition-all hover:shadow-md cursor-pointer h-full">
|
||||
<div className="aspect-[3/1] bg-gradient-to-br from-primary/20 to-primary/5 relative">
|
||||
{enrollment.course.coverImage ? (
|
||||
<img src={enrollment.course.coverImage} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<BookOpen className="h-8 w-8 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
{enrollment.completedAt && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-600 text-white text-xs font-medium">
|
||||
<Trophy className="h-3 w-3" />
|
||||
Пройден
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold line-clamp-1">{enrollment.course.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">{enrollment.course.author.name}</p>
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{enrollment.progress}%</span>
|
||||
<span>{lessonsCount} уроков</span>
|
||||
</div>
|
||||
<Progress value={enrollment.progress} className="h-1.5" />
|
||||
</div>
|
||||
{enrollment.completedAt && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full mt-3"
|
||||
onClick={(e) => { e.preventDefault(); handleOpenCertificate(enrollment.course.id); }}
|
||||
>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
Скачать сертификат
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, BookOpen, Clock, TrendingUp, Loader2 } from 'lucide-react';
|
||||
import { BookOpen, Clock3, FileText, Loader2, Plus, Send, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CourseCard } from '@/components/dashboard/course-card';
|
||||
@ -10,41 +10,40 @@ import { api } from '@/lib/api';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
interface Course {
|
||||
type Course = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING';
|
||||
status:
|
||||
| 'DRAFT'
|
||||
| 'PUBLISHED'
|
||||
| 'ARCHIVED'
|
||||
| 'GENERATING'
|
||||
| 'PENDING_MODERATION'
|
||||
| 'PENDING_REVIEW'
|
||||
| 'REJECTED'
|
||||
| 'APPROVED';
|
||||
chaptersCount: number;
|
||||
lessonsCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { toast } = useToast();
|
||||
const { loading: authLoading, user } = useAuth();
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
drafts: 0,
|
||||
published: 0,
|
||||
});
|
||||
|
||||
const loadCourses = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.getCourses();
|
||||
setCourses(result.data);
|
||||
const total = result.data.length;
|
||||
const drafts = result.data.filter((c: Course) => c.status === 'DRAFT').length;
|
||||
const published = result.data.filter((c: Course) => c.status === 'PUBLISHED').length;
|
||||
setStats({ total, drafts, published });
|
||||
const result = await api.getCourses({ limit: 100 });
|
||||
setCourses(result.data || []);
|
||||
} catch (error: any) {
|
||||
if (error.message !== 'Unauthorized') {
|
||||
toast({
|
||||
title: 'Ошибка загрузки',
|
||||
description: 'Не удалось загрузить курсы',
|
||||
description: 'Не удалось загрузить ваши курсы',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -60,17 +59,25 @@ export default function DashboardPage() {
|
||||
return;
|
||||
}
|
||||
loadCourses();
|
||||
}, [toast, authLoading, user]);
|
||||
}, [authLoading, user]);
|
||||
|
||||
const statsCards = [
|
||||
{ name: 'Всего курсов', value: stats.total.toString(), icon: BookOpen },
|
||||
{ name: 'Черновики', value: stats.drafts.toString(), icon: Clock },
|
||||
{ name: 'Опубликовано', value: stats.published.toString(), icon: TrendingUp },
|
||||
];
|
||||
const stats = useMemo(() => {
|
||||
const drafts = courses.filter((course) => course.status === 'DRAFT' || course.status === 'REJECTED');
|
||||
const published = courses.filter((course) => course.status === 'PUBLISHED');
|
||||
const pending = courses.filter(
|
||||
(course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW'
|
||||
);
|
||||
return {
|
||||
drafts,
|
||||
published,
|
||||
pending,
|
||||
total: courses.length,
|
||||
};
|
||||
}, [courses]);
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
@ -78,62 +85,119 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<section className="rounded-3xl border border-border/50 bg-gradient-to-br from-primary/10 via-background to-amber-100/40 p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Мои курсы</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Управляйте своими курсами и создавайте новые
|
||||
<h1 className="text-3xl font-bold tracking-tight">Кабинет автора</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Здесь только ваша авторская зона: черновики, курсы в проверке и опубликованные материалы.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Открыть лендинг
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/courses/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать курс
|
||||
Новый курс
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{statsCards.map((stat) => (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.name}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Черновики</CardTitle>
|
||||
<CardDescription>Можно редактировать и дорабатывать</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{stats.drafts.length}</span>
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Courses grid */}
|
||||
{courses.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{courses.map((course) => (
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">На модерации</CardTitle>
|
||||
<CardDescription>Ожидают решения модератора</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{stats.pending.length}</span>
|
||||
<Send className="h-5 w-5 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Опубликованные</CardTitle>
|
||||
<CardDescription>Доступны пользователям в каталоге</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{stats.published.length}</span>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock3 className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold">Черновики и отклонённые</h2>
|
||||
</div>
|
||||
{stats.drafts.length ? (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{stats.drafts.map((course) => (
|
||||
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-12 text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>Нет курсов</CardTitle>
|
||||
<CardDescription>
|
||||
Создайте свой первый курс с помощью AI
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/courses/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать курс
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||
Нет черновиков. Создайте курс, чтобы начать работу.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">На проверке</h2>
|
||||
{stats.pending.length ? (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{stats.pending.map((course) => (
|
||||
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||
Сейчас нет курсов в модерации.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Опубликованные</h2>
|
||||
{stats.published.length ? (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{stats.published.map((course) => (
|
||||
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||
Пока нет опубликованных курсов.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
154
apps/web/src/app/(dashboard)/dashboard/support/page.tsx
Normal file
154
apps/web/src/app/(dashboard)/dashboard/support/page.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { MessageCircle, Plus, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
|
||||
export default function SupportPage() {
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [ticketTitle, setTicketTitle] = useState('');
|
||||
const [ticketMessage, setTicketMessage] = useState('');
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const selectedTicket = useMemo(
|
||||
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||
[tickets, selectedTicketId]
|
||||
);
|
||||
|
||||
const loadTickets = async () => {
|
||||
const data = await api.getMySupportTickets().catch(() => []);
|
||||
setTickets(data);
|
||||
if (!selectedTicketId && data.length > 0) {
|
||||
setSelectedTicketId(data[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTickets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTicketId) return;
|
||||
api.getSupportTicketMessages(selectedTicketId).then((data) => setMessages(data)).catch(() => setMessages([]));
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('support:join', { ticketId: selectedTicketId });
|
||||
socket.on('support:new-message', (message: any) => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [selectedTicketId]);
|
||||
|
||||
const createTicket = async () => {
|
||||
if (!ticketTitle.trim()) return;
|
||||
await api.createSupportTicket({ title: ticketTitle.trim(), initialMessage: ticketMessage.trim() || undefined });
|
||||
setTicketTitle('');
|
||||
setTicketMessage('');
|
||||
await loadTickets();
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!selectedTicketId || !newMessage.trim()) return;
|
||||
await api.sendSupportMessage(selectedTicketId, newMessage.trim());
|
||||
setNewMessage('');
|
||||
const latest = await api.getSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||
setMessages(latest);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Тикеты поддержки</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={ticketTitle}
|
||||
onChange={(e) => setTicketTitle(e.target.value)}
|
||||
placeholder="Тема тикета"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={ticketMessage}
|
||||
onChange={(e) => setTicketMessage(e.target.value)}
|
||||
placeholder="Опишите проблему"
|
||||
className="w-full min-h-[90px] rounded-md border bg-background p-3 text-sm"
|
||||
/>
|
||||
<Button onClick={createTicket} className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать тикет
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{tickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm ${
|
||||
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedTicketId(ticket.id)}
|
||||
>
|
||||
<p className="font-medium">{ticket.title}</p>
|
||||
<p className="text-xs text-muted-foreground">Статус: {ticket.status}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[520px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{selectedTicket ? `Чат тикета: ${selectedTicket.title}` : 'Выберите тикет'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[430px] flex-col">
|
||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
||||
<p className="font-medium">
|
||||
{msg.user?.name || 'Пользователь'} {msg.isStaff ? '(Поддержка)' : ''}
|
||||
</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Сообщений пока нет
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Сообщение"
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button onClick={sendMessage} disabled={!selectedTicketId}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { Sidebar } from '@/components/dashboard/sidebar';
|
||||
import { DashboardHeader } from '@/components/dashboard/header';
|
||||
import { SupportWidget } from '@/components/dashboard/support-widget';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@ -13,6 +14,7 @@ export default function DashboardLayout({
|
||||
<DashboardHeader />
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
<SupportWidget />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
78
apps/web/src/app/admin/layout.tsx
Normal file
78
apps/web/src/app/admin/layout.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Loader2, ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Курсы', href: '/admin' },
|
||||
{ name: 'Тикеты поддержки', href: '/admin/support' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { loading, backendUser } = useAuth();
|
||||
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isStaff) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md rounded-xl border bg-card p-6 text-center">
|
||||
<ShieldAlert className="mx-auto mb-3 h-8 w-8 text-rose-500" />
|
||||
<p className="text-base font-semibold">Нет доступа к Админ Панели</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Требуется роль администратора или модератора.</p>
|
||||
<Link href="/dashboard" className="mt-4 inline-block text-sm text-primary hover:underline">
|
||||
Вернуться в личный кабинет
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/20">
|
||||
<header className="border-b bg-background">
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Админ Панель</p>
|
||||
<p className="text-xs text-muted-foreground">Модерация и поддержка</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
pathname === item.href ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/dashboard" className="text-sm text-primary hover:underline">
|
||||
Личный кабинет
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
apps/web/src/app/admin/page.tsx
Normal file
474
apps/web/src/app/admin/page.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Search,
|
||||
Trash2,
|
||||
Users,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ModerationCourse = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
author?: { name?: string | null; email?: string };
|
||||
moderationNote?: string | null;
|
||||
updatedAt: string;
|
||||
_count?: { chapters?: number; enrollments?: number; reviews?: number };
|
||||
};
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
role: 'USER' | 'MODERATOR' | 'ADMIN';
|
||||
subscriptionTier?: string;
|
||||
};
|
||||
|
||||
type AdminPayment = {
|
||||
id: string;
|
||||
amount: string | number;
|
||||
currency: string;
|
||||
status: string;
|
||||
mode: 'DEV' | 'PROD';
|
||||
provider: 'STRIPE' | 'YOOMONEY';
|
||||
eventCode?: string | null;
|
||||
createdAt: string;
|
||||
user?: { name?: string | null; email?: string };
|
||||
course?: { id: string; title: string };
|
||||
};
|
||||
|
||||
const statusFilters = [
|
||||
{ value: '', label: 'Все статусы' },
|
||||
{ value: 'PENDING_MODERATION', label: 'На проверке' },
|
||||
{ value: 'PUBLISHED', label: 'Опубликованные' },
|
||||
{ value: 'REJECTED', label: 'Отклонённые' },
|
||||
{ value: 'DRAFT', label: 'Черновики' },
|
||||
];
|
||||
|
||||
const badgeMap: Record<string, string> = {
|
||||
PENDING_REVIEW: 'bg-amber-100 text-amber-900',
|
||||
PENDING_MODERATION: 'bg-amber-100 text-amber-900',
|
||||
PUBLISHED: 'bg-green-100 text-green-900',
|
||||
REJECTED: 'bg-rose-100 text-rose-900',
|
||||
DRAFT: 'bg-slate-100 text-slate-900',
|
||||
ARCHIVED: 'bg-slate-200 text-slate-900',
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'courses' | 'users' | 'payments'>('courses');
|
||||
|
||||
const [courses, setCourses] = useState<ModerationCourse[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [noteDraft, setNoteDraft] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actingId, setActingId] = useState<string | null>(null);
|
||||
|
||||
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [usersSearch, setUsersSearch] = useState('');
|
||||
|
||||
const [payments, setPayments] = useState<AdminPayment[]>([]);
|
||||
const [paymentsLoading, setPaymentsLoading] = useState(false);
|
||||
const [paymentSearch, setPaymentSearch] = useState('');
|
||||
const [paymentMode, setPaymentMode] = useState('');
|
||||
|
||||
const loadCourses = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const requestedStatus = status === 'PENDING_REVIEW' ? 'PENDING_MODERATION' : status;
|
||||
const data = await api.getModerationCourses({ status: requestedStatus || undefined, search: search || undefined });
|
||||
setCourses(data || []);
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить курсы', variant: 'destructive' });
|
||||
setCourses([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const data = await api.getAdminUsers({ search: usersSearch || undefined });
|
||||
setAdminUsers(data || []);
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить пользователей', variant: 'destructive' });
|
||||
setAdminUsers([]);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPayments = async () => {
|
||||
setPaymentsLoading(true);
|
||||
try {
|
||||
const data = await api.getAdminPayments({
|
||||
search: paymentSearch || undefined,
|
||||
mode: (paymentMode || undefined) as any,
|
||||
});
|
||||
setPayments(data || []);
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить платежи', variant: 'destructive' });
|
||||
setPayments([]);
|
||||
} finally {
|
||||
setPaymentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCourses();
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'users') {
|
||||
loadUsers();
|
||||
}
|
||||
if (activeTab === 'payments') {
|
||||
loadPayments();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
total: courses.length,
|
||||
pending: courses.filter((course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW').length,
|
||||
published: courses.filter((course) => course.status === 'PUBLISHED').length,
|
||||
};
|
||||
}, [courses]);
|
||||
|
||||
const approve = async (courseId: string) => {
|
||||
setActingId(courseId);
|
||||
try {
|
||||
await api.approveModerationCourse(courseId, noteDraft[courseId] || undefined);
|
||||
toast({ title: 'Курс опубликован', description: 'Курс прошёл модерацию' });
|
||||
await loadCourses();
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async (courseId: string) => {
|
||||
setActingId(courseId);
|
||||
try {
|
||||
await api.rejectModerationCourse(courseId, noteDraft[courseId] || 'Нужны доработки');
|
||||
toast({ title: 'Курс отклонён', description: 'Автор увидит причину в курсе' });
|
||||
await loadCourses();
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCourse = async (courseId: string) => {
|
||||
setActingId(courseId);
|
||||
try {
|
||||
await api.deleteModerationCourse(courseId);
|
||||
toast({ title: 'Курс удалён', description: 'Курс удалён администратором' });
|
||||
await loadCourses();
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserRole = async (userId: string, role: 'USER' | 'MODERATOR' | 'ADMIN') => {
|
||||
try {
|
||||
await api.updateAdminUserRole(userId, role);
|
||||
toast({ title: 'Роль обновлена', description: `Новая роль: ${role}` });
|
||||
await loadUsers();
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message || 'Не удалось изменить роль', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl border bg-background p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Админ Панель</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Модерация курсов, поддержка, управление пользователями и платежами.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/support">
|
||||
<MessageCircle className="mr-2 h-4 w-4" />
|
||||
Тикеты поддержки
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-wrap gap-2">
|
||||
<Button variant={activeTab === 'courses' ? 'default' : 'outline'} onClick={() => setActiveTab('courses')}>
|
||||
Курсы
|
||||
</Button>
|
||||
<Button variant={activeTab === 'users' ? 'default' : 'outline'} onClick={() => setActiveTab('users')}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Пользователи
|
||||
</Button>
|
||||
<Button variant={activeTab === 'payments' ? 'default' : 'outline'} onClick={() => setActiveTab('payments')}>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Платежи
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{activeTab === 'courses' ? (
|
||||
<>
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Всего в выдаче</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold">{stats.total}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Ожидают модерации</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold">{stats.pending}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Опубликовано</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold">{stats.published}</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3 rounded-2xl border bg-card p-4 md:flex-row">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по курсам и авторам"
|
||||
className="h-10 w-full rounded-xl border bg-background pl-10 pr-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="h-10 rounded-xl border bg-background px-3 text-sm"
|
||||
>
|
||||
{statusFilters.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button onClick={loadCourses} disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Применить
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
{courses.map((course) => (
|
||||
<Card key={course.id} className="border-border/60">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{course.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{course.author?.name || 'Без имени'} ({course.author?.email || '—'})
|
||||
</CardDescription>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||
badgeMap[course.status] || 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{course.status}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<p>Глав: {course._count?.chapters || 0}</p>
|
||||
<p>Студентов: {course._count?.enrollments || 0}</p>
|
||||
<p>Отзывов: {course._count?.reviews || 0}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={noteDraft[course.id] || ''}
|
||||
onChange={(e) => setNoteDraft((prev) => ({ ...prev, [course.id]: e.target.value }))}
|
||||
placeholder="Комментарий модерации"
|
||||
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/courses/${course.id}`} target="_blank">
|
||||
Preview
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW' ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => approve(course.id)} disabled={actingId === course.id}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Опубликовать
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => reject(course.id)}
|
||||
disabled={actingId === course.id}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeCourse(course.id)}
|
||||
disabled={actingId === course.id}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить курс
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!loading && courses.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||
Курсы по заданным фильтрам не найдены.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'users' ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={usersSearch}
|
||||
onChange={(e) => setUsersSearch(e.target.value)}
|
||||
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
|
||||
placeholder="Поиск пользователя"
|
||||
/>
|
||||
<Button onClick={loadUsers} disabled={usersLoading}>
|
||||
{usersLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{adminUsers.map((user) => (
|
||||
<Card key={user.id}>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<p className="font-medium">{user.name || user.email}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email} • {user.subscriptionTier || 'FREE'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => updateUserRole(user.id, e.target.value as any)}
|
||||
className="h-9 rounded-lg border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="MODERATOR">MODERATOR</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!usersLoading && adminUsers.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">Пользователи не найдены.</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'payments' ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={paymentSearch}
|
||||
onChange={(e) => setPaymentSearch(e.target.value)}
|
||||
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
|
||||
placeholder="Поиск по курсу / пользователю"
|
||||
/>
|
||||
<select
|
||||
value={paymentMode}
|
||||
onChange={(e) => setPaymentMode(e.target.value)}
|
||||
className="h-10 rounded-xl border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Все режимы</option>
|
||||
<option value="DEV">DEV</option>
|
||||
<option value="PROD">PROD</option>
|
||||
</select>
|
||||
<Button onClick={loadPayments} disabled={paymentsLoading}>
|
||||
{paymentsLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Применить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{payments.map((payment) => (
|
||||
<Card key={payment.id}>
|
||||
<CardContent className="space-y-2 p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">{payment.course?.title || 'Курс удалён'}</p>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
{payment.mode} • {payment.provider}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{payment.user?.name || payment.user?.email} • {payment.amount} {payment.currency} • {payment.status}
|
||||
</p>
|
||||
{payment.eventCode ? (
|
||||
<p className="text-xs text-muted-foreground">Событие: {payment.eventCode}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!paymentsLoading && payments.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">Платежи не найдены.</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
apps/web/src/app/admin/support/page.tsx
Normal file
188
apps/web/src/app/admin/support/page.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { ChevronLeft, Loader2, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminSupportPage() {
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [status, setStatus] = useState('in_progress');
|
||||
const [message, setMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||
[tickets, selectedTicketId]
|
||||
);
|
||||
|
||||
const loadTickets = async () => {
|
||||
setLoading(true);
|
||||
const data = await api.getAdminSupportTickets().catch(() => []);
|
||||
setTickets(data);
|
||||
if (!selectedTicketId && data.length > 0) {
|
||||
setSelectedTicketId(data[0].id);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTickets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTicketId) return;
|
||||
api
|
||||
.getAdminSupportTicketMessages(selectedTicketId)
|
||||
.then((data) => setMessages(data))
|
||||
.catch(() => setMessages([]));
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('support:join', { ticketId: selectedTicketId });
|
||||
socket.on('support:new-message', (msg: any) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
});
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [selectedTicketId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
setStatus(selected.status || 'in_progress');
|
||||
}, [selected?.id]);
|
||||
|
||||
const sendReply = async () => {
|
||||
if (!selectedTicketId || !message.trim()) return;
|
||||
setSending(true);
|
||||
await api.sendAdminSupportMessage(selectedTicketId, message.trim());
|
||||
setMessage('');
|
||||
const list = await api.getAdminSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||
setMessages(list);
|
||||
await loadTickets();
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const updateStatus = async () => {
|
||||
if (!selectedTicketId) return;
|
||||
setSending(true);
|
||||
await api.updateAdminSupportTicketStatus(selectedTicketId, status);
|
||||
await loadTickets();
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Тикеты поддержки</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
К модерации
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Очередь тикетов</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
className={cn(
|
||||
'w-full rounded-xl border px-3 py-2 text-left text-sm transition',
|
||||
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => setSelectedTicketId(ticket.id)}
|
||||
>
|
||||
<p className="font-medium">{ticket.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ticket.user?.name || ticket.user?.email} • {ticket.status}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[560px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{selected ? `Тикет: ${selected.title}` : 'Выберите тикет'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[470px] flex-col">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<select
|
||||
className="h-9 rounded-lg border bg-background px-3 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
disabled={!selectedTicketId}
|
||||
>
|
||||
<option value="open">open</option>
|
||||
<option value="in_progress">in_progress</option>
|
||||
<option value="resolved">resolved</option>
|
||||
<option value="closed">closed</option>
|
||||
</select>
|
||||
<Button variant="outline" onClick={updateStatus} disabled={!selectedTicketId || sending}>
|
||||
Обновить статус
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-auto pr-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={cn('flex', msg.isStaff ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[75%] rounded-2xl px-3 py-2 text-sm',
|
||||
msg.isStaff ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Пользователь'}</p>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Ответ поддержки"
|
||||
className="h-10 flex-1 rounded-lg border bg-background px-3 text-sm"
|
||||
/>
|
||||
<Button onClick={sendReply} disabled={!selectedTicketId || sending}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
apps/web/src/app/cooperation/page.tsx
Normal file
132
apps/web/src/app/cooperation/page.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/landing/header';
|
||||
import { Footer } from '@/components/landing/footer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function CooperationPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
organization: '',
|
||||
contactName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: '',
|
||||
organizationType: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const result = await api.submitCooperationRequest({
|
||||
organization: form.organization,
|
||||
contactName: form.contactName,
|
||||
email: form.email,
|
||||
phone: form.phone || undefined,
|
||||
role: form.role || undefined,
|
||||
organizationType: form.organizationType || undefined,
|
||||
message: form.message,
|
||||
});
|
||||
setSuccess(result?.status === 'stored_and_sent' ? 'Заявка отправлена. Мы свяжемся с вами.' : 'Заявка сохранена. Мы свяжемся с вами по почте.');
|
||||
setForm({
|
||||
organization: '',
|
||||
contactName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: '',
|
||||
organizationType: '',
|
||||
message: '',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 container py-10 space-y-6">
|
||||
<section className="rounded-3xl border border-border/50 bg-gradient-to-br from-primary/10 via-background to-primary/5 p-6">
|
||||
<h1 className="text-3xl font-bold">Сотрудничество</h1>
|
||||
<p className="mt-2 text-muted-foreground max-w-3xl">
|
||||
Предоставляем платформу для вузов, школ, колледжей и компаний по договорённости:
|
||||
запуск внутренних академий, каталогов курсов, трекинг прогресса и поддержка авторов.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Что можем предоставить</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>1. White-label платформу с вашей айдентикой.</p>
|
||||
<p>2. Инструменты для авторов и методистов.</p>
|
||||
<p>3. Проверку контента, модерацию и аналитику обучения.</p>
|
||||
<p>4. Корпоративные группы, чаты и домашние задания.</p>
|
||||
<p>5. Интеграцию с процессами вашей организации.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Оставить заявку</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<input
|
||||
value={form.organization}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, organization: e.target.value }))}
|
||||
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||
placeholder="Организация"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
value={form.contactName}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, contactName: e.target.value }))}
|
||||
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||
placeholder="Контактное лицо"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||
placeholder="Email"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
|
||||
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||
placeholder="Телефон (необязательно)"
|
||||
/>
|
||||
<textarea
|
||||
value={form.message}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
|
||||
className="min-h-[140px] w-full rounded-lg border bg-background p-3 text-sm"
|
||||
placeholder="Опишите задачу и масштаб внедрения"
|
||||
required
|
||||
/>
|
||||
<Button className="w-full" disabled={loading}>
|
||||
{loading ? 'Отправка...' : 'Отправить заявку'}
|
||||
</Button>
|
||||
{success ? <p className="text-xs text-emerald-600">{success}</p> : null}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
278
apps/web/src/app/courses/[id]/page.tsx
Normal file
278
apps/web/src/app/courses/[id]/page.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft, BookOpen, Check, ChevronDown, Clock, Loader2, Shield, Star, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Header } from '@/components/landing/header';
|
||||
import { Footer } from '@/components/landing/footer';
|
||||
|
||||
export default function PublicCoursePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const id = params?.id as string;
|
||||
|
||||
const [course, setCourse] = useState<any>(null);
|
||||
const [reviews, setReviews] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enrolling, setEnrolling] = useState(false);
|
||||
const [enrolled, setEnrolled] = useState(false);
|
||||
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||
const isDevPayment = searchParams?.get('devPayment') === '1';
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [courseData, reviewsData] = await Promise.all([
|
||||
api.getPublicCourse(id),
|
||||
api.getCourseReviews(id).catch(() => ({ data: [] })),
|
||||
]);
|
||||
setCourse(courseData);
|
||||
setReviews(reviewsData.data || []);
|
||||
|
||||
if (user) {
|
||||
const enrollments = await api.getMyEnrollments().catch(() => []);
|
||||
setEnrolled(enrollments.some((item: any) => item.course.id === id));
|
||||
}
|
||||
} catch {
|
||||
setCourse(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [id, user]);
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!id || enrolling) return;
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setEnrolling(true);
|
||||
try {
|
||||
if (course?.price) {
|
||||
const session = await api.checkoutCourse(id);
|
||||
if (session?.url) {
|
||||
window.location.href = session.url;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await api.enrollInCourse(id);
|
||||
toast({ title: 'Готово', description: 'Вы записались на курс' });
|
||||
router.push(`/dashboard/courses/${id}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setEnrolling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex min-h-[420px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!course) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="container py-20 text-center flex-1">
|
||||
<p className="text-muted-foreground">Курс не найден</p>
|
||||
<Link href="/courses" className="mt-3 inline-block text-sm text-primary hover:underline">
|
||||
Вернуться в Курсы
|
||||
</Link>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalLessons = course.chapters.reduce((acc: number, chapter: any) => acc + chapter.lessons.length, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="container max-w-6xl space-y-6 py-8 flex-1">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/courses">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Назад к курсам
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-5 md:col-span-2">
|
||||
<div className="aspect-video overflow-hidden rounded-2xl bg-gradient-to-br from-amber-100 via-background to-cyan-100">
|
||||
{course.coverImage ? (
|
||||
<img src={course.coverImage} alt={course.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<BookOpen className="h-12 w-12 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{course.isVerified ? (
|
||||
<span className="group relative inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
|
||||
<Shield className="h-3 w-3" />
|
||||
Проверен автором
|
||||
<span className="pointer-events-none absolute -bottom-9 left-0 hidden w-56 rounded-md bg-black/85 px-2 py-1 text-[10px] font-normal leading-snug text-white shadow-lg group-hover:block">
|
||||
Автор подтвердил корректность и актуальность материала.
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{course.difficulty ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{course.difficulty === 'beginner'
|
||||
? 'Начинающий'
|
||||
: course.difficulty === 'intermediate'
|
||||
? 'Средний'
|
||||
: 'Продвинутый'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{course.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{course.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{course._count.enrollments} студентов
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-4 w-4" />
|
||||
{course.averageRating ? `${course.averageRating.toFixed(1)} (${course._count.reviews})` : 'Без рейтинга'}
|
||||
</span>
|
||||
<span>{totalLessons} уроков</span>
|
||||
{course.estimatedHours ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{course.estimatedHours}ч
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Содержание курса</h2>
|
||||
<div className="space-y-2">
|
||||
{course.chapters.map((chapter: any) => {
|
||||
const expanded = expandedChapters.includes(chapter.id);
|
||||
return (
|
||||
<div key={chapter.id} className="overflow-hidden rounded-xl border">
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedChapters((prev) =>
|
||||
prev.includes(chapter.id) ? prev.filter((id: string) => id !== chapter.id) : [...prev, chapter.id]
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50"
|
||||
>
|
||||
<p className="font-medium">{chapter.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{chapter.lessons.length} уроков</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition', expanded && 'rotate-180')} />
|
||||
</div>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="border-t bg-muted/20 p-2">
|
||||
{chapter.lessons.map((lesson: any) => (
|
||||
<div key={lesson.id} className="flex items-center gap-2 px-2 py-2 text-sm">
|
||||
<Check className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{lesson.title}</span>
|
||||
{lesson.durationMinutes ? (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{lesson.durationMinutes} мин</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="h-fit md:sticky md:top-6">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<p className="text-3xl font-bold">
|
||||
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
||||
</p>
|
||||
{isDevPayment ? (
|
||||
<span className="inline-flex w-fit items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-900">
|
||||
DEV Payment
|
||||
</span>
|
||||
) : null}
|
||||
{enrolled ? (
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={`/dashboard/courses/${id}`}>Перейти к обучению</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
|
||||
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{course.price ? 'Купить курс' : 'Записаться'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-600" />
|
||||
<span>{course.chapters.length} глав</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-600" />
|
||||
<span>{totalLessons} уроков</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-600" />
|
||||
<span>Сертификат по окончании</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{reviews.length ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<h2 className="text-lg font-semibold">Отзывы</h2>
|
||||
{reviews.map((review: any) => (
|
||||
<div key={review.id} className="border-b pb-3 last:border-b-0">
|
||||
<p className="text-sm font-medium">{review.user.name || 'Пользователь'}</p>
|
||||
<p className="text-xs text-muted-foreground">Оценка: {review.rating}/5</p>
|
||||
{review.title ? <p className="mt-1 text-sm font-medium">{review.title}</p> : null}
|
||||
{review.content ? <p className="mt-1 text-sm text-muted-foreground">{review.content}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/web/src/app/courses/page.tsx
Normal file
185
apps/web/src/app/courses/page.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Star, Users, BookOpen, Shield, Loader2 } from 'lucide-react';
|
||||
import { Header } from '@/components/landing/header';
|
||||
import { Footer } from '@/components/landing/footer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CatalogCourse {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
coverImage: string | null;
|
||||
difficulty: string | null;
|
||||
price: number | null;
|
||||
currency: string;
|
||||
isVerified: boolean;
|
||||
averageRating: number | null;
|
||||
enrollmentCount: number;
|
||||
reviewCount: number;
|
||||
chaptersCount: number;
|
||||
lessonsCount: number;
|
||||
author: { id: string; name: string | null; avatarUrl: string | null };
|
||||
}
|
||||
|
||||
const difficultyLabels: Record<string, { label: string; color: string }> = {
|
||||
beginner: { label: 'Начинающий', color: 'text-green-600 bg-green-50 dark:bg-green-900/30' },
|
||||
intermediate: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30' },
|
||||
advanced: { label: 'Продвинутый', color: 'text-red-600 bg-red-50 dark:bg-red-900/30' },
|
||||
};
|
||||
|
||||
export default function CoursesPage() {
|
||||
const [courses, setCourses] = useState<CatalogCourse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [difficulty, setDifficulty] = useState('');
|
||||
|
||||
const loadCourses = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.getCatalog({
|
||||
search: searchQuery || undefined,
|
||||
difficulty: difficulty || undefined,
|
||||
});
|
||||
setCourses(result.data);
|
||||
} catch {
|
||||
setCourses([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCourses();
|
||||
}, [difficulty]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
loadCourses();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 container py-10 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Курсы</h1>
|
||||
<p className="text-muted-foreground mt-1">Каталог доступных курсов для обучения</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск курсов..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Найти</Button>
|
||||
</form>
|
||||
<div className="flex gap-2">
|
||||
{['', 'beginner', 'intermediate', 'advanced'].map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant={difficulty === d ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setDifficulty(d)}
|
||||
>
|
||||
{d ? difficultyLabels[d]?.label : 'Все'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : courses.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground">Пока нет опубликованных курсов</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{courses.map((course) => (
|
||||
<Link key={course.id} href={`/courses/${course.id}`}>
|
||||
<Card className="group overflow-hidden transition-all hover:shadow-lg hover:-translate-y-0.5 cursor-pointer h-full">
|
||||
<div className="aspect-video bg-gradient-to-br from-primary/20 to-primary/5 relative overflow-hidden">
|
||||
{course.coverImage ? (
|
||||
<img src={course.coverImage} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<BookOpen className="h-10 w-10 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
{course.isVerified && (
|
||||
<div className="group absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
|
||||
<Shield className="h-3 w-3" />
|
||||
Проверен автором
|
||||
<span className="pointer-events-none absolute -bottom-9 right-0 hidden w-56 rounded-md bg-black/85 px-2 py-1 text-[10px] font-normal leading-snug text-white shadow-lg group-hover:block">
|
||||
Автор подтвердил корректность и актуальность материала.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{course.difficulty && difficultyLabels[course.difficulty] && (
|
||||
<div className={cn(
|
||||
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
difficultyLabels[course.difficulty].color
|
||||
)}>
|
||||
{difficultyLabels[course.difficulty].label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold line-clamp-1 group-hover:text-primary transition-colors">
|
||||
{course.title}
|
||||
</h3>
|
||||
{course.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">{course.description}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
{course.author.name || 'Автор'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
||||
{course.averageRating && (
|
||||
<span className="flex items-center gap-0.5 text-yellow-600 font-medium">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{course.averageRating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{course.enrollmentCount}
|
||||
</span>
|
||||
<span>{course.lessonsCount} уроков</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<span className="font-bold text-lg">
|
||||
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -104,3 +104,58 @@ html {
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
/* ─── Course content typography ─── */
|
||||
.prose-course h1 {
|
||||
@apply text-3xl font-bold mt-10 mb-4 pb-3 border-b border-border text-foreground;
|
||||
}
|
||||
.prose-course h2 {
|
||||
@apply text-2xl font-semibold mt-8 mb-3 text-foreground;
|
||||
}
|
||||
.prose-course h3 {
|
||||
@apply text-xl font-semibold mt-6 mb-2 text-foreground;
|
||||
}
|
||||
.prose-course p {
|
||||
@apply leading-7 mb-4 text-foreground/90;
|
||||
}
|
||||
.prose-course ul {
|
||||
@apply list-disc pl-6 mb-4 space-y-1;
|
||||
}
|
||||
.prose-course ol {
|
||||
@apply list-decimal pl-6 mb-4 space-y-1;
|
||||
}
|
||||
.prose-course li {
|
||||
@apply leading-7 text-foreground/90;
|
||||
}
|
||||
.prose-course li p {
|
||||
@apply mb-1;
|
||||
}
|
||||
.prose-course blockquote {
|
||||
@apply border-l-4 border-primary/50 bg-primary/5 pl-4 py-3 my-4 rounded-r-lg italic text-foreground/80;
|
||||
}
|
||||
.prose-course pre {
|
||||
@apply rounded-xl bg-muted p-5 font-mono text-sm border my-4 overflow-x-auto;
|
||||
}
|
||||
.prose-course code:not(pre code) {
|
||||
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-primary;
|
||||
}
|
||||
.prose-course hr {
|
||||
@apply my-8 border-border;
|
||||
}
|
||||
.prose-course a {
|
||||
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
|
||||
}
|
||||
.prose-course img {
|
||||
@apply rounded-xl my-6 max-w-full shadow-sm;
|
||||
}
|
||||
.prose-course strong {
|
||||
@apply font-semibold text-foreground;
|
||||
}
|
||||
|
||||
/* ProseMirror specific overrides for course content */
|
||||
.prose-course .ProseMirror {
|
||||
@apply outline-none;
|
||||
}
|
||||
.prose-course .ProseMirror > *:first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
128
apps/web/src/app/staff/login/page.tsx
Normal file
128
apps/web/src/app/staff/login/page.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Lock, Mail, ShieldCheck } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
export default function StaffLoginPage() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { signIn, backendUser, loading } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [awaitingRole, setAwaitingRole] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!awaitingRole || loading) return;
|
||||
|
||||
if (backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Нет доступа',
|
||||
description: 'Этот вход доступен только администраторам и модераторам',
|
||||
variant: 'destructive',
|
||||
});
|
||||
router.push('/dashboard');
|
||||
}, [awaitingRole, loading, backendUser, router, toast]);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Ошибка входа',
|
||||
description: error.message || 'Проверьте логин и пароль',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAwaitingRole(true);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-background to-amber-50/40">
|
||||
<div className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-md border-border/60 shadow-xl">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<ShieldCheck className="h-6 w-6" />
|
||||
</div>
|
||||
<CardTitle>Вход для staff</CardTitle>
|
||||
<CardDescription>Отдельная авторизация для админов и модераторов</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="staff-email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="staff-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10"
|
||||
placeholder="admin@company.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="staff-password">Пароль</Label>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="staff-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button type="submit" className="w-full" disabled={submitting || awaitingRole}>
|
||||
{submitting || awaitingRole ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
'Войти в staff'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Обычный вход для пользователей: <Link href="/login" className="text-primary hover:underline">/login</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -33,7 +33,15 @@ interface CourseCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: 'DRAFT' | 'GENERATING' | 'PUBLISHED' | 'ARCHIVED';
|
||||
status:
|
||||
| 'DRAFT'
|
||||
| 'GENERATING'
|
||||
| 'PENDING_MODERATION'
|
||||
| 'PENDING_REVIEW'
|
||||
| 'APPROVED'
|
||||
| 'PUBLISHED'
|
||||
| 'REJECTED'
|
||||
| 'ARCHIVED';
|
||||
chaptersCount: number;
|
||||
lessonsCount: number;
|
||||
updatedAt: string;
|
||||
@ -55,6 +63,22 @@ const statusConfig = {
|
||||
label: 'Опубликован',
|
||||
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
},
|
||||
PENDING_REVIEW: {
|
||||
label: 'На модерации',
|
||||
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
},
|
||||
PENDING_MODERATION: {
|
||||
label: 'На модерации',
|
||||
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
},
|
||||
APPROVED: {
|
||||
label: 'Одобрен',
|
||||
className: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
|
||||
},
|
||||
REJECTED: {
|
||||
label: 'Отклонён',
|
||||
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: 'Архив',
|
||||
className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
|
||||
106
apps/web/src/components/dashboard/course-chat.tsx
Normal file
106
apps/web/src/components/dashboard/course-chat.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MessageCircle, Send } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string | null; avatarUrl: string | null };
|
||||
}
|
||||
|
||||
interface CourseChatProps {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
onSendMessage: (content: string) => Promise<void>;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export function CourseChat({ groupId, userId, onSendMessage, messages }: CourseChatProps) {
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const groupedMessages = useMemo(() => messages, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [groupedMessages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!newMessage.trim() || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await onSendMessage(newMessage.trim());
|
||||
setNewMessage('');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex h-[560px] flex-col overflow-hidden border-border/60">
|
||||
<CardHeader className="border-b bg-muted/25 pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span className="flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Чат курса
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">Группа {groupId.slice(0, 6)}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<div className="mb-3 flex-1 space-y-3 overflow-auto pr-2">
|
||||
{groupedMessages.map((msg) => {
|
||||
const isOwn = msg.user.id === userId;
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex', isOwn ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('max-w-[80%] space-y-1', isOwn ? 'items-end' : 'items-start')}>
|
||||
<p className="text-xs text-muted-foreground">{msg.user.name || 'Участник'}</p>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl px-3 py-2 text-sm shadow-sm',
|
||||
isOwn
|
||||
? 'rounded-br-md bg-primary text-primary-foreground'
|
||||
: 'rounded-bl-md border bg-card'
|
||||
)}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{groupedMessages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Сообщений пока нет. Начните обсуждение.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t pt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||
placeholder="Написать сообщение..."
|
||||
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button size="sm" className="h-10 rounded-xl px-4" onClick={handleSend} disabled={sending || !newMessage.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Bell, Menu, LogOut, Settings, CreditCard } from 'lucide-react';
|
||||
import { Bell, Menu, LogOut, Settings, CreditCard, ShieldCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
@ -15,7 +15,8 @@ import {
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
export function DashboardHeader() {
|
||||
const { user, signOut } = useAuth();
|
||||
const { user, signOut, backendUser } = useAuth();
|
||||
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
||||
|
||||
const initials = user?.user_metadata?.full_name
|
||||
?.split(' ')
|
||||
@ -24,7 +25,7 @@ export function DashboardHeader() {
|
||||
.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U';
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b px-6">
|
||||
<header className="flex h-16 items-center justify-between border-b border-border/60 bg-background/80 px-6 backdrop-blur">
|
||||
{/* Mobile menu button */}
|
||||
<Button variant="ghost" size="icon" className="lg:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
@ -36,6 +37,14 @@ export function DashboardHeader() {
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{isStaff ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin">
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Админ Панель
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Notifications */}
|
||||
<Button variant="ghost" size="icon">
|
||||
<Bell className="h-5 w-5" />
|
||||
|
||||
154
apps/web/src/components/dashboard/lesson-chat-panel.tsx
Normal file
154
apps/web/src/components/dashboard/lesson-chat-panel.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { ChevronRight, MessageCircle, Minus, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
groupId: string | null;
|
||||
lessonId: string | null;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
export function LessonChatPanel({ groupId, lessonId, userId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId || !lessonId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getGroupMessages(groupId, lessonId)
|
||||
.then((data) => setMessages(data || []))
|
||||
.catch(() => setMessages([]));
|
||||
}, [groupId, lessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/course-groups`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('groups:join', { groupId });
|
||||
socket.on('groups:new-message', (payload: any) => {
|
||||
if (!lessonId) return;
|
||||
if (payload?.lessonId !== lessonId) return;
|
||||
setMessages((prev) => [...prev, payload]);
|
||||
});
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [groupId, lessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, open]);
|
||||
|
||||
const send = async () => {
|
||||
if (!groupId || !lessonId || !message.trim() || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await api.sendGroupMessage(groupId, message.trim(), lessonId);
|
||||
setMessage('');
|
||||
const latest = await api.getGroupMessages(groupId, lessonId).catch(() => []);
|
||||
setMessages(latest || []);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-40">
|
||||
{open ? (
|
||||
<div className="w-[360px] rounded-2xl border border-slate-700/60 bg-slate-950/85 text-slate-100 shadow-2xl backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/20 p-1.5 text-primary">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Чат урока</p>
|
||||
<p className="text-xs text-slate-300">Контекст текущего урока</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-slate-200 hover:bg-slate-800"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[420px] flex-col">
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{messages.map((item) => {
|
||||
const own = item.user?.id === userId;
|
||||
return (
|
||||
<div key={item.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[82%] rounded-2xl px-3 py-2 text-sm',
|
||||
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-slate-800 text-slate-100'
|
||||
)}
|
||||
>
|
||||
<p className="mb-1 text-xs opacity-80">{item.user?.name || 'Участник'}</p>
|
||||
<p>{item.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-center text-xs text-slate-400">Сообщений по этому уроку пока нет</p>
|
||||
) : null}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-700/60 p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
className="h-10 flex-1 rounded-lg border border-slate-700 bg-slate-900 px-3 text-sm text-slate-100 placeholder:text-slate-400"
|
||||
placeholder="Написать в чат урока"
|
||||
/>
|
||||
<Button size="icon" className="h-10 w-10" onClick={send} disabled={sending}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="h-12 rounded-full px-4 shadow-lg"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={!groupId || !lessonId}
|
||||
>
|
||||
<MessageCircle className="mr-2 h-4 w-4" />
|
||||
Чат урока
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
158
apps/web/src/components/dashboard/lesson-quiz.tsx
Normal file
158
apps/web/src/components/dashboard/lesson-quiz.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, XCircle, Trophy, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuizQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
correctAnswer: number;
|
||||
}
|
||||
|
||||
interface QuizSubmitResult {
|
||||
score: number;
|
||||
passed: boolean;
|
||||
correctAnswers?: number;
|
||||
totalQuestions?: number;
|
||||
}
|
||||
|
||||
interface LessonQuizProps {
|
||||
courseId: string;
|
||||
lessonId: string;
|
||||
questions: QuizQuestion[];
|
||||
onComplete: (answers: number[]) => Promise<QuizSubmitResult>;
|
||||
}
|
||||
|
||||
export function LessonQuiz({ questions, onComplete }: LessonQuizProps) {
|
||||
const [currentQuestion, setCurrentQuestion] = useState(0);
|
||||
const [answers, setAnswers] = useState<number[]>([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [serverResult, setServerResult] = useState<QuizSubmitResult | null>(null);
|
||||
|
||||
const handleSelectAnswer = (optionIndex: number) => {
|
||||
setSelectedAnswer(optionIndex);
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (selectedAnswer === null || submitting) return;
|
||||
const newAnswers = [...answers, selectedAnswer];
|
||||
setAnswers(newAnswers);
|
||||
|
||||
if (currentQuestion < questions.length - 1) {
|
||||
setCurrentQuestion(currentQuestion + 1);
|
||||
setSelectedAnswer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await onComplete(newAnswers);
|
||||
setServerResult(result);
|
||||
setShowResults(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (questions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showResults) {
|
||||
const score = serverResult?.score ?? 0;
|
||||
const passed = Boolean(serverResult?.passed);
|
||||
const correct =
|
||||
serverResult?.correctAnswers ??
|
||||
answers.filter((ans, idx) => ans === questions[idx].correctAnswer).length;
|
||||
const total = serverResult?.totalQuestions ?? questions.length;
|
||||
|
||||
return (
|
||||
<Card className="mt-8">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto h-16 w-16 rounded-full flex items-center justify-center mb-4',
|
||||
passed ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
)}
|
||||
>
|
||||
{passed ? (
|
||||
<Trophy className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-8 w-8 text-yellow-600 dark:text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">{passed ? 'Тест пройден' : 'Тест не пройден'}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Ваш результат: {correct} из {total} ({score}%)
|
||||
</p>
|
||||
<div className="space-y-2 max-w-md mx-auto">
|
||||
{questions.map((q, idx) => {
|
||||
const isCorrect = answers[idx] === q.correctAnswer;
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded text-sm',
|
||||
isCorrect ? 'bg-green-50 dark:bg-green-900/30' : 'bg-red-50 dark:bg-red-900/30'
|
||||
)}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span>Вопрос {idx + 1}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const question = questions[currentQuestion];
|
||||
|
||||
return (
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Тест по уроку ({currentQuestion + 1}/{questions.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="font-medium">{question.question}</p>
|
||||
<div className="grid gap-2">
|
||||
{question.options.map((option, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSelectAnswer(idx)}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border text-left transition-colors',
|
||||
selectedAnswer === idx ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={handleNext} disabled={selectedAnswer === null || submitting} className="w-full">
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Проверка...
|
||||
</>
|
||||
) : currentQuestion < questions.length - 1 ? (
|
||||
'Следующий вопрос'
|
||||
) : (
|
||||
'Завершить тест'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -5,28 +5,38 @@ import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
Settings,
|
||||
CreditCard,
|
||||
Plus,
|
||||
Search,
|
||||
LifeBuoy,
|
||||
Globe2,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen },
|
||||
{ name: 'Поиск', href: '/dashboard/search', icon: Search },
|
||||
{ name: 'Мои курсы', href: '/dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
|
||||
{ name: 'Курсы', href: '/courses', icon: Globe2, external: true },
|
||||
];
|
||||
|
||||
const bottomNavigation = [
|
||||
{ name: 'Поддержка', href: '/dashboard/support', icon: LifeBuoy },
|
||||
{ name: 'Настройки', href: '/dashboard/settings', icon: Settings },
|
||||
{ name: 'Подписка', href: '/dashboard/billing', icon: CreditCard },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { backendUser } = useAuth();
|
||||
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
||||
|
||||
const effectiveBottomNavigation = isStaff
|
||||
? [{ name: 'Админ Панель', href: '/admin', icon: ShieldCheck }, ...bottomNavigation]
|
||||
: bottomNavigation;
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:border-r lg:bg-muted/30">
|
||||
@ -53,7 +63,9 @@ export function Sidebar() {
|
||||
{/* Main navigation */}
|
||||
<nav className="flex-1 px-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const isActive = (item as any).exact
|
||||
? pathname === item.href
|
||||
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
@ -74,7 +86,7 @@ export function Sidebar() {
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<nav className="p-4 border-t space-y-1">
|
||||
{bottomNavigation.map((item) => {
|
||||
{effectiveBottomNavigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
|
||||
273
apps/web/src/components/dashboard/support-widget.tsx
Normal file
273
apps/web/src/components/dashboard/support-widget.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { ChevronLeft, LifeBuoy, MessageCircle, Minus, Plus, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import { getWsBaseUrl } from '@/lib/ws';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Ticket = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type TicketMessage = {
|
||||
id: string;
|
||||
content: string;
|
||||
isStaff: boolean;
|
||||
createdAt: string;
|
||||
user?: { id?: string; name?: string | null };
|
||||
};
|
||||
|
||||
export function SupportWidget() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TicketMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [ticketTitle, setTicketTitle] = useState('');
|
||||
const [ticketText, setTicketText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const selectedTicket = useMemo(
|
||||
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||
[tickets, selectedTicketId]
|
||||
);
|
||||
|
||||
const loadTickets = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getMySupportTickets().catch(() => []);
|
||||
setTickets(data || []);
|
||||
if (!selectedTicketId && data?.length) {
|
||||
setSelectedTicketId(data[0].id);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
loadTickets();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedTicketId) return;
|
||||
|
||||
api
|
||||
.getSupportTicketMessages(selectedTicketId)
|
||||
.then((data) => setMessages(data || []))
|
||||
.catch(() => setMessages([]));
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token },
|
||||
});
|
||||
socketRef.current = socket;
|
||||
socket.emit('support:join', { ticketId: selectedTicketId });
|
||||
socket.on('support:new-message', (msg: TicketMessage) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [open, selectedTicketId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, selectedTicketId]);
|
||||
|
||||
const createTicket = async () => {
|
||||
if (!ticketTitle.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await api.createSupportTicket({
|
||||
title: ticketTitle.trim(),
|
||||
initialMessage: ticketText.trim() || undefined,
|
||||
});
|
||||
setTicketTitle('');
|
||||
setTicketText('');
|
||||
setCreating(false);
|
||||
await loadTickets();
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!selectedTicketId || !newMessage.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await api.sendSupportMessage(selectedTicketId, newMessage.trim());
|
||||
setNewMessage('');
|
||||
const latest = await api.getSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||
setMessages(latest || []);
|
||||
await loadTickets();
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-40">
|
||||
{open ? (
|
||||
<div className="w-[360px] rounded-2xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/15 p-1.5 text-primary">
|
||||
<LifeBuoy className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Поддержка</p>
|
||||
<p className="text-xs text-muted-foreground">Тикеты и ответы</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => setOpen(false)}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedTicketId || creating ? (
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Новый тикет</p>
|
||||
{!creating ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Создать
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>
|
||||
Назад
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={ticketTitle}
|
||||
onChange={(e) => setTicketTitle(e.target.value)}
|
||||
placeholder="Тема тикета"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={ticketText}
|
||||
onChange={(e) => setTicketText(e.target.value)}
|
||||
placeholder="Опишите вопрос"
|
||||
className="min-h-[110px] w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button size="sm" className="w-full" disabled={sending} onClick={createTicket}>
|
||||
Отправить тикет
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Мои тикеты
|
||||
</p>
|
||||
<div className="max-h-[260px] space-y-2 overflow-auto pr-1">
|
||||
{loading ? <p className="text-xs text-muted-foreground">Загрузка...</p> : null}
|
||||
{tickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicketId(ticket.id)}
|
||||
className="w-full rounded-xl border px-3 py-2 text-left transition hover:bg-muted/60"
|
||||
>
|
||||
<p className="line-clamp-1 text-sm font-medium">{ticket.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Статус: {ticket.status}</p>
|
||||
</button>
|
||||
))}
|
||||
{!loading && tickets.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Пока нет тикетов</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[500px] flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setSelectedTicketId(null)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{selectedTicket?.title}</p>
|
||||
<p className="text-xs text-muted-foreground">Статус: {selectedTicket?.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn('flex', msg.isStaff ? 'justify-start' : 'justify-end')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-2xl px-3 py-2 text-sm',
|
||||
msg.isStaff ? 'bg-muted text-foreground' : 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
<MessageCircle className="mr-2 h-4 w-4" />
|
||||
Сообщений пока нет
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Ваш ответ"
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button size="icon" className="h-9 w-9" onClick={sendMessage} disabled={sending}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full shadow-lg"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Открыть поддержку"
|
||||
>
|
||||
<LifeBuoy className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import mermaid from 'mermaid';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
|
||||
const emptyDoc = { type: 'doc', content: [] };
|
||||
@ -27,22 +28,21 @@ export function LessonContentViewer({ content, className }: LessonContentViewerP
|
||||
HTMLAttributes: (node: { attrs: { language?: string } }) =>
|
||||
node.attrs.language === 'mermaid'
|
||||
? { class: 'mermaid rounded-lg p-4 bg-muted min-h-[80px]', 'data-language': 'mermaid' }
|
||||
: { class: 'rounded-lg bg-muted p-4 font-mono text-sm', 'data-language': node.attrs.language || '' },
|
||||
: { class: 'rounded-xl bg-muted p-5 font-mono text-sm border', 'data-language': node.attrs.language || '' },
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
HTMLAttributes: { class: 'text-primary underline underline-offset-2' },
|
||||
HTMLAttributes: { class: 'text-primary underline underline-offset-2 hover:text-primary/80 transition-colors' },
|
||||
}),
|
||||
Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full h-auto' } }),
|
||||
Image.configure({ HTMLAttributes: { class: 'rounded-xl max-w-full h-auto shadow-sm my-6' } }),
|
||||
],
|
||||
content: content ?? emptyDoc,
|
||||
editable: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'prose prose-lg dark:prose-invert max-w-none focus:outline-none leading-relaxed [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-foreground [&_.ProseMirror_p]:leading-7 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm [&_.ProseMirror_pre]:border [&_.ProseMirror_blockquote]:border-primary [&_.ProseMirror_blockquote]:bg-muted/30',
|
||||
class: 'outline-none text-foreground',
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -63,7 +63,7 @@ export function LessonContentViewer({ content, className }: LessonContentViewerP
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div ref={containerRef} className={cn('prose-course', className)}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,9 +3,11 @@ import { Sparkles } from 'lucide-react';
|
||||
|
||||
const navigation = {
|
||||
product: [
|
||||
{ name: 'Возможности', href: '#features' },
|
||||
{ name: 'Тарифы', href: '#pricing' },
|
||||
{ name: 'FAQ', href: '#faq' },
|
||||
{ name: 'Курсы', href: '/courses' },
|
||||
{ name: 'Сотрудничество', href: '/cooperation' },
|
||||
{ name: 'Возможности', href: '/#features' },
|
||||
{ name: 'Тарифы', href: '/#pricing' },
|
||||
{ name: 'FAQ', href: '/#faq' },
|
||||
],
|
||||
company: [
|
||||
{ name: 'О нас', href: '/about' },
|
||||
|
||||
@ -9,10 +9,12 @@ import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Возможности', href: '#features' },
|
||||
{ name: 'Как это работает', href: '#how-it-works' },
|
||||
{ name: 'Тарифы', href: '#pricing' },
|
||||
{ name: 'FAQ', href: '#faq' },
|
||||
{ name: 'Курсы', href: '/courses' },
|
||||
{ name: 'Сотрудничество', href: '/cooperation' },
|
||||
{ name: 'Возможности', href: '/#features' },
|
||||
{ name: 'Как это работает', href: '/#how-it-works' },
|
||||
{ name: 'Тарифы', href: '/#pricing' },
|
||||
{ name: 'FAQ', href: '/#faq' },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
|
||||
@ -1,11 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Zap, BookOpen, Brain } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
export function Hero() {
|
||||
const router = useRouter();
|
||||
const { user, signIn } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [isLoginOpen, setIsLoginOpen] = useState(false);
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [thinkingTick, setThinkingTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setThinkingTick((prev) => (prev + 1) % 3), 900);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
if (user) {
|
||||
setIsLaunching(true);
|
||||
setTimeout(() => router.push('/dashboard/courses/new'), 320);
|
||||
return;
|
||||
}
|
||||
setIsLoginOpen(true);
|
||||
};
|
||||
|
||||
const handleModalLogin = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (signingIn) return;
|
||||
setSigningIn(true);
|
||||
const result = await signIn(email.trim(), password);
|
||||
setSigningIn(false);
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
title: 'Не удалось войти',
|
||||
description: result.error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoginOpen(false);
|
||||
setIsLaunching(true);
|
||||
setTimeout(() => router.push('/dashboard/courses/new'), 320);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden py-20 sm:py-32">
|
||||
{/* Background gradient */}
|
||||
@ -58,17 +107,48 @@ export function Hero() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<Button size="xl" asChild>
|
||||
<Link href="/register">
|
||||
Создать первый курс
|
||||
<Button size="xl" onClick={handleCreateCourse}>
|
||||
Создать курс
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<Link href="#how-it-works">Как это работает</Link>
|
||||
<Link href="/courses">
|
||||
Смотреть курсы
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className={`mx-auto mt-10 w-full max-w-2xl rounded-2xl border border-white/25 bg-white/10 p-4 text-left shadow-xl backdrop-blur-xl transition-all duration-300 ${isLaunching ? 'scale-[1.02] ring-2 ring-primary/30' : ''}`}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.35 }}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="rounded-full border border-white/20 bg-background/40 px-2 py-1">
|
||||
AI Studio
|
||||
</span>
|
||||
<span className="text-primary">
|
||||
thinking{'.'.repeat(thinkingTick + 1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/20 bg-background/70 p-3">
|
||||
<p className="text-sm text-foreground/90">
|
||||
О чём вы хотите создать курс?
|
||||
</p>
|
||||
<div className="mt-2 rounded-lg bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
|
||||
Пример: «Python для аналитиков с нуля до проектов»
|
||||
<span className="ml-1 inline-block h-4 w-[1px] animate-pulse bg-primary align-middle" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Квиз, структура и план модулей формируются автоматически.</span>
|
||||
<Button size="sm" onClick={handleCreateCourse}>
|
||||
Создать первый курс
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
className="mt-16 grid grid-cols-3 gap-8"
|
||||
@ -100,6 +180,54 @@ export function Hero() {
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoginOpen ? (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-background p-5 shadow-2xl">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">Войдите, чтобы продолжить</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
После входа сразу откроется создание курса.
|
||||
</p>
|
||||
</div>
|
||||
<form className="space-y-3" onSubmit={handleModalLogin}>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||
placeholder="Email"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||
placeholder="Пароль"
|
||||
/>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setIsLoginOpen(false)}
|
||||
disabled={signingIn}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={signingIn}>
|
||||
{signingIn ? 'Входим...' : 'Войти'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Нет аккаунта? <Link href="/register" className="text-primary hover:underline">Зарегистрироваться</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,8 +6,18 @@ import { getSupabase } from '@/lib/supabase';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, setApiToken } from '@/lib/api';
|
||||
|
||||
interface BackendUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
subscriptionTier: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
backendUser: BackendUser | null;
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
|
||||
@ -20,6 +30,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [backendUser, setBackendUser] = useState<BackendUser | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
@ -57,16 +68,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setApiToken(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
const tryExchange = () => {
|
||||
api
|
||||
.exchangeToken(session.access_token)
|
||||
.then(({ accessToken }) => {
|
||||
.then(({ accessToken, user: backendUserData }) => {
|
||||
setApiToken(accessToken);
|
||||
setBackendUser(backendUserData);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
attempt++;
|
||||
if (attempt < maxRetries) {
|
||||
// Retry with exponential backoff (500ms, 1500ms, 3500ms)
|
||||
setTimeout(tryExchange, 500 * Math.pow(2, attempt));
|
||||
} else {
|
||||
setApiToken(null);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryExchange();
|
||||
}, [session?.access_token]);
|
||||
|
||||
// Exchange Supabase token for backend JWT; keep loading true until done so API calls wait for JWT
|
||||
@ -138,6 +164,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
backendUser,
|
||||
session,
|
||||
loading,
|
||||
signUp,
|
||||
|
||||
@ -179,6 +179,17 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getLessonQuiz(courseId: string, lessonId: string) {
|
||||
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`);
|
||||
}
|
||||
|
||||
async generateLessonHomework(courseId: string, lessonId: string, type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB') {
|
||||
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/homework/generate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type }),
|
||||
});
|
||||
}
|
||||
|
||||
// Generation
|
||||
async startGeneration(prompt: string) {
|
||||
return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
|
||||
@ -231,6 +242,313 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Catalog (public courses)
|
||||
async getCatalog(params?: { page?: number; limit?: number; search?: string; difficulty?: string }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
|
||||
const query = searchParams.toString();
|
||||
return this.request<{ data: any[]; meta: any }>(`/catalog${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async getPublicCourse(id: string) {
|
||||
return this.request<any>(`/catalog/${id}`);
|
||||
}
|
||||
|
||||
async publishCourse(id: string) {
|
||||
return this.request<any>(`/catalog/${id}/submit`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async checkoutCourse(id: string) {
|
||||
return this.request<{ url: string }>(`/catalog/${id}/checkout`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async toggleCourseVerification(id: string) {
|
||||
return this.request<any>(`/catalog/${id}/verify`, { method: 'PATCH' });
|
||||
}
|
||||
|
||||
// Enrollment & Progress
|
||||
async enrollInCourse(courseId: string) {
|
||||
return this.request<any>(`/enrollment/${courseId}/enroll`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async getMyEnrollments() {
|
||||
return this.request<any[]>('/enrollment');
|
||||
}
|
||||
|
||||
async getEnrollmentProgress(courseId: string) {
|
||||
return this.request<any>(`/enrollment/${courseId}/progress`);
|
||||
}
|
||||
|
||||
async completeLesson(courseId: string, lessonId: string) {
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/complete`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async submitQuizAnswers(courseId: string, lessonId: string, answers: number[]) {
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/quiz`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
}
|
||||
|
||||
async getLessonHomework(courseId: string, lessonId: string) {
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`);
|
||||
}
|
||||
|
||||
async submitLessonHomework(
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
data: { content?: string; type?: string; attachmentUrl?: string; githubUrl?: string } | string
|
||||
) {
|
||||
const payload = typeof data === 'string' ? { content: data } : data;
|
||||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async createReview(courseId: string, data: { rating: number; title?: string; content?: string }) {
|
||||
return this.request<any>(`/enrollment/${courseId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getCourseReviews(courseId: string, page?: number) {
|
||||
const params = page ? `?page=${page}` : '';
|
||||
return this.request<{ data: any[]; meta: any }>(`/enrollment/${courseId}/reviews${params}`);
|
||||
}
|
||||
|
||||
// Certificates
|
||||
async getCertificate(courseId: string) {
|
||||
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`
|
||||
);
|
||||
}
|
||||
|
||||
async reviewHomeworkSubmission(
|
||||
courseId: string,
|
||||
submissionId: string,
|
||||
data: { teacherScore: number; teacherFeedback?: string }
|
||||
) {
|
||||
return this.request<any>(`/courses/${courseId}/homework-submissions/${submissionId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getHomeworkSubmissions(courseId: string) {
|
||||
return this.request<any[]>(`/courses/${courseId}/homework-submissions`);
|
||||
}
|
||||
|
||||
// Groups
|
||||
async getDefaultCourseGroup(courseId: string) {
|
||||
return this.request<any>(`/groups/course/${courseId}/default`);
|
||||
}
|
||||
|
||||
async getGroupMessages(groupId: string, lessonId?: string) {
|
||||
const query = lessonId ? `?lessonId=${encodeURIComponent(lessonId)}` : '';
|
||||
return this.request<any[]>(`/groups/${groupId}/messages${query}`);
|
||||
}
|
||||
|
||||
async getGroupMembers(groupId: string) {
|
||||
return this.request<any[]>(`/groups/${groupId}/members`);
|
||||
}
|
||||
|
||||
async sendGroupMessage(groupId: string, content: string, lessonId?: string) {
|
||||
return this.request<any>(`/groups/${groupId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, lessonId }),
|
||||
});
|
||||
}
|
||||
|
||||
async createGroupInviteLink(groupId: string) {
|
||||
return this.request<{ inviteUrl: string }>(`/groups/${groupId}/invite-link`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async joinGroupByInvite(groupId: string) {
|
||||
return this.request<any>(`/groups/join/${groupId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Support
|
||||
async createSupportTicket(data: { title: string; initialMessage?: string; priority?: string }) {
|
||||
return this.request<any>('/support/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getMySupportTickets() {
|
||||
return this.request<any[]>('/support/tickets');
|
||||
}
|
||||
|
||||
async getSupportTicketMessages(id: string) {
|
||||
return this.request<any[]>(`/support/tickets/${id}/messages`);
|
||||
}
|
||||
|
||||
async sendSupportMessage(id: string, content: string) {
|
||||
return this.request<any>(`/support/tickets/${id}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminSupportTickets() {
|
||||
return this.request<any[]>('/support/admin/tickets');
|
||||
}
|
||||
|
||||
async getAdminSupportTicketMessages(id: string) {
|
||||
return this.request<any[]>(`/support/admin/tickets/${id}/messages`);
|
||||
}
|
||||
|
||||
async sendAdminSupportMessage(id: string, content: string) {
|
||||
return this.request<any>(`/support/admin/tickets/${id}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAdminSupportTicketStatus(id: string, status: string) {
|
||||
return this.request<any>(`/support/admin/tickets/${id}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
// Moderation reviews
|
||||
async hideReview(reviewId: string) {
|
||||
return this.request<any>(`/moderation/reviews/${reviewId}/hide`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async unhideReview(reviewId: string) {
|
||||
return this.request<any>(`/moderation/reviews/${reviewId}/unhide`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async getModerationCourses(params?: { status?: string; search?: string }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
const query = searchParams.toString();
|
||||
return this.request<any[]>(`/moderation/courses${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async getPendingModerationCourses() {
|
||||
return this.request<any[]>('/moderation/pending');
|
||||
}
|
||||
|
||||
async getModerationCoursePreview(courseId: string) {
|
||||
return this.request<any>(`/moderation/${courseId}/preview`);
|
||||
}
|
||||
|
||||
async previewModerationQuiz(courseId: string, lessonId: string, answers?: number[]) {
|
||||
return this.request<any>(`/moderation/${courseId}/quiz-preview`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ lessonId, answers }),
|
||||
});
|
||||
}
|
||||
|
||||
async approveModerationCourse(courseId: string, note?: string) {
|
||||
return this.request<any>(`/moderation/${courseId}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
}
|
||||
|
||||
async rejectModerationCourse(courseId: string, reason: string) {
|
||||
return this.request<any>(`/moderation/${courseId}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteModerationCourse(courseId: string) {
|
||||
return this.request<void>(`/moderation/${courseId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminUsers(params?: { search?: string; role?: string; limit?: number }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.role) searchParams.set('role', params.role);
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<any[]>(`/admin/users${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async updateAdminUserRole(userId: string, role: 'USER' | 'MODERATOR' | 'ADMIN') {
|
||||
return this.request<any>(`/admin/users/${userId}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminPayments(params?: {
|
||||
mode?: 'DEV' | 'PROD';
|
||||
provider?: 'STRIPE' | 'YOOMONEY';
|
||||
status?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.mode) searchParams.set('mode', params.mode);
|
||||
if (params?.provider) searchParams.set('provider', params.provider);
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.request<any[]>(`/admin/payments${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async submitCooperationRequest(data: {
|
||||
organization: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
organizationType?: string;
|
||||
message: string;
|
||||
}) {
|
||||
return this.request<any>('/cooperation/requests', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async uploadCourseSource(courseId: string, file: File) {
|
||||
const token = getApiToken();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_URL}/courses/${courseId}/sources/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getCourseSources(courseId: string) {
|
||||
return this.request<any[]>(`/courses/${courseId}/sources`);
|
||||
}
|
||||
|
||||
async getCourseSourceOutlineHints(courseId: string) {
|
||||
return this.request<any>(`/courses/${courseId}/sources/outline-hints`);
|
||||
}
|
||||
|
||||
// Search
|
||||
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
||||
const searchParams = new URLSearchParams({ q: query });
|
||||
|
||||
21
apps/web/src/lib/ws.ts
Normal file
21
apps/web/src/lib/ws.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export function getWsBaseUrl(): string {
|
||||
if (process.env.NEXT_PUBLIC_API_ORIGIN) {
|
||||
return process.env.NEXT_PUBLIC_API_ORIGIN;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return 'http://localhost:3125';
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location;
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return `${protocol}//${hostname}:3125`;
|
||||
}
|
||||
|
||||
// In production-like environments backend can be served behind same host
|
||||
// or exposed on a dedicated API origin via NEXT_PUBLIC_API_ORIGIN.
|
||||
if (port === '3080') {
|
||||
return `${protocol}//${hostname}:3125`;
|
||||
}
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
|
||||
}
|
||||
@ -62,8 +62,8 @@ export async function middleware(request: NextRequest) {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
// Protect dashboard routes
|
||||
if (request.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
// Protect dashboard and admin routes
|
||||
if (request.nextUrl.pathname.startsWith('/dashboard') || request.nextUrl.pathname.startsWith('/admin')) {
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
@ -80,5 +80,5 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/login', '/register'],
|
||||
matcher: ['/dashboard/:path*', '/admin/:path*', '/login', '/register'],
|
||||
};
|
||||
|
||||
@ -82,7 +82,7 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@ -88,11 +88,13 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/coursecraft?schema=public
|
||||
REDIS_URL: redis://redis:6379
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
web:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@ -30,13 +30,26 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Role
|
||||
role UserRole @default(USER)
|
||||
|
||||
// Relations
|
||||
settings UserSettings?
|
||||
subscription Subscription?
|
||||
courses Course[] @relation("AuthoredCourses")
|
||||
enrollments Enrollment[]
|
||||
lessonProgress LessonProgress[]
|
||||
purchases Purchase[]
|
||||
reviews Review[]
|
||||
generations CourseGeneration[]
|
||||
groupMembers GroupMember[]
|
||||
groupMessages GroupMessage[]
|
||||
uploadedSourceFiles CourseSourceFile[]
|
||||
homeworkSubmissions HomeworkSubmission[]
|
||||
supportTickets SupportTicket[]
|
||||
ticketMessages TicketMessage[]
|
||||
statusChanges CourseStatusHistory[] @relation("StatusChangedBy")
|
||||
cooperationRequests CooperationRequest[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -65,6 +78,12 @@ model UserSettings {
|
||||
@@map("user_settings")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
MODERATOR
|
||||
ADMIN
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Subscription & Payments
|
||||
// ============================================
|
||||
@ -113,7 +132,11 @@ model Subscription {
|
||||
enum CourseStatus {
|
||||
DRAFT
|
||||
GENERATING
|
||||
PENDING_MODERATION
|
||||
PENDING_REVIEW
|
||||
APPROVED
|
||||
PUBLISHED
|
||||
REJECTED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
@ -130,11 +153,14 @@ model Course {
|
||||
// Status
|
||||
status CourseStatus @default(DRAFT)
|
||||
|
||||
// Marketplace (future)
|
||||
// Marketplace
|
||||
isPublished Boolean @default(false) @map("is_published")
|
||||
price Decimal? @db.Decimal(10, 2) // null = private course
|
||||
price Decimal? @db.Decimal(10, 2) // null = free course
|
||||
currency String @default("USD")
|
||||
|
||||
// Author verification — author checked the content and vouches for quality
|
||||
isVerified Boolean @default(false) @map("is_verified")
|
||||
|
||||
// Categorization
|
||||
categoryId String? @map("category_id")
|
||||
tags String[] @default([])
|
||||
@ -155,13 +181,21 @@ model Course {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Moderation
|
||||
moderationNote String? @db.Text @map("moderation_note")
|
||||
moderatedAt DateTime? @map("moderated_at")
|
||||
|
||||
// Relations
|
||||
author User @relation("AuthoredCourses", fields: [authorId], references: [id], onDelete: Cascade)
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
chapters Chapter[]
|
||||
enrollments Enrollment[]
|
||||
purchases Purchase[]
|
||||
reviews Review[]
|
||||
generation CourseGeneration?
|
||||
groups CourseGroup[]
|
||||
statusHistory CourseStatusHistory[]
|
||||
sourceFiles CourseSourceFile[]
|
||||
|
||||
// Vector embedding for semantic search
|
||||
embedding Unsupported("vector(1536)")?
|
||||
@ -213,6 +247,9 @@ model Lesson {
|
||||
|
||||
// Relations
|
||||
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
|
||||
homework Homework[]
|
||||
quiz Quiz?
|
||||
groupMessages GroupMessage[]
|
||||
|
||||
// Vector embedding for semantic search
|
||||
embedding Unsupported("vector(1536)")?
|
||||
@ -221,6 +258,18 @@ model Lesson {
|
||||
@@map("lessons")
|
||||
}
|
||||
|
||||
model Quiz {
|
||||
id String @id @default(uuid())
|
||||
lessonId String @unique @map("lesson_id")
|
||||
questions Json // Array of {id, question, options, correctAnswer}
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("quizzes")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Course Generation
|
||||
// ============================================
|
||||
@ -307,6 +356,16 @@ model Category {
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
enum PaymentMode {
|
||||
DEV
|
||||
PROD
|
||||
}
|
||||
|
||||
enum PaymentProvider {
|
||||
STRIPE
|
||||
YOOMONEY
|
||||
}
|
||||
|
||||
model Purchase {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
@ -316,6 +375,10 @@ model Purchase {
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currency String @default("USD")
|
||||
stripePaymentId String? @map("stripe_payment_id")
|
||||
provider PaymentProvider @default(STRIPE)
|
||||
mode PaymentMode @default(PROD)
|
||||
eventCode String? @map("event_code")
|
||||
metadata Json?
|
||||
|
||||
// Status
|
||||
status String @default("completed") // pending, completed, refunded
|
||||
@ -360,3 +423,293 @@ model Review {
|
||||
@@index([courseId])
|
||||
@@map("reviews")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Enrollment & Progress
|
||||
// ============================================
|
||||
|
||||
model Enrollment {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
courseId String @map("course_id")
|
||||
|
||||
// Progress
|
||||
progress Int @default(0) // 0-100
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
// Certificate
|
||||
certificateUrl String? @map("certificate_url")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
lessons LessonProgress[]
|
||||
|
||||
@@unique([userId, courseId])
|
||||
@@index([userId])
|
||||
@@index([courseId])
|
||||
@@map("enrollments")
|
||||
}
|
||||
|
||||
model LessonProgress {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
enrollmentId String @map("enrollment_id")
|
||||
lessonId String @map("lesson_id")
|
||||
|
||||
completedAt DateTime? @map("completed_at")
|
||||
quizScore Int? @map("quiz_score") // 0-100
|
||||
quizPassed Boolean @default(false) @map("quiz_passed")
|
||||
quizPassedAt DateTime? @map("quiz_passed_at")
|
||||
homeworkSubmitted Boolean @default(false) @map("homework_submitted")
|
||||
homeworkSubmittedAt DateTime? @map("homework_submitted_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, lessonId])
|
||||
@@index([enrollmentId])
|
||||
@@map("lesson_progress")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Course Groups & Collaboration
|
||||
// ============================================
|
||||
|
||||
model CourseGroup {
|
||||
id String @id @default(uuid())
|
||||
courseId String @map("course_id")
|
||||
name String
|
||||
description String? @db.Text
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
members GroupMember[]
|
||||
messages GroupMessage[]
|
||||
|
||||
@@index([courseId])
|
||||
@@map("course_groups")
|
||||
}
|
||||
|
||||
model GroupMember {
|
||||
id String @id @default(uuid())
|
||||
groupId String @map("group_id")
|
||||
userId String @map("user_id")
|
||||
role String @default("student") // "teacher", "student"
|
||||
|
||||
joinedAt DateTime @default(now()) @map("joined_at")
|
||||
|
||||
group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, userId])
|
||||
@@map("group_members")
|
||||
}
|
||||
|
||||
model GroupMessage {
|
||||
id String @id @default(uuid())
|
||||
groupId String @map("group_id")
|
||||
userId String @map("user_id")
|
||||
lessonId String? @map("lesson_id")
|
||||
content String @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([groupId])
|
||||
@@index([lessonId])
|
||||
@@map("group_messages")
|
||||
}
|
||||
|
||||
model CourseStatusHistory {
|
||||
id String @id @default(uuid())
|
||||
courseId String @map("course_id")
|
||||
fromStatus CourseStatus? @map("from_status")
|
||||
toStatus CourseStatus @map("to_status")
|
||||
note String? @db.Text
|
||||
changedById String? @map("changed_by_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
changedBy User? @relation("StatusChangedBy", fields: [changedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([courseId, createdAt])
|
||||
@@map("course_status_history")
|
||||
}
|
||||
|
||||
enum CourseSourceType {
|
||||
PDF
|
||||
DOCX
|
||||
TXT
|
||||
PPTX
|
||||
IMAGE
|
||||
ZIP
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum CourseSourceParseStatus {
|
||||
PENDING
|
||||
PARSED
|
||||
FAILED
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
model CourseSourceFile {
|
||||
id String @id @default(uuid())
|
||||
courseId String @map("course_id")
|
||||
uploadedById String @map("uploaded_by_id")
|
||||
fileName String @map("file_name")
|
||||
mimeType String @map("mime_type")
|
||||
fileSize Int @map("file_size")
|
||||
sourceType CourseSourceType @map("source_type")
|
||||
storagePath String @map("storage_path")
|
||||
parseStatus CourseSourceParseStatus @default(PENDING) @map("parse_status")
|
||||
extractedText String? @db.Text @map("extracted_text")
|
||||
extractedMeta Json? @map("extracted_meta")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
uploadedBy User @relation(fields: [uploadedById], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([courseId, createdAt])
|
||||
@@map("course_source_files")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Homework & Assignments
|
||||
// ============================================
|
||||
|
||||
model Homework {
|
||||
id String @id @default(uuid())
|
||||
lessonId String @unique @map("lesson_id")
|
||||
title String
|
||||
description String @db.Text
|
||||
type HomeworkType @default(TEXT)
|
||||
config Json?
|
||||
dueDate DateTime? @map("due_date")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
submissions HomeworkSubmission[]
|
||||
|
||||
@@index([lessonId])
|
||||
@@map("homework")
|
||||
}
|
||||
|
||||
enum HomeworkReviewStatus {
|
||||
SUBMITTED
|
||||
AI_REVIEWED
|
||||
TEACHER_REVIEWED
|
||||
}
|
||||
|
||||
enum HomeworkType {
|
||||
TEXT
|
||||
FILE
|
||||
PROJECT
|
||||
QUIZ
|
||||
GITHUB
|
||||
}
|
||||
|
||||
model HomeworkSubmission {
|
||||
id String @id @default(uuid())
|
||||
homeworkId String @map("homework_id")
|
||||
userId String @map("user_id")
|
||||
content String? @db.Text
|
||||
answerType HomeworkType @default(TEXT) @map("answer_type")
|
||||
attachmentUrl String? @map("attachment_url")
|
||||
githubUrl String? @map("github_url")
|
||||
projectMeta Json? @map("project_meta")
|
||||
quizAnswers Json? @map("quiz_answers")
|
||||
|
||||
// AI grading
|
||||
aiScore Int? @map("ai_score") // 1-5
|
||||
aiFeedback String? @db.Text @map("ai_feedback")
|
||||
|
||||
// Teacher grading
|
||||
teacherScore Int? @map("teacher_score") // 1-5
|
||||
teacherFeedback String? @db.Text @map("teacher_feedback")
|
||||
reviewStatus HomeworkReviewStatus @default(SUBMITTED) @map("review_status")
|
||||
|
||||
submittedAt DateTime @default(now()) @map("submitted_at")
|
||||
gradedAt DateTime? @map("graded_at")
|
||||
|
||||
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([homeworkId, userId])
|
||||
@@map("homework_submissions")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Support Tickets
|
||||
// ============================================
|
||||
|
||||
model SupportTicket {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
title String
|
||||
status String @default("open") // "open", "in_progress", "resolved", "closed"
|
||||
priority String @default("normal") // "low", "normal", "high"
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
messages TicketMessage[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@map("support_tickets")
|
||||
}
|
||||
|
||||
model TicketMessage {
|
||||
id String @id @default(uuid())
|
||||
ticketId String @map("ticket_id")
|
||||
userId String @map("user_id")
|
||||
content String @db.Text
|
||||
isStaff Boolean @default(false) @map("is_staff")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([ticketId])
|
||||
@@map("ticket_messages")
|
||||
}
|
||||
|
||||
model CooperationRequest {
|
||||
id String @id @default(uuid())
|
||||
organization String
|
||||
contactName String @map("contact_name")
|
||||
email String
|
||||
phone String?
|
||||
role String?
|
||||
organizationType String? @map("organization_type")
|
||||
message String @db.Text
|
||||
status String @default("new")
|
||||
source String? @default("landing")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
userId String? @map("user_id")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@map("cooperation_requests")
|
||||
}
|
||||
|
||||
@ -30,7 +30,22 @@ export type {
|
||||
Category,
|
||||
Purchase,
|
||||
Review,
|
||||
Enrollment,
|
||||
LessonProgress,
|
||||
CourseStatusHistory,
|
||||
CourseSourceFile,
|
||||
CooperationRequest,
|
||||
} from '@prisma/client';
|
||||
|
||||
// Enum re-exports
|
||||
export { SubscriptionTier, CourseStatus, GenerationStatus } from '@prisma/client';
|
||||
export {
|
||||
SubscriptionTier,
|
||||
CourseStatus,
|
||||
GenerationStatus,
|
||||
UserRole,
|
||||
PaymentMode,
|
||||
PaymentProvider,
|
||||
CourseSourceType,
|
||||
CourseSourceParseStatus,
|
||||
HomeworkType,
|
||||
} from '@prisma/client';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user