diff --git a/DOCKER-SERVER-CONFIG.md b/DOCKER-SERVER-CONFIG.md new file mode 100644 index 0000000..f662270 --- /dev/null +++ b/DOCKER-SERVER-CONFIG.md @@ -0,0 +1,62 @@ +# Конфигурация Docker на сервере + +## Рекомендации для серверов с ограниченной RAM (8 GB) + +### 1. Ограничение BuildKit cache + +Чтобы BuildKit cache не раздувался до 80+ GB: + +```bash +# Создать или отредактировать +sudo nano /etc/docker/daemon.json +``` + +Содержимое: + +```json +{ + "builder": { + "gc": { + "defaultKeepStorage": "10GB", + "enabled": true + } + }, + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} +``` + +Затем перезапуск: + +```bash +sudo systemctl restart docker +``` + +### 2. Очистка build cache + +Если нужно освободить место вручную: + +```bash +# Удалить неиспользуемый build cache (осторожно: следующая сборка будет дольше) +docker builder prune -af + +# Или с ограничением по возрасту (старше 7 дней) +docker builder prune -af --filter "until=168h" +``` + +### 3. Frontend Dockerfile — лимит памяти Node.js + +В `front_material/Dockerfile` уже задано: + +```dockerfile +ENV NODE_OPTIONS="--max-old-space-size=2048" +``` + +Это ограничивает heap Node.js до 2 GB при сборке и снижает риск OOM и тяжёлого swapping на машинах с 8 GB RAM. + +### 4. Swap + +Рекомендуется swap 4–8 GB на серверах с 8 GB RAM — для стабильности при пиковых нагрузках. diff --git a/README-PROD.md b/README-PROD.md index 6490af9..c4f5794 100644 --- a/README-PROD.md +++ b/README-PROD.md @@ -1,148 +1,148 @@ -# PROD Окружение - Инструкция по управлению - -## ⚠️ ВАЖНО: Защита данных - -**PROD окружение использует отдельную сеть (`prod_network`) и именованные volumes для изоляции от dev.** - -### Что было исправлено: - -1. ✅ **Отдельная сеть** - `prod_network` вместо общей `dev_network` -2. ✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_` -3. ✅ **Полные имена контейнеров** - в `DATABASE_URL` и `REDIS_URL` используются полные имена контейнеров -4. ✅ **Изоляция от dev** - prod не может случайно подключиться к dev БД - -## 📋 Основные команды - -### Безопасная остановка (СОХРАНЯЕТ данные БД): -```bash -# Использовать скрипт из /var/www/service -/var/www/service/platform/safe-down-prod.sh - -# Или вручную -cd /var/www/platform/prod -docker compose down -``` - -### ⚠️ ОСТОРОЖНО: Полная очистка (УДАЛЯЕТ данные БД): -```bash -# Сначала создайте бэкап! -/var/www/service/backup/backup-prod-db.sh - -# Затем можно удалить volumes -cd /var/www/platform/prod -docker compose down --volumes -``` - -### Запуск: -```bash -docker compose up -d -``` - -### Создание бэкапа БД: -```bash -# Бэкап PROD БД -/var/www/service/backup/backup-all-db.sh - -# Альтернативный скрипт для PROD БД -/var/www/service/backup/backup-prod-db.sh - -# Автоматический бэкап PROD БД (для cron) -/var/www/service/backup/backup-db-auto.sh -``` - -**Примечание:** DEV БД не бэкапится, так как это окружение разработки. - -### Настройка автоматического бэкапа PROD БД (2 раза в день: 00:00 и 12:00): -```bash -# Установить автоматический бэкап PROD БД -/var/www/service/backup/setup-cron-backup.sh - -# Удалить автоматический бэкап -/var/www/service/backup/remove-cron-backup.sh - -# Проверить расписание -crontab -l | grep backup-db-auto - -# Просмотр логов -tail -f /var/www/platform/prod/backups/backup.log -``` - -**Примечание:** Автоматически бэкапится только PROD БД. DEV БД не бэкапится. - -### Полная пересборка PROD (с бэкапом): -```bash -/var/www/service/platform/rebuild-prod.sh -``` -Этот скрипт: -1. Создаёт бэкап БД -2. Останавливает контейнеры -3. Пересобирает образы без кэша -4. Запускает контейнеры -5. Применяет миграции - -### Применение миграций: -```bash -docker exec platform_prod_web python manage.py migrate -``` - -### Создание суперпользователя: -```bash -docker exec -it platform_prod_web python manage.py createsuperuser -``` - -## 🔧 Структура volumes - -- `platform_prod_postgres_data` - данные PostgreSQL БД -- `platform_prod_redis_data` - данные Redis -- `platform_prod_front_material_node_modules` - node_modules для frontend -- `platform_prod_front_material_next` - кэш Next.js - -## 🌐 Сеть - -- **Prod сеть**: `platform_prod_network` (изолирована от dev) -- **Dev сеть**: `dev_network` (отдельная) - -## 🔗 Подключения - -Все сервисы используют полные имена контейнеров: -- БД: `platform_prod_db` (не `db`) -- Redis: `platform_prod_redis` (не `redis`) - -Это гарантирует, что даже при запуске dev и prod одновременно, они не будут конфликтовать. - -## 📝 Что делать если данные потеряны - -1. Проверьте бэкапы: `ls -la ./backups/` -2. Если бэкапа нет, но данные есть в dev БД, можно скопировать: - ```bash - # Создать бэкап из dev - docker exec platform_dev_db pg_dump -U platform_dev_user -d platform_dev_db > /tmp/dev_backup.sql - - # Применить миграции в prod - docker exec platform_prod_web python manage.py migrate - - # Восстановить данные (осторожно!) - docker exec -i platform_prod_db psql -U platform_prod_user -d platform_prod_db < /tmp/dev_backup.sql - ``` -3. Если данных нет нигде - создайте пользователей заново через `createsuperuser` - -## 🚨 Частые ошибки - -### ❌ НЕ делайте: -- `docker compose down --volumes` без бэкапа -- Использование коротких имен (`db`, `redis`) в переменных окружения -- Общая сеть для dev и prod - -### ✅ Делайте: -- Всегда используйте `docker compose down` (без `--volumes`) -- Регулярно создавайте бэкапы PROD БД: `/var/www/service/backup/backup-all-db.sh` -- Используйте полные имена контейнеров в конфигурации - -## 📁 Расположение скриптов - -Все служебные скрипты перенесены в `/var/www/service/`: - -- **Бэкапы**: `/var/www/service/backup/` -- **Управление платформой**: `/var/www/service/platform/` - -Подробнее: `/var/www/service/README.md` +# PROD Окружение - Инструкция по управлению + +## ⚠️ ВАЖНО: Защита данных + +**PROD окружение использует отдельную сеть (`prod_network`) и именованные volumes для изоляции от dev.** + +### Что было исправлено: + +1. ✅ **Отдельная сеть** - `prod_network` вместо общей `dev_network` +2. ✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_` +3. ✅ **Полные имена контейнеров** - в `DATABASE_URL` и `REDIS_URL` используются полные имена контейнеров +4. ✅ **Изоляция от dev** - prod не может случайно подключиться к dev БД + +## 📋 Основные команды + +### Безопасная остановка (СОХРАНЯЕТ данные БД): +```bash +# Использовать скрипт из /var/www/service +/var/www/service/platform/safe-down-prod.sh + +# Или вручную +cd /var/www/platform/prod +docker compose down +``` + +### ⚠️ ОСТОРОЖНО: Полная очистка (УДАЛЯЕТ данные БД): +```bash +# Сначала создайте бэкап! +/var/www/service/backup/backup-prod-db.sh + +# Затем можно удалить volumes +cd /var/www/platform/prod +docker compose down --volumes +``` + +### Запуск: +```bash +docker compose up -d +``` + +### Создание бэкапа БД: +```bash +# Бэкап PROD БД +/var/www/service/backup/backup-all-db.sh + +# Альтернативный скрипт для PROD БД +/var/www/service/backup/backup-prod-db.sh + +# Автоматический бэкап PROD БД (для cron) +/var/www/service/backup/backup-db-auto.sh +``` + +**Примечание:** DEV БД не бэкапится, так как это окружение разработки. + +### Настройка автоматического бэкапа PROD БД (2 раза в день: 00:00 и 12:00): +```bash +# Установить автоматический бэкап PROD БД +/var/www/service/backup/setup-cron-backup.sh + +# Удалить автоматический бэкап +/var/www/service/backup/remove-cron-backup.sh + +# Проверить расписание +crontab -l | grep backup-db-auto + +# Просмотр логов +tail -f /var/www/platform/prod/backups/backup.log +``` + +**Примечание:** Автоматически бэкапится только PROD БД. DEV БД не бэкапится. + +### Полная пересборка PROD (с бэкапом): +```bash +/var/www/service/platform/rebuild-prod.sh +``` +Этот скрипт: +1. Создаёт бэкап БД +2. Останавливает контейнеры +3. Пересобирает образы без кэша +4. Запускает контейнеры +5. Применяет миграции + +### Применение миграций: +```bash +docker exec platform_prod_web python manage.py migrate +``` + +### Создание суперпользователя: +```bash +docker exec -it platform_prod_web python manage.py createsuperuser +``` + +## 🔧 Структура volumes + +- `platform_prod_postgres_data` - данные PostgreSQL БД +- `platform_prod_redis_data` - данные Redis +- `platform_prod_front_material_node_modules` - node_modules для frontend +- `platform_prod_front_material_next` - кэш Next.js + +## 🌐 Сеть + +- **Prod сеть**: `platform_prod_network` (изолирована от dev) +- **Dev сеть**: `dev_network` (отдельная) + +## 🔗 Подключения + +Все сервисы используют полные имена контейнеров: +- БД: `platform_prod_db` (не `db`) +- Redis: `platform_prod_redis` (не `redis`) + +Это гарантирует, что даже при запуске dev и prod одновременно, они не будут конфликтовать. + +## 📝 Что делать если данные потеряны + +1. Проверьте бэкапы: `ls -la ./backups/` +2. Если бэкапа нет, но данные есть в dev БД, можно скопировать: + ```bash + # Создать бэкап из dev + docker exec platform_dev_db pg_dump -U platform_dev_user -d platform_dev_db > /tmp/dev_backup.sql + + # Применить миграции в prod + docker exec platform_prod_web python manage.py migrate + + # Восстановить данные (осторожно!) + docker exec -i platform_prod_db psql -U platform_prod_user -d platform_prod_db < /tmp/dev_backup.sql + ``` +3. Если данных нет нигде - создайте пользователей заново через `createsuperuser` + +## 🚨 Частые ошибки + +### ❌ НЕ делайте: +- `docker compose down --volumes` без бэкапа +- Использование коротких имен (`db`, `redis`) в переменных окружения +- Общая сеть для dev и prod + +### ✅ Делайте: +- Всегда используйте `docker compose down` (без `--volumes`) +- Регулярно создавайте бэкапы PROD БД: `/var/www/service/backup/backup-all-db.sh` +- Используйте полные имена контейнеров в конфигурации + +## 📁 Расположение скриптов + +Все служебные скрипты перенесены в `/var/www/service/`: + +- **Бэкапы**: `/var/www/service/backup/` +- **Управление платформой**: `/var/www/service/platform/` + +Подробнее: `/var/www/service/README.md` diff --git a/REBUILD-INSTRUCTIONS.md b/REBUILD-INSTRUCTIONS.md index 7a87d7b..51f6d15 100644 --- a/REBUILD-INSTRUCTIONS.md +++ b/REBUILD-INSTRUCTIONS.md @@ -1,98 +1,98 @@ -# Инструкция по пересборке PROD и созданию бэкапов - -## 🎯 Что нужно сделать: - -### 1. Создать бэкап PROD БД - -```bash -# Сделать скрипты исполняемыми (первый раз) -chmod +x /var/www/service/backup/*.sh -chmod +x /var/www/service/platform/*.sh - -# Создать бэкап PROD БД -/var/www/service/backup/backup-all-db.sh -``` - -Это создаст бэкап: -- `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz` - -**Примечание:** DEV БД не бэкапится, так как это окружение разработки. - -### 2. Пересобрать PROD окружение - -```bash -# Автоматическая пересборка (с бэкапом) -/var/www/service/platform/rebuild-prod.sh -``` - -Или вручную: - -```bash -cd /var/www/platform/prod - -# Остановить контейнеры -docker compose down - -# Пересобрать без кэша -docker compose build --no-cache --pull - -# Запустить -docker compose up -d - -# Подождать запуска БД -sleep 10 - -# Применить миграции -docker exec platform_prod_web python manage.py migrate - -# Проверить статус -docker compose ps -``` - -### 3. Проверить, что всё работает - -```bash -# Проверить логи -docker compose logs -f - -# Проверить подключение к БД -docker exec platform_prod_web python manage.py shell -c "from django.db import connection; print('DB:', connection.settings_dict['NAME'])" - -# Проверить количество пользователей (если таблица существует) -docker exec platform_prod_db psql -U platform_prod_user -d platform_prod_db -c "SELECT COUNT(*) FROM users_user;" 2>/dev/null || echo "Таблица не создана, нужно применить миграции" -``` - -### 4. Если пользователей нет - создать суперпользователя - -```bash -docker exec -it platform_prod_web python manage.py createsuperuser -``` - -## 📋 Что было исправлено: - -✅ **Отдельная сеть для prod** - `prod_network` (изолирована от dev) -✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_` -✅ **Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis` -✅ **Защита от случайного удаления данных** - volumes не удаляются при `docker compose down` - -## ⚠️ Важно: - -- **НЕ используйте** `docker compose down --volumes` без бэкапа! -- Всегда создавайте бэкапы перед пересборкой -- Используйте `./safe-down.sh` для безопасной остановки - -## 🔄 Восстановление из бэкапа (если нужно): - -```bash -# Восстановить PROD БД -gunzip < /var/www/platform/prod/backups/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres -``` - -## 📁 Расположение скриптов - -Все служебные скрипты находятся в `/var/www/service/`: - -- **Бэкапы**: `/var/www/service/backup/` -- **Управление платформой**: `/var/www/service/platform/` - -Подробнее: `/var/www/service/README.md` +# Инструкция по пересборке PROD и созданию бэкапов + +## 🎯 Что нужно сделать: + +### 1. Создать бэкап PROD БД + +```bash +# Сделать скрипты исполняемыми (первый раз) +chmod +x /var/www/service/backup/*.sh +chmod +x /var/www/service/platform/*.sh + +# Создать бэкап PROD БД +/var/www/service/backup/backup-all-db.sh +``` + +Это создаст бэкап: +- `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz` + +**Примечание:** DEV БД не бэкапится, так как это окружение разработки. + +### 2. Пересобрать PROD окружение + +```bash +# Автоматическая пересборка (с бэкапом) +/var/www/service/platform/rebuild-prod.sh +``` + +Или вручную: + +```bash +cd /var/www/platform/prod + +# Остановить контейнеры +docker compose down + +# Пересобрать без кэша +docker compose build --no-cache --pull + +# Запустить +docker compose up -d + +# Подождать запуска БД +sleep 10 + +# Применить миграции +docker exec platform_prod_web python manage.py migrate + +# Проверить статус +docker compose ps +``` + +### 3. Проверить, что всё работает + +```bash +# Проверить логи +docker compose logs -f + +# Проверить подключение к БД +docker exec platform_prod_web python manage.py shell -c "from django.db import connection; print('DB:', connection.settings_dict['NAME'])" + +# Проверить количество пользователей (если таблица существует) +docker exec platform_prod_db psql -U platform_prod_user -d platform_prod_db -c "SELECT COUNT(*) FROM users_user;" 2>/dev/null || echo "Таблица не создана, нужно применить миграции" +``` + +### 4. Если пользователей нет - создать суперпользователя + +```bash +docker exec -it platform_prod_web python manage.py createsuperuser +``` + +## 📋 Что было исправлено: + +✅ **Отдельная сеть для prod** - `prod_network` (изолирована от dev) +✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_` +✅ **Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis` +✅ **Защита от случайного удаления данных** - volumes не удаляются при `docker compose down` + +## ⚠️ Важно: + +- **НЕ используйте** `docker compose down --volumes` без бэкапа! +- Всегда создавайте бэкапы перед пересборкой +- Используйте `./safe-down.sh` для безопасной остановки + +## 🔄 Восстановление из бэкапа (если нужно): + +```bash +# Восстановить PROD БД +gunzip < /var/www/platform/prod/backups/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres +``` + +## 📁 Расположение скриптов + +Все служебные скрипты находятся в `/var/www/service/`: + +- **Бэкапы**: `/var/www/service/backup/` +- **Управление платформой**: `/var/www/service/platform/` + +Подробнее: `/var/www/service/README.md` diff --git a/backend/apps/users/admin.py b/backend/apps/users/admin.py index e6eaeff..5968db8 100644 --- a/backend/apps/users/admin.py +++ b/backend/apps/users/admin.py @@ -84,6 +84,10 @@ class UserAdmin(BaseUserAdmin): 'telegram_notifications' ) }), + (_('Онбординг'), { + 'fields': ('onboarding_tours_seen',), + 'description': 'Прогресс подсказок по платформе (JSON). Чтобы сбросить — очистите поле или укажите {}.' + }), (_('Блокировка'), { 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), 'classes': ('collapse',) @@ -142,6 +146,10 @@ class MentorAdmin(BaseUserAdmin): 'notifications_enabled', 'email_notifications', 'telegram_notifications', 'ai_trust_draft', 'ai_trust_publish') }), + (_('Онбординг'), { + 'fields': ('onboarding_tours_seen',), + 'description': 'Прогресс подсказок (JSON). Чтобы сбросить — очистите или введите {}.' + }), (_('Важные даты'), { 'fields': ('last_login', 'last_activity', 'date_joined', 'created_at', 'updated_at'), 'classes': ('collapse',) diff --git a/backend/apps/users/migrations/0011_add_onboarding_tours_seen.py b/backend/apps/users/migrations/0011_add_onboarding_tours_seen.py new file mode 100644 index 0000000..ab7d692 --- /dev/null +++ b/backend/apps/users/migrations/0011_add_onboarding_tours_seen.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0010_user_login_token"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="onboarding_tours_seen", + field=models.JSONField( + blank=True, + default=dict, + help_text="Страницы, для которых уже показан приветственный тур", + verbose_name="Просмотренные туры онбординга", + ), + ), + ] diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py index 0dad7a7..27d9132 100644 --- a/backend/apps/users/models.py +++ b/backend/apps/users/models.py @@ -241,6 +241,15 @@ class User(AbstractUser): verbose_name='Последняя активность' ) + # Прогресс онбординга (какие страницы уже показывали подсказки) + # Формат: {"dashboard": true, "schedule": true, "students": false, ...} + onboarding_tours_seen = models.JSONField( + default=dict, + blank=True, + verbose_name='Просмотренные туры онбординга', + help_text='Страницы, для которых уже показан приветственный тур', + ) + # Настройки уведомлений notifications_enabled = models.BooleanField( default=True, diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py index 654cd29..4fbe82c 100644 --- a/backend/apps/users/profile_views.py +++ b/backend/apps/users/profile_views.py @@ -568,6 +568,7 @@ class ProfileViewSet(viewsets.ViewSet): 'ai_trust_draft': getattr(user, 'ai_trust_draft', False), 'ai_trust_publish': getattr(user, 'ai_trust_publish', False), } + settings['onboarding_tours_seen'] = getattr(user, 'onboarding_tours_seen', {}) or {} return Response(settings) @@ -842,6 +843,14 @@ class ProfileViewSet(viewsets.ViewSet): if 'ai_trust_publish' in mentor_ai: user.ai_trust_publish = bool(mentor_ai['ai_trust_publish']) + # Онбординг: отметка просмотренных туров + if 'onboarding_tours_seen' in request.data: + tours = request.data['onboarding_tours_seen'] + if isinstance(tours, dict): + current = getattr(user, 'onboarding_tours_seen', None) or {} + merged = {**current, **{k: bool(v) for k, v in tours.items()}} + user.onboarding_tours_seen = merged + user.save() return Response({'message': 'Настройки успешно обновлены'}) diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py index 2787e72..68878ae 100644 --- a/backend/apps/users/serializers.py +++ b/backend/apps/users/serializers.py @@ -38,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer): 'country', 'city', 'email_verified', 'is_active', 'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором + 'onboarding_tours_seen', 'invitation_link_token', 'invitation_link', 'login_token', 'login_link', 'notifications_enabled', 'email_notifications', 'telegram_notifications', diff --git a/backup-all-db.sh b/backup-all-db.sh index bd161fa..bc0f0d6 100644 --- a/backup-all-db.sh +++ b/backup-all-db.sh @@ -1,65 +1,65 @@ -#!/bin/bash - -# Скрипт для создания бэкапов БД PROD и DEV - -set -e - -BACKUP_DIR="./backups" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) - -echo "==========================================" -echo "Создание бэкапов БД (PROD и DEV)" -echo "==========================================" -echo "" - -# Создать директорию для бэкапов -mkdir -p "$BACKUP_DIR" - -# Функция для создания бэкапа -backup_db() { - local CONTAINER_NAME=$1 - local DB_USER=$2 - local DB_NAME=$3 - local BACKUP_NAME=$4 - - echo "Создание бэкапа: $BACKUP_NAME" - - # Проверить, что контейнер запущен - if ! docker ps | grep -q "$CONTAINER_NAME"; then - echo "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..." - return 1 - fi - - BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz" - - # Создать бэкап - if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then - BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) - echo " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)" - return 0 - else - echo " ✗ Ошибка создания бэкапа для $BACKUP_NAME" - return 1 - fi -} - -# Бэкап PROD БД -echo "--- PROD БД ---" -backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db" - -echo "" - -# Бэкап DEV БД -echo "--- DEV БД ---" -backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db" - -echo "" -echo "==========================================" -echo "Бэкапы сохранены в: $BACKUP_DIR" -echo "==========================================" -echo "" -echo "Для восстановления PROD БД:" -echo " gunzip < $BACKUP_DIR/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres" -echo "" -echo "Для восстановления DEV БД:" -echo " gunzip < $BACKUP_DIR/platform_dev_db_*.sql.gz | docker exec -i platform_dev_db psql -U platform_dev_user -d postgres" +#!/bin/bash + +# Скрипт для создания бэкапов БД PROD и DEV + +set -e + +BACKUP_DIR="./backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +echo "==========================================" +echo "Создание бэкапов БД (PROD и DEV)" +echo "==========================================" +echo "" + +# Создать директорию для бэкапов +mkdir -p "$BACKUP_DIR" + +# Функция для создания бэкапа +backup_db() { + local CONTAINER_NAME=$1 + local DB_USER=$2 + local DB_NAME=$3 + local BACKUP_NAME=$4 + + echo "Создание бэкапа: $BACKUP_NAME" + + # Проверить, что контейнер запущен + if ! docker ps | grep -q "$CONTAINER_NAME"; then + echo "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..." + return 1 + fi + + BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz" + + # Создать бэкап + if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then + BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) + echo " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)" + return 0 + else + echo " ✗ Ошибка создания бэкапа для $BACKUP_NAME" + return 1 + fi +} + +# Бэкап PROD БД +echo "--- PROD БД ---" +backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db" + +echo "" + +# Бэкап DEV БД +echo "--- DEV БД ---" +backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db" + +echo "" +echo "==========================================" +echo "Бэкапы сохранены в: $BACKUP_DIR" +echo "==========================================" +echo "" +echo "Для восстановления PROD БД:" +echo " gunzip < $BACKUP_DIR/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres" +echo "" +echo "Для восстановления DEV БД:" +echo " gunzip < $BACKUP_DIR/platform_dev_db_*.sql.gz | docker exec -i platform_dev_db psql -U platform_dev_user -d postgres" diff --git a/backup-db-auto.sh b/backup-db-auto.sh index 8f111d6..1d062c0 100644 --- a/backup-db-auto.sh +++ b/backup-db-auto.sh @@ -1,99 +1,99 @@ -#!/bin/bash - -# Автоматический скрипт для создания бэкапов БД PROD и DEV -# Запускается через cron дважды в день (00:00 и 12:00) - -set -e - -BACKUP_DIR="/var/www/platform/prod/backups" -LOG_FILE="/var/www/platform/prod/backups/backup.log" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -DATE=$(date +%Y-%m-%d\ %H:%M:%S) - -# Создать директорию для бэкапов и логов -mkdir -p "$BACKUP_DIR" - -# Функция для логирования -log() { - echo "[$DATE] $1" | tee -a "$LOG_FILE" -} - -log "==========================================" -log "Начало автоматического бэкапа БД" -log "==========================================" - -# Функция для создания бэкапа -backup_db() { - local CONTAINER_NAME=$1 - local DB_USER=$2 - local DB_NAME=$3 - local BACKUP_NAME=$4 - - log "Создание бэкапа: $BACKUP_NAME" - - # Проверить, что контейнер запущен - if ! docker ps | grep -q "$CONTAINER_NAME"; then - log "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..." - return 1 - fi - - BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz" - - # Создать бэкап - if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then - BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) - log " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)" - - # Проверить размер файла (должен быть больше 0) - if [ ! -s "$BACKUP_FILE" ]; then - log " ✗ ОШИБКА: Бэкап пустой!" - rm -f "$BACKUP_FILE" - return 1 - fi - - return 0 - else - log " ✗ Ошибка создания бэкапа для $BACKUP_NAME" - return 1 - fi -} - -# Бэкап PROD БД -PROD_SUCCESS=false -if backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"; then - PROD_SUCCESS=true -fi - -# Бэкап DEV БД -DEV_SUCCESS=false -if backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"; then - DEV_SUCCESS=true -fi - -# Очистка старых бэкапов (оставляем последние 30 дней) -log "Очистка старых бэкапов (старше 30 дней)..." -find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +30 -delete 2>/dev/null || true -DELETED_COUNT=$(find "$BACKUP_DIR" -name "*.sql.gz" -type f 2>/dev/null | wc -l) -log "Осталось бэкапов: $DELETED_COUNT" - -# Итоги -log "==========================================" -if [ "$PROD_SUCCESS" = true ] && [ "$DEV_SUCCESS" = true ]; then - log "✓ Бэкапы созданы успешно (PROD и DEV)" -elif [ "$PROD_SUCCESS" = true ]; then - log "⚠️ Бэкап PROD создан, DEV пропущен" -elif [ "$DEV_SUCCESS" = true ]; then - log "⚠️ Бэкап DEV создан, PROD пропущен" -else - log "✗ Ошибка: бэкапы не созданы!" - exit 1 -fi -log "==========================================" - -# Проверка места на диске -DISK_USAGE=$(df -h "$BACKUP_DIR" | tail -1 | awk '{print $5}' | sed 's/%//') -if [ "$DISK_USAGE" -gt 80 ]; then - log "⚠️ ВНИМАНИЕ: Использовано дискового пространства: ${DISK_USAGE}%" -fi - -exit 0 +#!/bin/bash + +# Автоматический скрипт для создания бэкапов БД PROD и DEV +# Запускается через cron дважды в день (00:00 и 12:00) + +set -e + +BACKUP_DIR="/var/www/platform/prod/backups" +LOG_FILE="/var/www/platform/prod/backups/backup.log" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +DATE=$(date +%Y-%m-%d\ %H:%M:%S) + +# Создать директорию для бэкапов и логов +mkdir -p "$BACKUP_DIR" + +# Функция для логирования +log() { + echo "[$DATE] $1" | tee -a "$LOG_FILE" +} + +log "==========================================" +log "Начало автоматического бэкапа БД" +log "==========================================" + +# Функция для создания бэкапа +backup_db() { + local CONTAINER_NAME=$1 + local DB_USER=$2 + local DB_NAME=$3 + local BACKUP_NAME=$4 + + log "Создание бэкапа: $BACKUP_NAME" + + # Проверить, что контейнер запущен + if ! docker ps | grep -q "$CONTAINER_NAME"; then + log "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..." + return 1 + fi + + BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz" + + # Создать бэкап + if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then + BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) + log " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)" + + # Проверить размер файла (должен быть больше 0) + if [ ! -s "$BACKUP_FILE" ]; then + log " ✗ ОШИБКА: Бэкап пустой!" + rm -f "$BACKUP_FILE" + return 1 + fi + + return 0 + else + log " ✗ Ошибка создания бэкапа для $BACKUP_NAME" + return 1 + fi +} + +# Бэкап PROD БД +PROD_SUCCESS=false +if backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"; then + PROD_SUCCESS=true +fi + +# Бэкап DEV БД +DEV_SUCCESS=false +if backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"; then + DEV_SUCCESS=true +fi + +# Очистка старых бэкапов (оставляем последние 30 дней) +log "Очистка старых бэкапов (старше 30 дней)..." +find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +30 -delete 2>/dev/null || true +DELETED_COUNT=$(find "$BACKUP_DIR" -name "*.sql.gz" -type f 2>/dev/null | wc -l) +log "Осталось бэкапов: $DELETED_COUNT" + +# Итоги +log "==========================================" +if [ "$PROD_SUCCESS" = true ] && [ "$DEV_SUCCESS" = true ]; then + log "✓ Бэкапы созданы успешно (PROD и DEV)" +elif [ "$PROD_SUCCESS" = true ]; then + log "⚠️ Бэкап PROD создан, DEV пропущен" +elif [ "$DEV_SUCCESS" = true ]; then + log "⚠️ Бэкап DEV создан, PROD пропущен" +else + log "✗ Ошибка: бэкапы не созданы!" + exit 1 +fi +log "==========================================" + +# Проверка места на диске +DISK_USAGE=$(df -h "$BACKUP_DIR" | tail -1 | awk '{print $5}' | sed 's/%//') +if [ "$DISK_USAGE" -gt 80 ]; then + log "⚠️ ВНИМАНИЕ: Использовано дискового пространства: ${DISK_USAGE}%" +fi + +exit 0 diff --git a/backup-db.sh b/backup-db.sh index ad9567c..3a5ab83 100644 --- a/backup-db.sh +++ b/backup-db.sh @@ -1,42 +1,42 @@ -#!/bin/bash - -# Скрипт для создания бэкапа БД PROD - -set -e - -BACKUP_DIR="./backups" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -BACKUP_FILE="$BACKUP_DIR/platform_prod_db_backup_$TIMESTAMP.sql.gz" - -echo "==========================================" -echo "Создание бэкапа PROD БД" -echo "==========================================" -echo "" - -# Создать директорию для бэкапов -mkdir -p "$BACKUP_DIR" - -# Проверить, что контейнер БД запущен -if ! docker ps | grep -q platform_prod_db; then - echo "Ошибка: Контейнер platform_prod_db не запущен" - echo "Запустите БД: docker compose up -d db" - exit 1 -fi - -echo "Создание бэкапа..." -echo "Файл: $BACKUP_FILE" -echo "" - -# Создать бэкап -if docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "$BACKUP_FILE"; then - BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) - echo "✓ Бэкап создан успешно" - echo " Размер: $BACKUP_SIZE" - echo " Файл: $BACKUP_FILE" - echo "" - echo "Для восстановления:" - echo " gunzip < $BACKUP_FILE | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres" -else - echo "✗ Ошибка создания бэкапа!" - exit 1 -fi +#!/bin/bash + +# Скрипт для создания бэкапа БД PROD + +set -e + +BACKUP_DIR="./backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/platform_prod_db_backup_$TIMESTAMP.sql.gz" + +echo "==========================================" +echo "Создание бэкапа PROD БД" +echo "==========================================" +echo "" + +# Создать директорию для бэкапов +mkdir -p "$BACKUP_DIR" + +# Проверить, что контейнер БД запущен +if ! docker ps | grep -q platform_prod_db; then + echo "Ошибка: Контейнер platform_prod_db не запущен" + echo "Запустите БД: docker compose up -d db" + exit 1 +fi + +echo "Создание бэкапа..." +echo "Файл: $BACKUP_FILE" +echo "" + +# Создать бэкап +if docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "$BACKUP_FILE"; then + BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) + echo "✓ Бэкап создан успешно" + echo " Размер: $BACKUP_SIZE" + echo " Файл: $BACKUP_FILE" + echo "" + echo "Для восстановления:" + echo " gunzip < $BACKUP_FILE | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres" +else + echo "✗ Ошибка создания бэкапа!" + exit 1 +fi diff --git a/docker/livekit/livekit-config.yaml b/docker/livekit/livekit-config.yaml index 59c8c43..ac892db 100644 --- a/docker/livekit/livekit-config.yaml +++ b/docker/livekit/livekit-config.yaml @@ -1,27 +1,27 @@ -# LiveKit Server — поддержка 2K и высокого битрейта -# Ключи можно переопределить через LIVEKIT_KEYS в docker-compose - -port: 7880 -keys: - APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf -rtc: - port_range_start: 50000 - port_range_end: 60000 - tcp_port: 7881 - use_external_ip: false - # Буферы для видео (по умолчанию 500) — чуть выше для 2K/высокого битрейта - packet_buffer_size_video: 600 - packet_buffer_size_audio: 200 - congestion_control: - enabled: true - allow_pause: true - allow_tcp_fallback: true - -room: - auto_create: true - empty_timeout: 300 - max_participants: 50 - -logging: - level: info - sample: false +# LiveKit Server — поддержка 2K и высокого битрейта +# Ключи можно переопределить через LIVEKIT_KEYS в docker-compose + +port: 7880 +keys: + APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf +rtc: + port_range_start: 50000 + port_range_end: 60000 + tcp_port: 7881 + use_external_ip: false + # Буферы для видео (по умолчанию 500) — чуть выше для 2K/высокого битрейта + packet_buffer_size_video: 600 + packet_buffer_size_audio: 200 + congestion_control: + enabled: true + allow_pause: true + allow_tcp_fallback: true + +room: + auto_create: true + empty_timeout: 300 + max_participants: 50 + +logging: + level: info + sample: false diff --git a/etc/docker/daemon.json.example b/etc/docker/daemon.json.example index 34de7cd..b40901d 100644 --- a/etc/docker/daemon.json.example +++ b/etc/docker/daemon.json.example @@ -1,13 +1,13 @@ -{ - "builder": { - "gc": { - "defaultKeepStorage": "10GB", - "enabled": true - } - }, - "log-driver": "json-file", - "log-opts": { - "max-size": "10m", - "max-file": "3" - } -} +{ + "builder": { + "gc": { + "defaultKeepStorage": "10GB", + "enabled": true + } + }, + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} diff --git a/front_material/.dockerignore b/front_material/.dockerignore index 13fadf8..2665ece 100644 --- a/front_material/.dockerignore +++ b/front_material/.dockerignore @@ -1,25 +1,25 @@ -node_modules -.next -.git -.gitignore -*.md -.env*.local -.env -.env.* -.DS_Store -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.vercel -coverage -.nyc_output -.vscode -.idea -docs -.cursor -agent-transcripts -__pycache__ -*.pyc -.pytest_cache -.mypy_cache +node_modules +.next +.git +.gitignore +*.md +.env*.local +.env +.env.* +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.vercel +coverage +.nyc_output +.vscode +.idea +docs +.cursor +agent-transcripts +__pycache__ +*.pyc +.pytest_cache +.mypy_cache diff --git a/front_material/Dockerfile b/front_material/Dockerfile index 58b931c..92f9f00 100644 --- a/front_material/Dockerfile +++ b/front_material/Dockerfile @@ -72,7 +72,8 @@ ENV NEXT_PUBLIC_EXCALIDRAW_URL=$NEXT_PUBLIC_EXCALIDRAW_URL COPY package*.json ./ # Устанавливаем все зависимости для сборки -RUN npm ci +# npm install вместо npm ci: package-lock.json может быть не синхронизирован после добавления driver.js +RUN npm install # Копируем исходный код COPY . . diff --git a/front_material/STRUCTURE.md b/front_material/STRUCTURE.md index 073867f..37e8a77 100644 --- a/front_material/STRUCTURE.md +++ b/front_material/STRUCTURE.md @@ -117,6 +117,7 @@ front_material/ │ ├── contexts/ # React Context │ ├── AuthContext.tsx # Контекст аутентификации +│ ├── OnboardingContext.tsx # Онбординг-туры (Driver.js, привязка к страницам и ролям) │ ├── ThemeContext.tsx # Контекст темы (light/dark) │ └── SelectedChildContext.tsx # Контекст выбранного ребенка (для родителей) │ @@ -143,6 +144,7 @@ front_material/ │ ├── lib/ # Утилиты │ ├── material-components.ts # Импорт всех Material компонентов +│ ├── onboarding-steps.ts # Шаги онбординга по страницам/ролям (ментор, студент, родитель) │ └── utils.ts # Вспомогательные функции │ ├── styles/ # CSS стили diff --git a/front_material/api/auth.ts b/front_material/api/auth.ts index 392c8f3..bc80355 100644 --- a/front_material/api/auth.ts +++ b/front_material/api/auth.ts @@ -44,6 +44,7 @@ export interface User { language?: string; city?: string; country?: string; + onboarding_tours_seen?: Record; } /** diff --git a/front_material/api/profile.ts b/front_material/api/profile.ts index fbe1a30..587313b 100644 --- a/front_material/api/profile.ts +++ b/front_material/api/profile.ts @@ -17,6 +17,9 @@ export interface MentorHomeworkAISettings { ai_trust_publish?: boolean; } +/** Прогресс онбординга: страница → просмотрено */ +export type OnboardingToursSeen = Record; + export interface ProfileSettings { preferences: { timezone?: string; @@ -30,6 +33,8 @@ export interface ProfileSettings { }; /** Только для ментора: доверие AI при проверке ДЗ */ mentor_homework_ai?: MentorHomeworkAISettings; + /** Просмотренные туры онбординга по страницам */ + onboarding_tours_seen?: OnboardingToursSeen; } export async function getProfileSettings(): Promise { diff --git a/front_material/api/schedule.ts b/front_material/api/schedule.ts index 170b640..d1befe7 100644 --- a/front_material/api/schedule.ts +++ b/front_material/api/schedule.ts @@ -1,260 +1,260 @@ -/** - * API модуль для расписания занятий - */ - -import apiClient from '@/lib/api-client'; - -export interface Lesson { - id: string; - title: string; - subject?: string; - description?: string; - start_time: string; - end_time: string; - status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; - mentor?: { - id: string; - first_name: string; - last_name: string; - email: string; - }; - client?: { - id: string; - user?: { - id: string; - first_name: string; - last_name: string; - email: string; - }; - }; - client_name?: string; - mentor_notes?: string; - mentor_grade?: number; - school_grade?: number; - homework_text?: string; - price?: number; - meeting_url?: string; - duration?: number; - group?: number; - group_name?: string; - livekit_room_name?: string; - completed_at?: string; -} - -/** Файл урока (для экрана завершения занятия) */ -export interface LessonFile { - id: string | number; - lesson: string | number; - file?: string; - material?: string | number; - source?: 'uploaded' | 'material'; - filename: string; - file_size?: number; - file_size_display?: string; - file_url?: string; - description?: string; - uploaded_by?: number; - uploaded_by_name?: string; - created_at?: string; -} - -export interface CreateLessonFileData { - lesson: string; - file?: File; - material?: string; - filename?: string; - description?: string; -} - -/** - * Получить список занятий - * Для родителя передать child_id (user_id ребёнка). - * Для ментора передать client_id (Client.id) — занятия конкретного студента. - */ -export async function getLessons(params?: { - start_date?: string; - end_date?: string; - status?: string; - child_id?: string; - client_id?: string; -}): Promise<{ results: Lesson[]; count?: number }> { - const queryParams = new URLSearchParams(); - if (params?.start_date) queryParams.append('start_date', params.start_date); - if (params?.end_date) queryParams.append('end_date', params.end_date); - if (params?.status) queryParams.append('status', params.status); - if (params?.child_id) queryParams.append('child_id', params.child_id); - if (params?.client_id) queryParams.append('client_id', params.client_id); - - const queryString = queryParams.toString(); - const url = `/schedule/lessons/${queryString ? `?${queryString}` : ''}`; - const response = await apiClient.get(url); - - if (Array.isArray(response.data)) { - return { results: response.data }; - } - return response.data; -} - -/** Ответ calendar API */ -interface CalendarResponse { - success: boolean; - data: { start_date: string; end_date: string; lessons: Lesson[]; total: number }; -} - -/** - * Занятия для календаря (лёгкий endpoint по диапазону дат). - * Для родителя передать child_id (user_id ребёнка). - */ -export async function getLessonsCalendar(params: { - start_date: string; - end_date: string; - status?: string; - child_id?: string; -}): Promise<{ lessons: Lesson[] }> { - const q = new URLSearchParams({ start_date: params.start_date, end_date: params.end_date }); - if (params.status) q.append('status', params.status); - if (params.child_id) q.append('child_id', params.child_id); - // cache: false — после создания/редактирования/удаления занятия интерфейс должен обновиться без перезагрузки - const res = await apiClient.get(`/schedule/lessons/calendar/?${q}`, { cache: false }); - const lessons = res.data?.data?.lessons; - return { lessons: Array.isArray(lessons) ? lessons : [] }; -} - -/** - * Получить занятие по ID - */ -export async function getLesson(id: string): Promise { - const response = await apiClient.get(`/schedule/lessons/${id}/`); - return response.data; -} - -export interface CreateLessonData { - client: string; - title?: string; - description?: string; - start_time: string; - duration: number; - price?: number; - is_recurring?: boolean; - subject_id?: number; - mentor_subject_id?: number; - subject_name?: string; -} - -export interface UpdateLessonData { - title?: string; - description?: string; - start_time?: string; - duration?: number; - price?: number; - /** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */ - status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; -} - -/** - * Создать занятие - */ -export async function createLesson(data: CreateLessonData): Promise { - const response = await apiClient.post('/schedule/lessons/', data); - return response.data; -} - -/** - * Обновить занятие - */ -export async function updateLesson(id: string, data: UpdateLessonData): Promise { - const response = await apiClient.patch(`/schedule/lessons/${id}/`, data); - return response.data; -} - -/** - * Удалить занятие - */ -export async function deleteLesson(id: string, deleteAllFuture = false): Promise { - await apiClient.delete(`/schedule/lessons/${id}/`, { - data: { delete_all_future: deleteAllFuture }, - }); -} - -/** Ответ API завершения занятия */ -export interface CompleteLessonResponse { - success: boolean; - message?: string; - data?: Lesson; -} - -/** - * Завершить занятие / обновить обратную связь. - * lessonFileIds — ID файлов урока, которые нужно привязать к ДЗ (только они попадут в «Файлы задания»). - */ -export async function completeLesson( - id: string, - notes?: string, - mentorGrade?: number, - schoolGrade?: number, - homeworkText?: string, - hasHomeworkFiles?: boolean, - lessonFileIds?: number[] -): Promise { - const body: Record = { - notes: notes ?? '', - mentor_grade: mentorGrade, - school_grade: schoolGrade, - homework_text: homeworkText, - has_homework_files: hasHomeworkFiles, - }; - if (lessonFileIds != null) { - body.lesson_file_ids = lessonFileIds; - } - const response = await apiClient.post(`/schedule/lessons/${id}/complete/`, body); - return response.data; -} - -/** - * Получить файлы урока (для экрана завершения занятия). - */ -export async function getLessonFiles(lessonId: string): Promise { - const response = await apiClient.get( - `/schedule/lesson-files/?lesson=${lessonId}` - ); - const data = response.data; - if (Array.isArray(data)) return data; - return (data as { results: LessonFile[] })?.results ?? []; -} - -/** - * Создать файл урока (загрузка файла или привязка материала). - */ -export async function createLessonFile(data: CreateLessonFileData): Promise { - const formData = new FormData(); - formData.append('lesson', data.lesson); - if (data.file) formData.append('file', data.file); - if (data.material) formData.append('material', data.material); - if (data.filename) formData.append('filename', data.filename); - if (data.description) formData.append('description', data.description); - const response = await apiClient.post('/schedule/lesson-files/', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - return response.data; -} - -/** - * Удалить файл урока. - */ -export async function deleteLessonFile(fileId: string): Promise { - await apiClient.delete(`/schedule/lesson-files/${fileId}/`); -} - -/** - * Прикрепить файл к уроку (для ДЗ при завершении занятия). - * Возвращает созданный LessonFile (нужен id для передачи в complete как lesson_file_ids). - */ -export async function uploadLessonFile(lessonId: number | string, file: File): Promise { - const formData = new FormData(); - formData.append('lesson', String(lessonId)); - formData.append('file', file); - const response = await apiClient.post('/schedule/lesson-files/', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - return response.data; -} +/** + * API модуль для расписания занятий + */ + +import apiClient from '@/lib/api-client'; + +export interface Lesson { + id: string; + title: string; + subject?: string; + description?: string; + start_time: string; + end_time: string; + status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; + mentor?: { + id: string; + first_name: string; + last_name: string; + email: string; + }; + client?: { + id: string; + user?: { + id: string; + first_name: string; + last_name: string; + email: string; + }; + }; + client_name?: string; + mentor_notes?: string; + mentor_grade?: number; + school_grade?: number; + homework_text?: string; + price?: number; + meeting_url?: string; + duration?: number; + group?: number; + group_name?: string; + livekit_room_name?: string; + completed_at?: string; +} + +/** Файл урока (для экрана завершения занятия) */ +export interface LessonFile { + id: string | number; + lesson: string | number; + file?: string; + material?: string | number; + source?: 'uploaded' | 'material'; + filename: string; + file_size?: number; + file_size_display?: string; + file_url?: string; + description?: string; + uploaded_by?: number; + uploaded_by_name?: string; + created_at?: string; +} + +export interface CreateLessonFileData { + lesson: string; + file?: File; + material?: string; + filename?: string; + description?: string; +} + +/** + * Получить список занятий + * Для родителя передать child_id (user_id ребёнка). + * Для ментора передать client_id (Client.id) — занятия конкретного студента. + */ +export async function getLessons(params?: { + start_date?: string; + end_date?: string; + status?: string; + child_id?: string; + client_id?: string; +}): Promise<{ results: Lesson[]; count?: number }> { + const queryParams = new URLSearchParams(); + if (params?.start_date) queryParams.append('start_date', params.start_date); + if (params?.end_date) queryParams.append('end_date', params.end_date); + if (params?.status) queryParams.append('status', params.status); + if (params?.child_id) queryParams.append('child_id', params.child_id); + if (params?.client_id) queryParams.append('client_id', params.client_id); + + const queryString = queryParams.toString(); + const url = `/schedule/lessons/${queryString ? `?${queryString}` : ''}`; + const response = await apiClient.get(url); + + if (Array.isArray(response.data)) { + return { results: response.data }; + } + return response.data; +} + +/** Ответ calendar API */ +interface CalendarResponse { + success: boolean; + data: { start_date: string; end_date: string; lessons: Lesson[]; total: number }; +} + +/** + * Занятия для календаря (лёгкий endpoint по диапазону дат). + * Для родителя передать child_id (user_id ребёнка). + */ +export async function getLessonsCalendar(params: { + start_date: string; + end_date: string; + status?: string; + child_id?: string; +}): Promise<{ lessons: Lesson[] }> { + const q = new URLSearchParams({ start_date: params.start_date, end_date: params.end_date }); + if (params.status) q.append('status', params.status); + if (params.child_id) q.append('child_id', params.child_id); + // cache: false — после создания/редактирования/удаления занятия интерфейс должен обновиться без перезагрузки + const res = await apiClient.get(`/schedule/lessons/calendar/?${q}`, { cache: false }); + const lessons = res.data?.data?.lessons; + return { lessons: Array.isArray(lessons) ? lessons : [] }; +} + +/** + * Получить занятие по ID + */ +export async function getLesson(id: string): Promise { + const response = await apiClient.get(`/schedule/lessons/${id}/`); + return response.data; +} + +export interface CreateLessonData { + client: string; + title?: string; + description?: string; + start_time: string; + duration: number; + price?: number; + is_recurring?: boolean; + subject_id?: number; + mentor_subject_id?: number; + subject_name?: string; +} + +export interface UpdateLessonData { + title?: string; + description?: string; + start_time?: string; + duration?: number; + price?: number; + /** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */ + status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; +} + +/** + * Создать занятие + */ +export async function createLesson(data: CreateLessonData): Promise { + const response = await apiClient.post('/schedule/lessons/', data); + return response.data; +} + +/** + * Обновить занятие + */ +export async function updateLesson(id: string, data: UpdateLessonData): Promise { + const response = await apiClient.patch(`/schedule/lessons/${id}/`, data); + return response.data; +} + +/** + * Удалить занятие + */ +export async function deleteLesson(id: string, deleteAllFuture = false): Promise { + await apiClient.delete(`/schedule/lessons/${id}/`, { + data: { delete_all_future: deleteAllFuture }, + }); +} + +/** Ответ API завершения занятия */ +export interface CompleteLessonResponse { + success: boolean; + message?: string; + data?: Lesson; +} + +/** + * Завершить занятие / обновить обратную связь. + * lessonFileIds — ID файлов урока, которые нужно привязать к ДЗ (только они попадут в «Файлы задания»). + */ +export async function completeLesson( + id: string, + notes?: string, + mentorGrade?: number, + schoolGrade?: number, + homeworkText?: string, + hasHomeworkFiles?: boolean, + lessonFileIds?: number[] +): Promise { + const body: Record = { + notes: notes ?? '', + mentor_grade: mentorGrade, + school_grade: schoolGrade, + homework_text: homeworkText, + has_homework_files: hasHomeworkFiles, + }; + if (lessonFileIds != null) { + body.lesson_file_ids = lessonFileIds; + } + const response = await apiClient.post(`/schedule/lessons/${id}/complete/`, body); + return response.data; +} + +/** + * Получить файлы урока (для экрана завершения занятия). + */ +export async function getLessonFiles(lessonId: string): Promise { + const response = await apiClient.get( + `/schedule/lesson-files/?lesson=${lessonId}` + ); + const data = response.data; + if (Array.isArray(data)) return data; + return (data as { results: LessonFile[] })?.results ?? []; +} + +/** + * Создать файл урока (загрузка файла или привязка материала). + */ +export async function createLessonFile(data: CreateLessonFileData): Promise { + const formData = new FormData(); + formData.append('lesson', data.lesson); + if (data.file) formData.append('file', data.file); + if (data.material) formData.append('material', data.material); + if (data.filename) formData.append('filename', data.filename); + if (data.description) formData.append('description', data.description); + const response = await apiClient.post('/schedule/lesson-files/', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; +} + +/** + * Удалить файл урока. + */ +export async function deleteLessonFile(fileId: string): Promise { + await apiClient.delete(`/schedule/lesson-files/${fileId}/`); +} + +/** + * Прикрепить файл к уроку (для ДЗ при завершении занятия). + * Возвращает созданный LessonFile (нужен id для передачи в complete как lesson_file_ids). + */ +export async function uploadLessonFile(lessonId: number | string, file: File): Promise { + const formData = new FormData(); + formData.append('lesson', String(lessonId)); + formData.append('file', file); + const response = await apiClient.post('/schedule/lesson-files/', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; +} diff --git a/front_material/app/(auth)/forgot-password/page.tsx b/front_material/app/(auth)/forgot-password/page.tsx index ad830b4..c8f0388 100644 --- a/front_material/app/(auth)/forgot-password/page.tsx +++ b/front_material/app/(auth)/forgot-password/page.tsx @@ -1,145 +1,145 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { requestPasswordReset } from '@/api/auth'; -import { getErrorMessage } from '@/lib/error-utils'; - -const loadMaterialComponents = async () => { - await Promise.all([ - import('@material/web/textfield/filled-text-field.js'), - import('@material/web/button/filled-button.js'), - import('@material/web/button/text-button.js'), - ]); -}; - -export default function ForgotPasswordPage() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); - const [componentsLoaded, setComponentsLoaded] = useState(false); - - useEffect(() => { - loadMaterialComponents() - .then(() => setComponentsLoaded(true)) - .catch((err) => { - console.error('Error loading components:', err); - setComponentsLoaded(true); - }); - }, []); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - setSuccess(false); - - try { - await requestPasswordReset({ email }); - setSuccess(true); - } catch (err: any) { - setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.')); - } finally { - setLoading(false); - } - }; - - if (!componentsLoaded) { - return ( -
-
-
- ); - } - - return ( -
- -

- Восстановление пароля -

- - {success ? ( - <> -
- Инструкции по восстановлению пароля отправлены на ваш email. -
- router.push('/login')} - style={{ width: '100%', height: '48px' }} - > - Вернуться к входу - - - ) : ( - <> -

- Введите ваш email для восстановления пароля -

-
-
- setEmail(e.target.value || '')} - required - style={{ width: '100%' }} - /> -
- - {error && ( -
- {error} -
- )} - - - {loading ? 'Отправка...' : 'Отправить'} - - -
- router.push('/login')} style={{ fontSize: '14px' }}> - Вернуться к входу - -
-
- - )} -
- ); -} +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { requestPasswordReset } from '@/api/auth'; +import { getErrorMessage } from '@/lib/error-utils'; + +const loadMaterialComponents = async () => { + await Promise.all([ + import('@material/web/textfield/filled-text-field.js'), + import('@material/web/button/filled-button.js'), + import('@material/web/button/text-button.js'), + ]); +}; + +export default function ForgotPasswordPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [componentsLoaded, setComponentsLoaded] = useState(false); + + useEffect(() => { + loadMaterialComponents() + .then(() => setComponentsLoaded(true)) + .catch((err) => { + console.error('Error loading components:', err); + setComponentsLoaded(true); + }); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + setSuccess(false); + + try { + await requestPasswordReset({ email }); + setSuccess(true); + } catch (err: any) { + setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.')); + } finally { + setLoading(false); + } + }; + + if (!componentsLoaded) { + return ( +
+
+
+ ); + } + + return ( +
+ +

+ Восстановление пароля +

+ + {success ? ( + <> +
+ Инструкции по восстановлению пароля отправлены на ваш email. +
+ router.push('/login')} + style={{ width: '100%', height: '48px' }} + > + Вернуться к входу + + + ) : ( + <> +

+ Введите ваш email для восстановления пароля +

+
+
+ setEmail(e.target.value || '')} + required + style={{ width: '100%' }} + /> +
+ + {error && ( +
+ {error} +
+ )} + + + {loading ? 'Отправка...' : 'Отправить'} + + +
+ router.push('/login')} style={{ fontSize: '14px' }}> + Вернуться к входу + +
+
+ + )} +
+ ); +} diff --git a/front_material/app/(auth)/reset-password/page.tsx b/front_material/app/(auth)/reset-password/page.tsx index 5b49177..753d699 100644 --- a/front_material/app/(auth)/reset-password/page.tsx +++ b/front_material/app/(auth)/reset-password/page.tsx @@ -1,214 +1,214 @@ -'use client'; - -import { useState, useEffect, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { confirmPasswordReset } from '@/api/auth'; -import { getErrorMessage } from '@/lib/error-utils'; - -const loadMaterialComponents = async () => { - await Promise.all([ - import('@material/web/textfield/filled-text-field.js'), - import('@material/web/button/filled-button.js'), - import('@material/web/button/text-button.js'), - ]); -}; - -function ResetPasswordContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams.get('token'); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); - const [componentsLoaded, setComponentsLoaded] = useState(false); - - useEffect(() => { - loadMaterialComponents().then(() => setComponentsLoaded(true)); - }, []); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!token) { - setError('Отсутствует ссылка для сброса пароля. Запросите восстановление пароля снова.'); - return; - } - if (password !== confirmPassword) { - setError('Пароли не совпадают'); - return; - } - setLoading(true); - setError(''); - try { - await confirmPasswordReset(token, password, confirmPassword); - setSuccess(true); - } catch (err: any) { - setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.')); - } finally { - setLoading(false); - } - }; - - if (!componentsLoaded) { - return ( -
-

- Uchill -

-

Загрузка...

-
-
-
-
- ); - } - - if (!token) { - return ( -
-

- Uchill -

-

- Сброс пароля -

-
- Отсутствует ссылка для сброса пароля. Перейдите по ссылке из письма или запросите восстановление пароля снова. -
- router.push('/forgot-password')} style={{ width: '100%', height: '48px' }}> - Восстановить пароль - -
- router.push('/login')} style={{ fontSize: '14px' }}> - На страницу входа - -
-
- ); - } - - if (success) { - return ( -
-

- Uchill -

-

- Сброс пароля -

-
- Пароль успешно изменён. Войдите с новым паролем. -
- router.push('/login')} style={{ width: '100%', height: '48px' }}> - Войти - -
- ); - } - - return ( -
- -

- Введите новый пароль -

- -
-
- setPassword(e.target.value || '')} - required - style={{ width: '100%' }} - /> -
-
- setConfirmPassword(e.target.value || '')} - required - style={{ width: '100%' }} - /> -
- - {error && ( -
- {error} -
- )} - - - {loading ? 'Сохранение...' : 'Сохранить пароль'} - - -
- router.push('/login')} style={{ fontSize: '14px' }}> - На страницу входа - -
-
-
- ); -} - -export default function ResetPasswordPage() { - return ( - -

Загрузка...

-
- } - > - - - ); -} +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { confirmPasswordReset } from '@/api/auth'; +import { getErrorMessage } from '@/lib/error-utils'; + +const loadMaterialComponents = async () => { + await Promise.all([ + import('@material/web/textfield/filled-text-field.js'), + import('@material/web/button/filled-button.js'), + import('@material/web/button/text-button.js'), + ]); +}; + +function ResetPasswordContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [componentsLoaded, setComponentsLoaded] = useState(false); + + useEffect(() => { + loadMaterialComponents().then(() => setComponentsLoaded(true)); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!token) { + setError('Отсутствует ссылка для сброса пароля. Запросите восстановление пароля снова.'); + return; + } + if (password !== confirmPassword) { + setError('Пароли не совпадают'); + return; + } + setLoading(true); + setError(''); + try { + await confirmPasswordReset(token, password, confirmPassword); + setSuccess(true); + } catch (err: any) { + setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.')); + } finally { + setLoading(false); + } + }; + + if (!componentsLoaded) { + return ( +
+

+ Uchill +

+

Загрузка...

+
+
+
+
+ ); + } + + if (!token) { + return ( +
+

+ Uchill +

+

+ Сброс пароля +

+
+ Отсутствует ссылка для сброса пароля. Перейдите по ссылке из письма или запросите восстановление пароля снова. +
+ router.push('/forgot-password')} style={{ width: '100%', height: '48px' }}> + Восстановить пароль + +
+ router.push('/login')} style={{ fontSize: '14px' }}> + На страницу входа + +
+
+ ); + } + + if (success) { + return ( +
+

+ Uchill +

+

+ Сброс пароля +

+
+ Пароль успешно изменён. Войдите с новым паролем. +
+ router.push('/login')} style={{ width: '100%', height: '48px' }}> + Войти + +
+ ); + } + + return ( +
+ +

+ Введите новый пароль +

+ +
+
+ setPassword(e.target.value || '')} + required + style={{ width: '100%' }} + /> +
+
+ setConfirmPassword(e.target.value || '')} + required + style={{ width: '100%' }} + /> +
+ + {error && ( +
+ {error} +
+ )} + + + {loading ? 'Сохранение...' : 'Сохранить пароль'} + + +
+ router.push('/login')} style={{ fontSize: '14px' }}> + На страницу входа + +
+
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + +

Загрузка...

+
+ } + > + + + ); +} diff --git a/front_material/app/(auth)/verify-email/page.tsx b/front_material/app/(auth)/verify-email/page.tsx index 02d01d3..b64b131 100644 --- a/front_material/app/(auth)/verify-email/page.tsx +++ b/front_material/app/(auth)/verify-email/page.tsx @@ -1,174 +1,174 @@ -'use client'; - -import { useState, useEffect, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { verifyEmail } from '@/api/auth'; -import { getErrorMessage } from '@/lib/error-utils'; - -const loadMaterialComponents = async () => { - await Promise.all([ - import('@material/web/button/filled-button.js'), - import('@material/web/button/text-button.js'), - ]); -}; - -function VerifyEmailContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams.get('token'); - const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); - const [message, setMessage] = useState(''); - const [componentsLoaded, setComponentsLoaded] = useState(false); - - useEffect(() => { - loadMaterialComponents().then(() => setComponentsLoaded(true)); - }, []); - - useEffect(() => { - if (!componentsLoaded || !token) { - if (!token) { - setStatus('error'); - setMessage('Отсутствует ссылка для подтверждения. Проверьте письмо или запросите новое.'); - } - return; - } - - let cancelled = false; - verifyEmail(token) - .then((res) => { - if (cancelled) return; - if (res.success) { - setStatus('success'); - setMessage('Email успешно подтверждён. Теперь вы можете войти в аккаунт.'); - } else { - setStatus('error'); - setMessage(res.message || 'Не удалось подтвердить email.'); - } - }) - .catch((err: any) => { - if (cancelled) return; - setStatus('error'); - setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.')); - }); - - return () => { - cancelled = true; - }; - }, [token, componentsLoaded]); - - if (!componentsLoaded) { - return ( -
-

- Uchill -

-

- Подтверждение email... -

-
-
-
-
- ); - } - - return ( -
- -

- Подтверждение email -

- - {status === 'loading' && ( -
-
-
- )} - - {status === 'success' && ( - <> -
- {message} -
- router.push('/login')} - style={{ width: '100%', height: '48px' }} - > - Войти в аккаунт - - - )} - - {status === 'error' && ( - <> -
- {message} -
- router.push('/login')} - style={{ width: '100%', height: '48px', marginBottom: '12px' }} - > - На страницу входа - -
- router.push('/register')} style={{ fontSize: '14px' }}> - Зарегистрироваться - -
- - )} -
- ); -} - -export default function VerifyEmailPage() { - return ( - -

Загрузка...

-
- } - > - - - ); -} +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { verifyEmail } from '@/api/auth'; +import { getErrorMessage } from '@/lib/error-utils'; + +const loadMaterialComponents = async () => { + await Promise.all([ + import('@material/web/button/filled-button.js'), + import('@material/web/button/text-button.js'), + ]); +}; + +function VerifyEmailContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [message, setMessage] = useState(''); + const [componentsLoaded, setComponentsLoaded] = useState(false); + + useEffect(() => { + loadMaterialComponents().then(() => setComponentsLoaded(true)); + }, []); + + useEffect(() => { + if (!componentsLoaded || !token) { + if (!token) { + setStatus('error'); + setMessage('Отсутствует ссылка для подтверждения. Проверьте письмо или запросите новое.'); + } + return; + } + + let cancelled = false; + verifyEmail(token) + .then((res) => { + if (cancelled) return; + if (res.success) { + setStatus('success'); + setMessage('Email успешно подтверждён. Теперь вы можете войти в аккаунт.'); + } else { + setStatus('error'); + setMessage(res.message || 'Не удалось подтвердить email.'); + } + }) + .catch((err: any) => { + if (cancelled) return; + setStatus('error'); + setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.')); + }); + + return () => { + cancelled = true; + }; + }, [token, componentsLoaded]); + + if (!componentsLoaded) { + return ( +
+

+ Uchill +

+

+ Подтверждение email... +

+
+
+
+
+ ); + } + + return ( +
+ +

+ Подтверждение email +

+ + {status === 'loading' && ( +
+
+
+ )} + + {status === 'success' && ( + <> +
+ {message} +
+ router.push('/login')} + style={{ width: '100%', height: '48px' }} + > + Войти в аккаунт + + + )} + + {status === 'error' && ( + <> +
+ {message} +
+ router.push('/login')} + style={{ width: '100%', height: '48px', marginBottom: '12px' }} + > + На страницу входа + +
+ router.push('/register')} style={{ fontSize: '14px' }}> + Зарегистрироваться + +
+ + )} +
+ ); +} + +export default function VerifyEmailPage() { + return ( + +

Загрузка...

+
+ } + > + + + ); +} diff --git a/front_material/app/(protected)/analytics/page.tsx b/front_material/app/(protected)/analytics/page.tsx index 87864bd..c783069 100644 --- a/front_material/app/(protected)/analytics/page.tsx +++ b/front_material/app/(protected)/analytics/page.tsx @@ -174,7 +174,7 @@ export default function AnalyticsPage() { ); return ( - +
+
+ {error && (
+
{!isFullWidthPage && }
)}
+
); diff --git a/front_material/app/(protected)/materials/page.tsx b/front_material/app/(protected)/materials/page.tsx index 96a7e38..7b15d9a 100644 --- a/front_material/app/(protected)/materials/page.tsx +++ b/front_material/app/(protected)/materials/page.tsx @@ -573,7 +573,7 @@ export default function MaterialsPage() { } return ( -
+
setAddPanelOpen(true)} style={{ display: 'inline-flex', diff --git a/front_material/app/(protected)/my-progress/page.tsx b/front_material/app/(protected)/my-progress/page.tsx index bd032ec..e23e110 100644 --- a/front_material/app/(protected)/my-progress/page.tsx +++ b/front_material/app/(protected)/my-progress/page.tsx @@ -288,7 +288,7 @@ export default function MyProgressPage() { return (
- + {/* Ячейка 1: Общая статистика за период + выбор предмета и даты */} +

Подписки и оплата

diff --git a/front_material/app/(protected)/profile/page.tsx b/front_material/app/(protected)/profile/page.tsx index 0f3f45f..44c8172 100644 --- a/front_material/app/(protected)/profile/page.tsx +++ b/front_material/app/(protected)/profile/page.tsx @@ -24,6 +24,7 @@ import { ProfilePaymentTab } from '@/components/profile/ProfilePaymentTab'; import { NotificationSettingsSection } from '@/components/profile/NotificationSettingsSection'; import { ParentChildNotificationSettings } from '@/components/profile/ParentChildNotificationSettings'; import { TelegramSection } from '@/components/profile/TelegramSection'; +import { OnboardingTipsSection } from '@/components/profile/OnboardingTipsSection'; import { Switch } from '@/components/common/Switch'; function getAvatarUrl(user: { avatar_url?: string | null; avatar?: string | null } | null): string | null { @@ -382,6 +383,7 @@ function ProfilePage() { return (
+ {(user?.role === 'mentor' || user?.role === 'client') && ( +
+ +
+ )}

Настройки уведомлений

diff --git a/front_material/app/(protected)/referrals/page.tsx b/front_material/app/(protected)/referrals/page.tsx index 35a14eb..fac018a 100644 --- a/front_material/app/(protected)/referrals/page.tsx +++ b/front_material/app/(protected)/referrals/page.tsx @@ -6,6 +6,7 @@ export default function ReferralsPage() { return (
{/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */}
{ - const start = startOfMonth(subMonths(visibleMonth, 1)); - const end = endOfMonth(addMonths(visibleMonth, 1)); - const isInitial = !hasLoadedLessonsOnceRef.current; - try { - if (isInitial) setLessonsLoading(true); - setError(null); - const { lessons: lessonsData } = await getLessonsCalendar({ - start_date: format(start, 'yyyy-MM-dd'), - end_date: format(end, 'yyyy-MM-dd'), - ...(selectedChild?.id && { child_id: selectedChild.id }), - }); - const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({ - id: lesson.id, - title: lesson.title, - start_time: lesson.start_time, - end_time: lesson.end_time, - status: lesson.status, - client: lesson.client?.id, - client_name: lesson.client_name ?? (lesson.client?.user - ? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim() - : undefined), - mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name - ? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim() - : undefined), - subject: lesson.subject ?? lesson.subject_name ?? '', - })); - setLessons(mappedLessons); - hasLoadedLessonsOnceRef.current = true; - } catch (err: any) { - console.error('Error loading lessons:', err); - setError(err?.message || 'Ошибка загрузки занятий'); - } finally { - if (isInitial) setLessonsLoading(false); - } - }, [visibleMonth, selectedChild?.id]); + const loadLessons = useCallback( + async (merge?: boolean) => { + const start = startOfMonth(subMonths(visibleMonth, 1)); + const end = endOfMonth(addMonths(visibleMonth, 1)); + const doMerge = merge ?? hasLoadedLessonsOnceRef.current; + const isInitial = !hasLoadedLessonsOnceRef.current && !doMerge; + try { + setLessonsLoading(true); + setError(null); + const { lessons: lessonsData } = await getLessonsCalendar({ + start_date: format(start, 'yyyy-MM-dd'), + end_date: format(end, 'yyyy-MM-dd'), + ...(selectedChild?.id && { child_id: selectedChild.id }), + }); + const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({ + id: lesson.id, + title: lesson.title, + start_time: lesson.start_time, + end_time: lesson.end_time, + status: lesson.status, + client: lesson.client?.id, + client_name: lesson.client_name ?? (lesson.client?.user + ? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim() + : undefined), + mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name + ? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim() + : undefined), + subject: lesson.subject ?? lesson.subject_name ?? '', + })); + if (doMerge) { + setLessons((prev) => { + const startStr = format(start, 'yyyy-MM-dd'); + const endStr = format(end, 'yyyy-MM-dd'); + const byId = new Map(); + prev.forEach((l) => { + const lessonDateStr = l.start_time?.slice(0, 10) ?? ''; + if (lessonDateStr < startStr || lessonDateStr > endStr) { + byId.set(String(l.id), l); + } + }); + mappedLessons.forEach((l) => byId.set(String(l.id), l)); + return Array.from(byId.values()); + }); + } else { + setLessons(mappedLessons); + } + hasLoadedLessonsOnceRef.current = true; + } catch (err: any) { + console.error('Error loading lessons:', err); + setError(err?.message || 'Ошибка загрузки занятий'); + } finally { + setLessonsLoading(false); + } + }, + [visibleMonth, selectedChild?.id] + ); useEffect(() => { loadLessons(); @@ -442,7 +462,7 @@ export default function SchedulePage() { // чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента minHeight: 'min(calc(100vh - 160px), 600px)', }}> -
+
-
+
= ({ flexDirection: 'column', }} > - {lessonsLoading ? ( + {lessonsLoading && lessons.length === 0 ? ( ) : ( { try { const d = startOfDay(date); diff --git a/front_material/components/chat/ChatList.tsx b/front_material/components/chat/ChatList.tsx index ae0a25d..3218d42 100644 --- a/front_material/components/chat/ChatList.tsx +++ b/front_material/components/chat/ChatList.tsx @@ -60,6 +60,7 @@ export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMo return ( { - const mod10 = n % 10; - const mod100 = n % 100; - if (mod10 === 1 && mod100 !== 11) return one; - if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; - return many; - }; - - if (diffMin < 1) return 'только что'; - if (diffMin < 60) return `${diffMin} ${pluralRu(diffMin, 'минуту', 'минуты', 'минут')} назад`; - if (diffHr < 6) { - if (diffHr === 1) return 'час назад'; - return `${diffHr} ${pluralRu(diffHr, 'час', 'часа', 'часов')} назад`; - } - // больше 6 часов — показываем HH:mm - return formatTime(ts); -} - -function dateKey(ts?: string) { - if (!ts) return ''; - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return ''; - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -function formatDayHeader(ts?: string) { - if (!ts) return ''; - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return ''; - const now = new Date(); - const todayKey = dateKey(now.toISOString()); - const yKey = dateKey(new Date(now.getTime() - 24 * 3600000).toISOString()); - const k = dateKey(ts); - if (k === todayKey) return 'Сегодня'; - if (k === yKey) return 'Вчера'; - const dd = String(d.getDate()).padStart(2, '0'); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const yyyy = String(d.getFullYear()); - return `${dd}.${mm}.${yyyy}`; -} - -function formatLastSeen(iso: string | null) { - if (!iso) return null; - try { - const d = new Date(iso); - const now = new Date(); - const sameYear = d.getFullYear() === now.getFullYear(); - const sameDay = d.toDateString() === now.toDateString(); - if (sameDay) { - return `Был(а) в сети сегодня в ${d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`; - } - const datePart = d.toLocaleDateString('ru-RU', { - day: 'numeric', - month: 'long', - ...(sameYear ? {} : { year: 'numeric' }), - }); - const timePart = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); - return `Был(а) в сети ${datePart} в ${timePart}`; - } catch { - return null; - } -} - -const SYSTEM_EMOJI_PREFIX = /^[\s🔔📝🗑️🗑✅📤⚠️⏰📅📚]+/; - -function stripLeadingEmojis(s: string): string { - const t = (s || '').trim(); - return t.replace(SYSTEM_EMOJI_PREFIX, '').replace(/^[-–—•\s]+/, '').trim() || t; -} - -/** Убирает HTML-теги из строки (чтобы в чате не отображались теги в уведомлениях). */ -function stripHtml(s: string): string { - if (typeof s !== 'string') return ''; - return s.replace(/<[^>]*>/g, '').trim(); -} - -type SystemTheme = { - icon: React.ReactNode; - label: string; -}; - -function getSystemMessageTheme(content: string): SystemTheme { - const raw = (content || '').trim(); - const lower = raw.toLowerCase(); - - if (/проверено|проверен/.test(lower) && /домашнее|дз|задани/.test(lower)) { - return { icon: , label: 'ДЗ проверено' }; - } - if (/(сдано|сдал)\s|дз сдано|домашнее задание сдано/.test(lower)) { - return { icon: , label: 'ДЗ сдано' }; - } - if (/просрочено|просрочен/.test(lower)) { - return { icon: , label: 'Просрочено' }; - } - if (/напоминание|дедлайн|напоминание о дедлайне/.test(lower)) { - return { icon: , label: 'Напоминание' }; - } - if (/удалено|удален|удалил|сообщение удалено/.test(lower)) { - return { icon: , label: 'Удалено' }; - } - if (/занятие началось|занятие завершено|урок начался|урок завершен|перенесен|отменен/.test(lower)) { - return { icon: , label: 'Занятие' }; - } - if (/новое домашнее|назначено.*домашнее|домашнее задание|^дз\s|дз назначено/.test(lower)) { - return { icon: , label: 'Домашнее задание' }; - } - if (/уведомление|напоминани/.test(lower) || /^🔔/.test(raw)) { - return { icon: , label: 'Уведомление' }; - } - - return { icon: , label: 'Системное сообщение' }; -} - -export function ChatWindow({ - chat, - currentUserId, - onBack, - onMessagesMarkedAsRead, -}: { - chat: Chat | null; - currentUserId: number | null; - onBack?: () => void; - /** Вызывается после того, как часть сообщений отмечена прочитанными (для обновления счётчика в списке чатов) */ - onMessagesMarkedAsRead?: () => void; -}) { - const [messages, setMessages] = React.useState([]); - const [loading, setLoading] = React.useState(false); - const [loadingMore, setLoadingMore] = React.useState(false); - const [page, setPage] = React.useState(1); - const [hasMore, setHasMore] = React.useState(false); - const [text, setText] = React.useState(''); - const listRef = React.useRef(null); - const lastWheelUpAtRef = React.useRef(0); - const markedReadRef = React.useRef>(new Set()); - const lastSentRef = React.useRef<{ id: number | string; uuid?: string } | null>(null); - - const chatUuid = (chat as any)?.uuid || null; - const otherUserId = (chat as any)?.other_user_id ?? null; - const [presence, setPresence] = React.useState<{ is_online: boolean; last_activity: string | null } | null>(null); - - // initial from chat list + realtime updates - React.useEffect(() => { - if (!chat) return; - setPresence({ - is_online: !!(chat as any).other_is_online, - last_activity: (chat as any).other_last_activity ?? null, - }); - }, [chat]); - - // Сброс при смене чата - React.useEffect(() => { - markedReadRef.current = new Set(); - lastSentRef.current = null; - }, [chatUuid]); - - React.useEffect(() => { - if (!otherUserId) return; - const existing = getUserStatus(otherUserId); - if (existing) setPresence({ is_online: existing.is_online, last_activity: existing.last_activity }); - const unsubscribe = subscribeToUserStatus((s) => { - if (s.user_id === otherUserId) setPresence({ is_online: s.is_online, last_activity: s.last_activity }); - }); - return () => { unsubscribe?.(); }; - }, [otherUserId]); - - useChatWebSocket({ - chatUuid, - enabled: !!chatUuid, - onMessage: (m) => { - const chatId = chat?.id != null ? Number(chat.id) : null; - const msgChatId = m.chat != null ? Number(m.chat) : null; - if (chatId == null || msgChatId !== chatId) return; - const mid = (m as any).id; - const muuid = (m as any).uuid; - const sent = lastSentRef.current; - if (sent && (String(mid) === String(sent.id) || (muuid != null && sent.uuid != null && String(muuid) === String(sent.uuid)))) { - lastSentRef.current = null; - return; - } - setMessages((prev) => { - const isDuplicate = prev.some((x) => { - const sameId = mid != null && x.id != null && String(x.id) === String(mid); - const sameUuid = muuid != null && (x as any).uuid != null && String((x as any).uuid) === String(muuid); - return sameId || sameUuid; - }); - if (isDuplicate) return prev; - return [...prev, m]; - }); - }, - }); - - React.useEffect(() => { - if (!chat) return; - setLoading(true); - setLoadingMore(false); - setPage(1); - setHasMore(false); - - (async () => { - try { - const pageSize = 30; - const resp = chatUuid - ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: pageSize }) - : await getMessages(chat.id, { page: 1, page_size: pageSize }); - const initial = (resp.results || []) as Message[]; - const sorted = [...initial].sort((a: any, b: any) => { - const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; - const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; - return ta - tb; - }); - setMessages(sorted); - setHasMore(!!(resp as any).next || ((resp as any).count ?? 0) > sorted.length); - - // Молниеносный скролл вниз (мгновенно, без анимации) - requestAnimationFrame(() => { - const el = listRef.current; - if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' }); - }); - } finally { - setLoading(false); - } - })(); - }, [chat?.id, chatUuid]); - - // Отмечаем сообщения прочитанными, когда они попадают в зону скролла - React.useEffect(() => { - if (!chatUuid || !listRef.current || messages.length === 0) return; - const container = listRef.current; - const observer = new IntersectionObserver( - (entries) => { - const toMark: string[] = []; - for (const e of entries) { - if (!e.isIntersecting) continue; - const uuid = (e.target as HTMLElement).getAttribute('data-message-uuid'); - const isMine = (e.target as HTMLElement).getAttribute('data-is-mine') === 'true'; - if (uuid && !isMine && !markedReadRef.current.has(uuid)) { - toMark.push(uuid); - markedReadRef.current.add(uuid); - } - } - if (toMark.length > 0) { - markMessagesAsRead(chatUuid, toMark) - .then(() => onMessagesMarkedAsRead?.()) - .catch(() => {}); - } - }, - { root: container, rootMargin: '0px', threshold: 0.5 } - ); - const nodes = container.querySelectorAll('[data-message-uuid]'); - nodes.forEach((n) => observer.observe(n)); - return () => observer.disconnect(); - }, [chatUuid, messages, onMessagesMarkedAsRead]); - - const loadOlder = React.useCallback(async () => { - if (!chat || loading || loadingMore || !hasMore) return; - const container = listRef.current; - if (!container) return; - setLoadingMore(true); - const prevScrollHeight = container.scrollHeight; - const prevScrollTop = container.scrollTop; - - try { - const nextPage = page + 1; - const pageSize = 30; - const resp = chatUuid - ? await getChatMessagesByUuid(chatUuid, { page: nextPage, page_size: pageSize }) - : await getMessages(chat.id, { page: nextPage, page_size: pageSize }); - - const batch = (resp.results || []) as Message[]; - const sortedBatch = [...batch].sort((a: any, b: any) => { - const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; - const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; - return ta - tb; - }); - - setMessages((prev) => { - const existingKeys = new Set(prev.map((m: any) => m?.uuid || m?.id)); - const toAdd = sortedBatch.filter((m: any) => !existingKeys.has(m?.uuid || m?.id)); - const merged = [...toAdd, ...prev]; - merged.sort((a: any, b: any) => { - const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; - const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; - return ta - tb; - }); - return merged; - }); - - setPage(nextPage); - setHasMore(!!(resp as any).next); - } finally { - // сохранить позицию прокрутки - setTimeout(() => { - const c = listRef.current; - if (!c) return; - const newScrollHeight = c.scrollHeight; - c.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight); - }, 0); - setLoadingMore(false); - } - }, [chat, chatUuid, hasMore, loading, loadingMore, page]); - - const handleSend = async () => { - if (!chat) return; - const content = text.trim(); - if (!content) return; - setText(''); - try { - const msg = await sendMessage(chat.id, content); - lastSentRef.current = { id: msg.id, uuid: (msg as any).uuid }; - const safeMsg = { ...msg, created_at: (msg as any).created_at || new Date().toISOString() } as Message; - setMessages((prev) => [...prev, safeMsg]); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const el = listRef.current; - if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); - }); - }); - } catch { - // rollback - setText(content); - } - }; - - if (!chat) { - return ( - - Выберите чат из списка - - ); - } - - return ( - - {/* Header */} - - - {onBack && ( - - - arrow_back - - - )} - - {(chat.participant_name || 'Ч') - .trim() - .split(/\s+/) - .slice(0, 2) - .map((p) => p[0]) - .join('') - .toUpperCase()} - - - - {chat.participant_name || 'Чат'} - - - {presence?.is_online - ? 'Онлайн' - : formatLastSeen(presence?.last_activity ?? null) || 'Оффлайн'} - - - - - - {/* Messages */} - { - // deltaY < 0 = прокрутка вверх - if (e.deltaY < 0) lastWheelUpAtRef.current = Date.now(); - }} - onScroll={(e) => { - const el = e.currentTarget; - // Подгружаем только если: - // - до верха осталось < 40px - // - пользователь именно скроллит вверх (wheel up недавно) - const nearTop = el.scrollTop < 40; - const wheelUpRecently = Date.now() - lastWheelUpAtRef.current < 200; - if (nearTop && wheelUpRecently) loadOlder(); - }} - > - {loadingMore && ( - - Загрузка… - - )} - {loading ? ( - - Загрузка… - - ) : ( - (() => { - const out: React.ReactNode[] = []; - let prevDay = ''; - const seen = new Set(); - const uniqueMessages = messages.filter((m) => { - const k = String((m as any).uuid ?? m.id ?? ''); - if (!k || seen.has(k)) return false; - seen.add(k); - return true; - }); - - uniqueMessages.forEach((m, idx) => { - const created = (m as any).created_at as string | undefined; - const day = dateKey(created); - if (day && day !== prevDay) { - out.push( - - {formatDayHeader(created)} - - ); - prevDay = day; - } - - const senderId = - (m as any).sender_id ?? - (typeof (m as any).sender === 'number' - ? (m as any).sender - : (m as any).sender?.id ?? null); - const isMine = !!currentUserId && senderId === currentUserId; - const isSystem = - (m as any).message_type === 'system' || - (typeof (m as any).sender === 'string' && (m as any).sender.toLowerCase() === 'system') || - (!senderId && (m as any).sender_name === 'System'); - const msgContent = (m as any).content || ''; - const sysTheme = isSystem ? getSystemMessageTheme(msgContent) : null; - const sysDisplayContent = isSystem ? stripHtml(stripLeadingEmojis(msgContent)) : ''; - - const msgUuid = (m as any).uuid ? String((m as any).uuid) : null; - const msgKey = (m as any).uuid || m.id || `msg-${idx}`; - out.push( - - {isSystem && sysTheme && ( - <> - - {sysTheme.icon} - - {sysTheme.label} - - - - {sysDisplayContent || '—'} - - - )} - {!isSystem && ( - {msgContent} - )} - - {formatRelativeStamp((m as any).created_at)} - - - ); - }); - - return out; - })() - )} - - - {/* Input */} - - setText(e.target.value)} - placeholder="Сообщение…" - fullWidth - multiline - minRows={1} - maxRows={4} - sx={{ - '& .MuiInputBase-root': { - borderRadius: 3, - backgroundColor: 'rgba(255,255,255,0.85)', - }, - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }} - /> - - - - - - ); -} - +'use client'; + +import React from 'react'; +import { Avatar, Box, IconButton, TextField, Typography } from '@mui/material'; +import SendRoundedIcon from '@mui/icons-material/SendRounded'; +import AssignmentRoundedIcon from '@mui/icons-material/AssignmentRounded'; +import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; +import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; +import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded'; +import EventNoteRoundedIcon from '@mui/icons-material/EventNoteRounded'; +import NotificationsActiveRoundedIcon from '@mui/icons-material/NotificationsActiveRounded'; +import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import type { Chat, Message } from '@/api/chat'; +import { getMessages, getChatMessagesByUuid, sendMessage, markMessagesAsRead } from '@/api/chat'; +import { useChatWebSocket } from '@/hooks/useChatWebSocket'; +import { getUserStatus, subscribeToUserStatus } from '@/hooks/usePresenceWebSocket'; + +function formatTime(ts?: string) { + try { + if (!ts) return ''; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + } catch { + return ''; + } +} + +function formatRelativeStamp(ts?: string) { + if (!ts) return ''; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + if (diffMs < 0) return formatTime(ts); + const diffMin = Math.floor(diffMs / 60000); + const diffHr = Math.floor(diffMs / 3600000); + + const pluralRu = (n: number, one: string, few: string, many: string) => { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return one; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; + return many; + }; + + if (diffMin < 1) return 'только что'; + if (diffMin < 60) return `${diffMin} ${pluralRu(diffMin, 'минуту', 'минуты', 'минут')} назад`; + if (diffHr < 6) { + if (diffHr === 1) return 'час назад'; + return `${diffHr} ${pluralRu(diffHr, 'час', 'часа', 'часов')} назад`; + } + // больше 6 часов — показываем HH:mm + return formatTime(ts); +} + +function dateKey(ts?: string) { + if (!ts) return ''; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +function formatDayHeader(ts?: string) { + if (!ts) return ''; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const now = new Date(); + const todayKey = dateKey(now.toISOString()); + const yKey = dateKey(new Date(now.getTime() - 24 * 3600000).toISOString()); + const k = dateKey(ts); + if (k === todayKey) return 'Сегодня'; + if (k === yKey) return 'Вчера'; + const dd = String(d.getDate()).padStart(2, '0'); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const yyyy = String(d.getFullYear()); + return `${dd}.${mm}.${yyyy}`; +} + +function formatLastSeen(iso: string | null) { + if (!iso) return null; + try { + const d = new Date(iso); + const now = new Date(); + const sameYear = d.getFullYear() === now.getFullYear(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) { + return `Был(а) в сети сегодня в ${d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`; + } + const datePart = d.toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long', + ...(sameYear ? {} : { year: 'numeric' }), + }); + const timePart = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + return `Был(а) в сети ${datePart} в ${timePart}`; + } catch { + return null; + } +} + +const SYSTEM_EMOJI_PREFIX = /^[\s🔔📝🗑️🗑✅📤⚠️⏰📅📚]+/; + +function stripLeadingEmojis(s: string): string { + const t = (s || '').trim(); + return t.replace(SYSTEM_EMOJI_PREFIX, '').replace(/^[-–—•\s]+/, '').trim() || t; +} + +/** Убирает HTML-теги из строки (чтобы в чате не отображались теги в уведомлениях). */ +function stripHtml(s: string): string { + if (typeof s !== 'string') return ''; + return s.replace(/<[^>]*>/g, '').trim(); +} + +type SystemTheme = { + icon: React.ReactNode; + label: string; +}; + +function getSystemMessageTheme(content: string): SystemTheme { + const raw = (content || '').trim(); + const lower = raw.toLowerCase(); + + if (/проверено|проверен/.test(lower) && /домашнее|дз|задани/.test(lower)) { + return { icon: , label: 'ДЗ проверено' }; + } + if (/(сдано|сдал)\s|дз сдано|домашнее задание сдано/.test(lower)) { + return { icon: , label: 'ДЗ сдано' }; + } + if (/просрочено|просрочен/.test(lower)) { + return { icon: , label: 'Просрочено' }; + } + if (/напоминание|дедлайн|напоминание о дедлайне/.test(lower)) { + return { icon: , label: 'Напоминание' }; + } + if (/удалено|удален|удалил|сообщение удалено/.test(lower)) { + return { icon: , label: 'Удалено' }; + } + if (/занятие началось|занятие завершено|урок начался|урок завершен|перенесен|отменен/.test(lower)) { + return { icon: , label: 'Занятие' }; + } + if (/новое домашнее|назначено.*домашнее|домашнее задание|^дз\s|дз назначено/.test(lower)) { + return { icon: , label: 'Домашнее задание' }; + } + if (/уведомление|напоминани/.test(lower) || /^🔔/.test(raw)) { + return { icon: , label: 'Уведомление' }; + } + + return { icon: , label: 'Системное сообщение' }; +} + +export function ChatWindow({ + chat, + currentUserId, + onBack, + onMessagesMarkedAsRead, +}: { + chat: Chat | null; + currentUserId: number | null; + onBack?: () => void; + /** Вызывается после того, как часть сообщений отмечена прочитанными (для обновления счётчика в списке чатов) */ + onMessagesMarkedAsRead?: () => void; +}) { + const [messages, setMessages] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [loadingMore, setLoadingMore] = React.useState(false); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [text, setText] = React.useState(''); + const listRef = React.useRef(null); + const lastWheelUpAtRef = React.useRef(0); + const markedReadRef = React.useRef>(new Set()); + const lastSentRef = React.useRef<{ id: number | string; uuid?: string } | null>(null); + + const chatUuid = (chat as any)?.uuid || null; + const otherUserId = (chat as any)?.other_user_id ?? null; + const [presence, setPresence] = React.useState<{ is_online: boolean; last_activity: string | null } | null>(null); + + // initial from chat list + realtime updates + React.useEffect(() => { + if (!chat) return; + setPresence({ + is_online: !!(chat as any).other_is_online, + last_activity: (chat as any).other_last_activity ?? null, + }); + }, [chat]); + + // Сброс при смене чата + React.useEffect(() => { + markedReadRef.current = new Set(); + lastSentRef.current = null; + }, [chatUuid]); + + React.useEffect(() => { + if (!otherUserId) return; + const existing = getUserStatus(otherUserId); + if (existing) setPresence({ is_online: existing.is_online, last_activity: existing.last_activity }); + const unsubscribe = subscribeToUserStatus((s) => { + if (s.user_id === otherUserId) setPresence({ is_online: s.is_online, last_activity: s.last_activity }); + }); + return () => { unsubscribe?.(); }; + }, [otherUserId]); + + useChatWebSocket({ + chatUuid, + enabled: !!chatUuid, + onMessage: (m) => { + const chatId = chat?.id != null ? Number(chat.id) : null; + const msgChatId = m.chat != null ? Number(m.chat) : null; + if (chatId == null || msgChatId !== chatId) return; + const mid = (m as any).id; + const muuid = (m as any).uuid; + const sent = lastSentRef.current; + if (sent && (String(mid) === String(sent.id) || (muuid != null && sent.uuid != null && String(muuid) === String(sent.uuid)))) { + lastSentRef.current = null; + return; + } + setMessages((prev) => { + const isDuplicate = prev.some((x) => { + const sameId = mid != null && x.id != null && String(x.id) === String(mid); + const sameUuid = muuid != null && (x as any).uuid != null && String((x as any).uuid) === String(muuid); + return sameId || sameUuid; + }); + if (isDuplicate) return prev; + return [...prev, m]; + }); + }, + }); + + React.useEffect(() => { + if (!chat) return; + setLoading(true); + setLoadingMore(false); + setPage(1); + setHasMore(false); + + (async () => { + try { + const pageSize = 30; + const resp = chatUuid + ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: pageSize }) + : await getMessages(chat.id, { page: 1, page_size: pageSize }); + const initial = (resp.results || []) as Message[]; + const sorted = [...initial].sort((a: any, b: any) => { + const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + }); + setMessages(sorted); + setHasMore(!!(resp as any).next || ((resp as any).count ?? 0) > sorted.length); + + // Молниеносный скролл вниз (мгновенно, без анимации) + requestAnimationFrame(() => { + const el = listRef.current; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' }); + }); + } finally { + setLoading(false); + } + })(); + }, [chat?.id, chatUuid]); + + // Отмечаем сообщения прочитанными, когда они попадают в зону скролла + React.useEffect(() => { + if (!chatUuid || !listRef.current || messages.length === 0) return; + const container = listRef.current; + const observer = new IntersectionObserver( + (entries) => { + const toMark: string[] = []; + for (const e of entries) { + if (!e.isIntersecting) continue; + const uuid = (e.target as HTMLElement).getAttribute('data-message-uuid'); + const isMine = (e.target as HTMLElement).getAttribute('data-is-mine') === 'true'; + if (uuid && !isMine && !markedReadRef.current.has(uuid)) { + toMark.push(uuid); + markedReadRef.current.add(uuid); + } + } + if (toMark.length > 0) { + markMessagesAsRead(chatUuid, toMark) + .then(() => onMessagesMarkedAsRead?.()) + .catch(() => {}); + } + }, + { root: container, rootMargin: '0px', threshold: 0.5 } + ); + const nodes = container.querySelectorAll('[data-message-uuid]'); + nodes.forEach((n) => observer.observe(n)); + return () => observer.disconnect(); + }, [chatUuid, messages, onMessagesMarkedAsRead]); + + const loadOlder = React.useCallback(async () => { + if (!chat || loading || loadingMore || !hasMore) return; + const container = listRef.current; + if (!container) return; + setLoadingMore(true); + const prevScrollHeight = container.scrollHeight; + const prevScrollTop = container.scrollTop; + + try { + const nextPage = page + 1; + const pageSize = 30; + const resp = chatUuid + ? await getChatMessagesByUuid(chatUuid, { page: nextPage, page_size: pageSize }) + : await getMessages(chat.id, { page: nextPage, page_size: pageSize }); + + const batch = (resp.results || []) as Message[]; + const sortedBatch = [...batch].sort((a: any, b: any) => { + const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + }); + + setMessages((prev) => { + const existingKeys = new Set(prev.map((m: any) => m?.uuid || m?.id)); + const toAdd = sortedBatch.filter((m: any) => !existingKeys.has(m?.uuid || m?.id)); + const merged = [...toAdd, ...prev]; + merged.sort((a: any, b: any) => { + const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + }); + return merged; + }); + + setPage(nextPage); + setHasMore(!!(resp as any).next); + } finally { + // сохранить позицию прокрутки + setTimeout(() => { + const c = listRef.current; + if (!c) return; + const newScrollHeight = c.scrollHeight; + c.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight); + }, 0); + setLoadingMore(false); + } + }, [chat, chatUuid, hasMore, loading, loadingMore, page]); + + const handleSend = async () => { + if (!chat) return; + const content = text.trim(); + if (!content) return; + setText(''); + try { + const msg = await sendMessage(chat.id, content); + lastSentRef.current = { id: msg.id, uuid: (msg as any).uuid }; + const safeMsg = { ...msg, created_at: (msg as any).created_at || new Date().toISOString() } as Message; + setMessages((prev) => [...prev, safeMsg]); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const el = listRef.current; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + }); + }); + } catch { + // rollback + setText(content); + } + }; + + if (!chat) { + return ( + + Выберите чат из списка + + ); + } + + return ( + + {/* Header */} + + + {onBack && ( + + + arrow_back + + + )} + + {(chat.participant_name || 'Ч') + .trim() + .split(/\s+/) + .slice(0, 2) + .map((p) => p[0]) + .join('') + .toUpperCase()} + + + + {chat.participant_name || 'Чат'} + + + {presence?.is_online + ? 'Онлайн' + : formatLastSeen(presence?.last_activity ?? null) || 'Оффлайн'} + + + + + + {/* Messages */} + { + // deltaY < 0 = прокрутка вверх + if (e.deltaY < 0) lastWheelUpAtRef.current = Date.now(); + }} + onScroll={(e) => { + const el = e.currentTarget; + // Подгружаем только если: + // - до верха осталось < 40px + // - пользователь именно скроллит вверх (wheel up недавно) + const nearTop = el.scrollTop < 40; + const wheelUpRecently = Date.now() - lastWheelUpAtRef.current < 200; + if (nearTop && wheelUpRecently) loadOlder(); + }} + > + {loadingMore && ( + + Загрузка… + + )} + {loading ? ( + + Загрузка… + + ) : ( + (() => { + const out: React.ReactNode[] = []; + let prevDay = ''; + const seen = new Set(); + const uniqueMessages = messages.filter((m) => { + const k = String((m as any).uuid ?? m.id ?? ''); + if (!k || seen.has(k)) return false; + seen.add(k); + return true; + }); + + uniqueMessages.forEach((m, idx) => { + const created = (m as any).created_at as string | undefined; + const day = dateKey(created); + if (day && day !== prevDay) { + out.push( + + {formatDayHeader(created)} + + ); + prevDay = day; + } + + const senderId = + (m as any).sender_id ?? + (typeof (m as any).sender === 'number' + ? (m as any).sender + : (m as any).sender?.id ?? null); + const isMine = !!currentUserId && senderId === currentUserId; + const isSystem = + (m as any).message_type === 'system' || + (typeof (m as any).sender === 'string' && (m as any).sender.toLowerCase() === 'system') || + (!senderId && (m as any).sender_name === 'System'); + const msgContent = (m as any).content || ''; + const sysTheme = isSystem ? getSystemMessageTheme(msgContent) : null; + const sysDisplayContent = isSystem ? stripHtml(stripLeadingEmojis(msgContent)) : ''; + + const msgUuid = (m as any).uuid ? String((m as any).uuid) : null; + const msgKey = (m as any).uuid || m.id || `msg-${idx}`; + out.push( + + {isSystem && sysTheme && ( + <> + + {sysTheme.icon} + + {sysTheme.label} + + + + {sysDisplayContent || '—'} + + + )} + {!isSystem && ( + {msgContent} + )} + + {formatRelativeStamp((m as any).created_at)} + + + ); + }); + + return out; + })() + )} + + + {/* Input */} + + setText(e.target.value)} + placeholder="Сообщение…" + fullWidth + multiline + minRows={1} + maxRows={4} + sx={{ + '& .MuiInputBase-root': { + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.85)', + }, + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + /> + + + + + + ); +} + diff --git a/front_material/components/dashboard/ClientDashboard.tsx b/front_material/components/dashboard/ClientDashboard.tsx index 2333d9d..baa2e73 100644 --- a/front_material/components/dashboard/ClientDashboard.tsx +++ b/front_material/components/dashboard/ClientDashboard.tsx @@ -74,13 +74,18 @@ export const ClientDashboard: React.FC = ({ childId }) => } return ( -
+
{/* Статистика студента */} -
= ({ childId }) => {/* Следующее занятие */} {stats?.next_lesson && ( -
= ({ childId }) => marginBottom: '24px' }}> {/* Домашние задания */} -
= ({ childId }) =>
{/* Ближайшие занятия */} -
void; selectedDate?: Date; userTimezone?: string; + /** Идёт загрузка данных (запрос нового месяца) — блокирует навигацию */ + loading?: boolean; } export const LessonsCalendar: React.FC = ({ @@ -57,6 +60,7 @@ export const LessonsCalendar: React.FC = ({ onMonthChange, selectedDate, userTimezone, + loading = false, }) => { const safeSelectedDate = useMemo(() => { if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate); @@ -176,24 +180,30 @@ export const LessonsCalendar: React.FC = ({ @@ -201,16 +211,24 @@ export const LessonsCalendar: React.FC = ({ + {loading && ( + + + + )} diff --git a/front_material/components/dashboard/mentor/ExtraStatsSection.tsx b/front_material/components/dashboard/mentor/ExtraStatsSection.tsx index d0768db..679760c 100644 --- a/front_material/components/dashboard/mentor/ExtraStatsSection.tsx +++ b/front_material/components/dashboard/mentor/ExtraStatsSection.tsx @@ -140,7 +140,7 @@ export const ExtraStatsSection: React.FC = ({ stats, loa const rows = buildRows(stats, loading).slice(0, 9); return ( - +
{rows.map((row, index) => { diff --git a/front_material/components/dashboard/mentor/IncomeSection.tsx b/front_material/components/dashboard/mentor/IncomeSection.tsx index 39a1946..7057244 100644 --- a/front_material/components/dashboard/mentor/IncomeSection.tsx +++ b/front_material/components/dashboard/mentor/IncomeSection.tsx @@ -37,7 +37,7 @@ export const IncomeSection: React.FC = ({ const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0); return ( - + = flipped={flipped} onFlippedChange={setFlipped} front={ - + {loading && !data ? ( diff --git a/front_material/components/dashboard/mentor/UpcomingLessonsSection.tsx b/front_material/components/dashboard/mentor/UpcomingLessonsSection.tsx index dc27a7c..adba853 100644 --- a/front_material/components/dashboard/mentor/UpcomingLessonsSection.tsx +++ b/front_material/components/dashboard/mentor/UpcomingLessonsSection.tsx @@ -107,7 +107,7 @@ export const UpcomingLessonsSection: React.FC = ({ setFlipped(v); }} front={ - + {loading && !data ? ( diff --git a/front_material/components/dashboard/ui/DashboardLayout.tsx b/front_material/components/dashboard/ui/DashboardLayout.tsx index 7806e18..0dfe20a 100644 --- a/front_material/components/dashboard/ui/DashboardLayout.tsx +++ b/front_material/components/dashboard/ui/DashboardLayout.tsx @@ -11,11 +11,13 @@ export interface DashboardLayoutProps { children: React.ReactNode; /** Дополнительный класс для контейнера */ className?: string; + /** data-tour для онбординга */ + 'data-tour'?: string; } -export const DashboardLayout: React.FC = ({ children, className = '' }) => { +export const DashboardLayout: React.FC = ({ children, className = '', 'data-tour': dataTour }) => { return ( -
+
{children}
); diff --git a/front_material/components/dashboard/ui/FlipCard.tsx b/front_material/components/dashboard/ui/FlipCard.tsx index 7c56948..d5e8cdc 100644 --- a/front_material/components/dashboard/ui/FlipCard.tsx +++ b/front_material/components/dashboard/ui/FlipCard.tsx @@ -1,83 +1,83 @@ -/** - * Карточка с лицевой и обратной стороной (переключение без анимации переворота). - */ - -'use client'; - -import React, { useMemo, useState } from 'react'; - -export interface FlipCardProps { - /** Контент лицевой стороны */ - front: React.ReactNode; - /** Контент обратной стороны */ - back: React.ReactNode; - /** Высота карточки */ - height?: string | number; - /** Дополнительный класс */ - className?: string; - /** Управляемый режим (если задан) */ - flipped?: boolean; - /** Коллбек при смене состояния */ - onFlippedChange?: (flipped: boolean) => void; -} - -export const FlipCard: React.FC = ({ - front, - back, - height = 'auto', - className = '', - flipped, - onFlippedChange, -}) => { - const [internalFlipped, setInternalFlipped] = useState(false); - const isControlled = useMemo(() => flipped !== undefined, [flipped]); - const isFlipped = isControlled ? (flipped as boolean) : internalFlipped; - - const setFlipped = (next: boolean) => { - if (!isControlled) setInternalFlipped(next); - onFlippedChange?.(next); - }; - - return ( -
-
- {front} -
-
- {back} -
-
- ); -}; +/** + * Карточка с лицевой и обратной стороной (переключение без анимации переворота). + */ + +'use client'; + +import React, { useMemo, useState } from 'react'; + +export interface FlipCardProps { + /** Контент лицевой стороны */ + front: React.ReactNode; + /** Контент обратной стороны */ + back: React.ReactNode; + /** Высота карточки */ + height?: string | number; + /** Дополнительный класс */ + className?: string; + /** Управляемый режим (если задан) */ + flipped?: boolean; + /** Коллбек при смене состояния */ + onFlippedChange?: (flipped: boolean) => void; +} + +export const FlipCard: React.FC = ({ + front, + back, + height = 'auto', + className = '', + flipped, + onFlippedChange, +}) => { + const [internalFlipped, setInternalFlipped] = useState(false); + const isControlled = useMemo(() => flipped !== undefined, [flipped]); + const isFlipped = isControlled ? (flipped as boolean) : internalFlipped; + + const setFlipped = (next: boolean) => { + if (!isControlled) setInternalFlipped(next); + onFlippedChange?.(next); + }; + + return ( +
+
+ {front} +
+
+ {back} +
+
+ ); +}; diff --git a/front_material/components/dashboard/ui/Panel.tsx b/front_material/components/dashboard/ui/Panel.tsx index b0c3e36..431707d 100644 --- a/front_material/components/dashboard/ui/Panel.tsx +++ b/front_material/components/dashboard/ui/Panel.tsx @@ -16,6 +16,8 @@ export interface PanelProps { /** Внутренние отступы. По умолчанию 24px */ padding?: 'none' | 'sm' | 'md' | 'lg'; style?: React.CSSProperties; + /** Атрибут для онбординга (data-tour) */ + 'data-tour'?: string; } const paddingMap = { @@ -31,10 +33,12 @@ export const Panel: React.FC = ({ interactive = false, padding = 'md', style, + 'data-tour': dataTour, }) => { const p = paddingMap[padding]; return (
+ {error && (
(''); const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams)); @@ -923,6 +925,15 @@ export default function LiveKitRoomContent() { } }, []); + // Подсказка по видеозвонку для студента (один раз, после входа в комнату) + useEffect(() => { + if (user?.role !== 'client' || !onboarding || showPreJoin) return; + const t = setTimeout(() => { + onboarding.runTourManually('livekit'); + }, 3500); + return () => clearTimeout(t); + }, [user?.role, onboarding, showPreJoin]); + useEffect(() => { const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null; if (id && !isNaN(id)) { diff --git a/front_material/components/navigation/ChildSelector.tsx b/front_material/components/navigation/ChildSelector.tsx index 1a74471..9f22628 100644 --- a/front_material/components/navigation/ChildSelector.tsx +++ b/front_material/components/navigation/ChildSelector.tsx @@ -45,7 +45,7 @@ export function ChildSelectorCompact() { const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?'; return ( -
+
+ )} + +
+ {expanded && ( +
+ {pages.map((key) => ( + + ))} +
+ )} +
+ ); +} diff --git a/front_material/contexts/OnboardingContext.tsx b/front_material/contexts/OnboardingContext.tsx new file mode 100644 index 0000000..0a75fe1 --- /dev/null +++ b/front_material/contexts/OnboardingContext.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { createContext, useContext, useEffect, useRef, useCallback } from 'react'; +import { usePathname } from 'next/navigation'; +import { driver, type DriveStep, type Driver } from 'driver.js'; +import 'driver.js/dist/driver.css'; +import '@/styles/driver-onboarding.css'; +import { + MENTOR_ONBOARDING, + CLIENT_ONBOARDING, + PARENT_ONBOARDING, + getOnboardingKey, + getOnboardingProgress, +} from '@/lib/onboarding-steps'; +import { getProfileSettings, updateProfileSettings } from '@/api/profile'; +import { useAuth } from '@/contexts/AuthContext'; + +type Role = 'mentor' | 'client' | 'parent'; + +interface OnboardingContextType { + markTourSeen: (pageId: string) => Promise; + runTourManually: (pageKey: string, options?: { force?: boolean }) => void; + getProgress: () => { seen: number; total: number }; + refreshProgress: () => Promise; +} + +const OnboardingContext = createContext(null); + +export function useOnboarding() { + return useContext(OnboardingContext); +} + +export function OnboardingProvider({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const { user } = useAuth(); + const toursSeenRef = useRef>({}); + const driverRef = useRef(null); + + const markTourSeen = useCallback(async (pageId: string) => { + if (!pageId) return; + toursSeenRef.current[pageId] = true; + try { + await updateProfileSettings({ + onboarding_tours_seen: { [pageId]: true }, + }); + } catch { + // ignore + } + }, []); + + const runTour = useCallback( + ( + config: { pageId: string; steps: { element?: string; popover: { title: string; description: string; side?: string; align?: string } }[] }, + skipSeenCheck?: boolean + ) => { + if (!skipSeenCheck && toursSeenRef.current[config.pageId]) return; + if (!config.steps.length) return; + + // Преобразуем шаги в формат driver.js + const steps: DriveStep[] = config.steps + .map((s) => { + // driver.js: если элемент не найден, step показывается как overlay по центру + const element = s.element && document.querySelector(s.element) ? s.element : undefined; + return { + element: element || undefined, + popover: { + title: s.popover.title, + description: s.popover.description, + side: (s.popover.side as 'top' | 'right' | 'bottom' | 'left') || 'bottom', + align: (s.popover.align as 'start' | 'center' | 'end') || 'center', + }, + }; + }) + .filter((s) => s.element || s.popover); + + if (steps.length === 0) return; + + if (driverRef.current) { + driverRef.current.destroy(); + driverRef.current = null; + } + + const driverObj = driver({ + showProgress: true, + steps, + nextBtnText: 'Далее', + prevBtnText: 'Назад', + doneBtnText: 'Понятно', + progressText: '{{current}} из {{total}}', + popoverClass: 'driver-onboarding-friendly', + onDestroyStarted: () => { + markTourSeen(config.pageId); + driverObj.destroy(); + driverRef.current = null; + }, + }); + driverRef.current = driverObj; + driverObj.drive(); + }, + [markTourSeen] + ); + + const runTourManually = useCallback( + (pageKey: string, options?: { force?: boolean }) => { + const role = user?.role as Role; + if (!role || !['mentor', 'client', 'parent'].includes(role)) return; + const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING; + const config = configs[pageKey]; + if (config) runTour(config, options?.force); + }, + [user?.role, runTour] + ); + + const getProgress = useCallback(() => { + const role = user?.role as Role; + if (!role || !['mentor', 'client', 'parent'].includes(role)) return { seen: 0, total: 0 }; + return getOnboardingProgress(toursSeenRef.current, role); + }, [user?.role]); + + const refreshProgress = useCallback(async () => { + try { + const settings = await getProfileSettings(); + const seen = settings?.onboarding_tours_seen ?? {}; + toursSeenRef.current = { ...toursSeenRef.current, ...seen }; + } catch { + // ignore + } + }, []); + + const runTourRef = useRef(runTour); + runTourRef.current = runTour; + + useEffect(() => { + if (!user) return; + getProfileSettings() + .then((s) => { + const seen = s?.onboarding_tours_seen ?? {}; + toursSeenRef.current = { ...toursSeenRef.current, ...seen }; + }) + .catch(() => {}); + }, [user?.id]); + + useEffect(() => { + if (!user || !pathname) return; + const role = user.role as Role; + if (!['mentor', 'client', 'parent'].includes(role)) return; + + if (pathname.startsWith('/login') || pathname.startsWith('/register') || pathname.startsWith('/livekit')) return; + + const key = getOnboardingKey(pathname, role); + if (!key) return; + + const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING; + const config = configs[key]; + if (!config) return; + + let cancelled = false; + const loadAndRun = async () => { + try { + const settings = await getProfileSettings(); + if (cancelled) return; + const seen = settings?.onboarding_tours_seen ?? {}; + toursSeenRef.current = { ...toursSeenRef.current, ...seen }; + if (seen[config.pageId]) return; + + setTimeout(() => { + if (!cancelled) runTourRef.current(config); + }, 600); + } catch { + // при ошибке не показываем тур + } + }; + loadAndRun(); + return () => { cancelled = true; }; + }, [pathname, user]); + + const value: OnboardingContextType = { + markTourSeen, + runTourManually, + getProgress, + refreshProgress, + }; + + return ( + + {children} + + ); +} diff --git a/front_material/docs/ONBOARDING.md b/front_material/docs/ONBOARDING.md new file mode 100644 index 0000000..7f342e6 --- /dev/null +++ b/front_material/docs/ONBOARDING.md @@ -0,0 +1,49 @@ +# Онбординг-туры для платформы + +Контекстные подсказки при первом посещении страниц для менторов, студентов и родителей. Используется библиотека **Driver.js**. + +## Архитектура + +### Backend + +- **Поле User.onboarding_tours_seen** (JSONField): `{"mentor-dashboard": true, "mentor-schedule": false, ...}` — какие туры уже просмотрены. +- **API**: + - `GET /profile/settings/` — возвращает `onboarding_tours_seen` в ответе. + - `PATCH /profile/update_settings/` — принимает `onboarding_tours_seen` и сливает с текущим состоянием. + +### Frontend + +- **OnboardingProvider** (`contexts/OnboardingContext.tsx`): при смене страницы проверяет, нужен ли тур для текущей роли. Если тур ещё не просмотрен — запускает Driver.js. +- **Шаги** (`lib/onboarding-steps.ts`): определения шагов по страницам и ролям (MENTOR_ONBOARDING, CLIENT_ONBOARDING, PARENT_ONBOARDING). +- **data-tour** атрибуты: элементы с `data-tour="..."` используются как цели для подсветки (например, `mentor-income`, `schedule-calendar`, `client-lessons`). + +## Страницы и шаги + +### Ментор + +- dashboard — Динамика доходов, Ближайшие занятия, Недавние сдачи, Навигация +- schedule — Календарь, форма создания занятия +- students, materials, homework, feedback, analytics, payment, profile + +### Студент (client) + +- dashboard — Ближайшие занятия, Навигация +- schedule, materials, homework, my-progress, request-mentor, profile + +### Родитель + +- dashboard — Выбор ребёнка, Занятия ребёнка, Навигация +- homework, my-progress, profile + +## Добавление новых шагов + +1. Добавить шаги в `lib/onboarding-steps.ts` в нужный объект (MENTOR_ONBOARDING, CLIENT_ONBOARDING, PARENT_ONBOARDING). +2. Добавить `data-tour="уникальный-id"` на целевой элемент в компоненте. +3. Шаги без `element` или с несуществующим селектором отображаются как overlay по центру. + +## Ручной запуск тура + +```ts +const { runTourManually } = useOnboarding(); +runTourManually('dashboard'); // для текущей роли +``` diff --git a/front_material/package.json b/front_material/package.json index efc3e1b..db8927c 100644 --- a/front_material/package.json +++ b/front_material/package.json @@ -25,6 +25,7 @@ "axios": "^1.7.9", "date-fns": "^4.1.0", "dayjs": "^1.11.19", + "driver.js": "^1.3.1", "livekit-client": "^2.16.0", "next": "^16.1.4", "react": "^19", diff --git a/front_material/styles/driver-onboarding.css b/front_material/styles/driver-onboarding.css new file mode 100644 index 0000000..47cd593 --- /dev/null +++ b/front_material/styles/driver-onboarding.css @@ -0,0 +1,85 @@ +/** + * Симпатичная стилизация онбординг-туров (Driver.js). + * Мягкие тени, скругления, дружелюбная палитра. + */ + +.driver-popover.driver-onboarding-friendly { + background: var(--md-sys-color-surface-container-high, #fff); + border-radius: 20px; + box-shadow: 0 12px 40px rgba(103, 80, 164, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(103, 80, 164, 0.12); + padding: 20px 24px; + max-width: 360px; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-title { + font-size: 18px; + font-weight: 600; + color: var(--md-sys-color-on-surface, #1c1b1f); + margin-bottom: 10px; + line-height: 1.35; + letter-spacing: -0.01em; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-description { + font-size: 15px; + line-height: 1.5; + color: var(--md-sys-color-on-surface-variant, #49454f); + margin-bottom: 20px; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 4px; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn, +.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { + padding: 10px 20px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + border: none; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn:hover, +.driver-popover.driver-onboarding-friendly .driver-popover-next-btn:hover { + transform: translateY(-1px); +} + +.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn { + background: var(--md-sys-color-surface-variant, #e7e0ec); + color: var(--md-sys-color-on-surface-variant, #49454f); +} + +.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { + background: var(--md-sys-color-primary, #6750a4); + color: var(--md-sys-color-on-primary, #fff); +} + +.driver-popover.driver-onboarding-friendly .driver-popover-progress-text { + font-size: 13px; + color: var(--md-sys-color-outline, #79747e); + font-weight: 500; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-arrow { + border-color: var(--md-sys-color-surface-container-high, #fff); +} + +.driver-overlay { + background: rgba(0, 0, 0, 0) !important; +} + +.driver-active-element { + border-radius: 16px !important; + box-shadow: + 0 0 0 4px rgba(103, 80, 164, 0.9), + 0 0 0 8px rgba(103, 80, 164, 0.25), + 0 0 32px rgba(103, 80, 164, 0.35) !important; +} diff --git a/rebuild-prod.sh b/rebuild-prod.sh index 9b79d98..fa5aa2d 100644 --- a/rebuild-prod.sh +++ b/rebuild-prod.sh @@ -1,66 +1,66 @@ -#!/bin/bash - -# Скрипт для полной пересборки PROD окружения с бэкапом БД - -set -e - -echo "==========================================" -echo "Полная пересборка PROD окружения" -echo "==========================================" -echo "" -echo "⚠️ ВНИМАНИЕ: Это пересоберёт все контейнеры без кэша" -echo "" - -cd "$(dirname "$0")" - -# Шаг 1: Создать бэкап БД -echo "Шаг 1: Создание бэкапа БД..." -if [ -f "./backup-all-db.sh" ]; then - ./backup-all-db.sh -else - echo "⚠️ Скрипт backup-all-db.sh не найден, создаём бэкап вручную..." - mkdir -p ./backups - TIMESTAMP=$(date +%Y%m%d_%H%M%S) - if docker ps | grep -q platform_prod_db; then - docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz" - echo "✓ Бэкап создан: ./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz" - else - echo "⚠️ Контейнер БД не запущен, пропускаем бэкап" - fi -fi - -echo "" -echo "Шаг 2: Остановка контейнеров..." -docker compose down - -echo "" -echo "Шаг 3: Пересборка образов без кэша..." -echo "Это может занять несколько минут..." -docker compose build --no-cache --pull - -echo "" -echo "Шаг 4: Запуск контейнеров..." -docker compose up -d - -echo "" -echo "Шаг 5: Ожидание запуска БД..." -sleep 10 - -echo "" -echo "Шаг 6: Применение миграций..." -docker exec platform_prod_web python manage.py migrate - -echo "" -echo "Шаг 7: Проверка статуса контейнеров..." -docker compose ps - -echo "" -echo "==========================================" -echo "✓ Пересборка завершена!" -echo "==========================================" -echo "" -echo "Проверьте логи:" -echo " docker compose logs -f" -echo "" -echo "Если нужно создать суперпользователя:" -echo " docker exec -it platform_prod_web python manage.py createsuperuser" +#!/bin/bash + +# Скрипт для полной пересборки PROD окружения с бэкапом БД + +set -e + +echo "==========================================" +echo "Полная пересборка PROD окружения" +echo "==========================================" +echo "" +echo "⚠️ ВНИМАНИЕ: Это пересоберёт все контейнеры без кэша" +echo "" + +cd "$(dirname "$0")" + +# Шаг 1: Создать бэкап БД +echo "Шаг 1: Создание бэкапа БД..." +if [ -f "./backup-all-db.sh" ]; then + ./backup-all-db.sh +else + echo "⚠️ Скрипт backup-all-db.sh не найден, создаём бэкап вручную..." + mkdir -p ./backups + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + if docker ps | grep -q platform_prod_db; then + docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz" + echo "✓ Бэкап создан: ./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz" + else + echo "⚠️ Контейнер БД не запущен, пропускаем бэкап" + fi +fi + +echo "" +echo "Шаг 2: Остановка контейнеров..." +docker compose down + +echo "" +echo "Шаг 3: Пересборка образов без кэша..." +echo "Это может занять несколько минут..." +docker compose build --no-cache --pull + +echo "" +echo "Шаг 4: Запуск контейнеров..." +docker compose up -d + +echo "" +echo "Шаг 5: Ожидание запуска БД..." +sleep 10 + +echo "" +echo "Шаг 6: Применение миграций..." +docker exec platform_prod_web python manage.py migrate + +echo "" +echo "Шаг 7: Проверка статуса контейнеров..." +docker compose ps + +echo "" +echo "==========================================" +echo "✓ Пересборка завершена!" +echo "==========================================" +echo "" +echo "Проверьте логи:" +echo " docker compose logs -f" +echo "" +echo "Если нужно создать суперпользователя:" +echo " docker exec -it platform_prod_web python manage.py createsuperuser" diff --git a/remove-cron-backup.sh b/remove-cron-backup.sh index 943b4fd..b6acdf1 100644 --- a/remove-cron-backup.sh +++ b/remove-cron-backup.sh @@ -1,31 +1,31 @@ -#!/bin/bash - -# Скрипт для удаления автоматического бэкапа из cron - -set -e - -SCRIPT_DIR="/var/www/platform/prod" -BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh" -CRON_USER="root" - -echo "==========================================" -echo "Удаление автоматического бэкапа из cron" -echo "==========================================" -echo "" - -# Проверить, есть ли запись в crontab -if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then - echo "Найдена запись в crontab:" - crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT" - echo "" - read -p "Удалить? (y/N): " -n 1 -r - echo "" - if [[ $REPLY =~ ^[Yy]$ ]]; then - crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" - - echo "✓ Запись удалена из crontab" - else - echo "Отменено." - fi -else - echo "Запись в crontab не найдена." -fi +#!/bin/bash + +# Скрипт для удаления автоматического бэкапа из cron + +set -e + +SCRIPT_DIR="/var/www/platform/prod" +BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh" +CRON_USER="root" + +echo "==========================================" +echo "Удаление автоматического бэкапа из cron" +echo "==========================================" +echo "" + +# Проверить, есть ли запись в crontab +if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then + echo "Найдена запись в crontab:" + crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT" + echo "" + read -p "Удалить? (y/N): " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" - + echo "✓ Запись удалена из crontab" + else + echo "Отменено." + fi +else + echo "Запись в crontab не найдена." +fi diff --git a/safe-down.sh b/safe-down.sh index 313036f..5355887 100644 --- a/safe-down.sh +++ b/safe-down.sh @@ -1,26 +1,26 @@ -#!/bin/bash - -# Безопасная остановка PROD окружения -# Этот скрипт останавливает контейнеры БЕЗ удаления volumes (данных БД) - -set -e - -echo "==========================================" -echo "Безопасная остановка PROD окружения" -echo "==========================================" -echo "" -echo "Это остановит контейнеры, но СОХРАНИТ данные БД и Redis" -echo "" - -cd "$(dirname "$0")" - -# Остановить контейнеры без удаления volumes -docker compose down - -echo "" -echo "✓ Контейнеры остановлены" -echo "✓ Volumes сохранены (данные БД не потеряны)" -echo "" -echo "Для запуска: docker compose up -d" -echo "Для полной очистки (с удалением данных): docker compose down --volumes" -echo " (ВНИМАНИЕ: это удалит все данные БД!)" +#!/bin/bash + +# Безопасная остановка PROD окружения +# Этот скрипт останавливает контейнеры БЕЗ удаления volumes (данных БД) + +set -e + +echo "==========================================" +echo "Безопасная остановка PROD окружения" +echo "==========================================" +echo "" +echo "Это остановит контейнеры, но СОХРАНИТ данные БД и Redis" +echo "" + +cd "$(dirname "$0")" + +# Остановить контейнеры без удаления volumes +docker compose down + +echo "" +echo "✓ Контейнеры остановлены" +echo "✓ Volumes сохранены (данные БД не потеряны)" +echo "" +echo "Для запуска: docker compose up -d" +echo "Для полной очистки (с удалением данных): docker compose down --volumes" +echo " (ВНИМАНИЕ: это удалит все данные БД!)" diff --git a/setup-cron-backup.sh b/setup-cron-backup.sh index 52953ae..c4a0be0 100644 --- a/setup-cron-backup.sh +++ b/setup-cron-backup.sh @@ -1,79 +1,79 @@ -#!/bin/bash - -# Скрипт для настройки автоматического бэкапа БД через cron -# Запускается дважды в день: в 00:00 и 12:00 - -set -e - -SCRIPT_DIR="/var/www/platform/prod" -BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh" -CRON_USER="root" - -echo "==========================================" -echo "Настройка автоматического бэкапа БД" -echo "==========================================" -echo "" - -# Проверить, что скрипт существует -if [ ! -f "$BACKUP_SCRIPT" ]; then - echo "Ошибка: Скрипт $BACKUP_SCRIPT не найден!" - exit 1 -fi - -# Сделать скрипт исполняемым -chmod +x "$BACKUP_SCRIPT" -echo "✓ Скрипт сделан исполняемым" - -# Создать директорию для бэкапов -mkdir -p "$SCRIPT_DIR/backups" -echo "✓ Директория для бэкапов создана" - -# Найти путь к docker (для cron) -DOCKER_PATH=$(which docker 2>/dev/null || echo "/usr/bin/docker") - -# Проверить, есть ли уже запись в crontab -# Используем PATH с docker и bash для надежности -CRON_CMD="0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin $BACKUP_SCRIPT >> $SCRIPT_DIR/backups/cron.log 2>&1" - -if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then - echo "⚠️ Запись в crontab уже существует" - echo "" - echo "Текущий crontab:" - crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT" - echo "" - read -p "Заменить существующую запись? (y/N): " -n 1 -r - echo "" - if [[ $REPLY =~ ^[Yy]$ ]]; then - # Удалить старую запись - crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" - - # Добавить новую - (crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" - - echo "✓ Запись в crontab обновлена" - else - echo "Отменено. Существующая запись сохранена." - exit 0 - fi -else - # Добавить новую запись - (crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" - - echo "✓ Запись в crontab добавлена" -fi - -echo "" -echo "==========================================" -echo "Настройка завершена!" -echo "==========================================" -echo "" -echo "Расписание бэкапов:" -echo " - Каждый день в 00:00 (полночь)" -echo " - Каждый день в 12:00 (полдень)" -echo "" -echo "Проверить crontab:" -echo " crontab -u $CRON_USER -l" -echo "" -echo "Просмотр логов бэкапов:" -echo " tail -f $SCRIPT_DIR/backups/backup.log" -echo " tail -f $SCRIPT_DIR/backups/cron.log" -echo "" -echo "Удалить автоматический бэкап:" -echo " crontab -u $CRON_USER -l | grep -v '$BACKUP_SCRIPT' | crontab -u $CRON_USER -" +#!/bin/bash + +# Скрипт для настройки автоматического бэкапа БД через cron +# Запускается дважды в день: в 00:00 и 12:00 + +set -e + +SCRIPT_DIR="/var/www/platform/prod" +BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh" +CRON_USER="root" + +echo "==========================================" +echo "Настройка автоматического бэкапа БД" +echo "==========================================" +echo "" + +# Проверить, что скрипт существует +if [ ! -f "$BACKUP_SCRIPT" ]; then + echo "Ошибка: Скрипт $BACKUP_SCRIPT не найден!" + exit 1 +fi + +# Сделать скрипт исполняемым +chmod +x "$BACKUP_SCRIPT" +echo "✓ Скрипт сделан исполняемым" + +# Создать директорию для бэкапов +mkdir -p "$SCRIPT_DIR/backups" +echo "✓ Директория для бэкапов создана" + +# Найти путь к docker (для cron) +DOCKER_PATH=$(which docker 2>/dev/null || echo "/usr/bin/docker") + +# Проверить, есть ли уже запись в crontab +# Используем PATH с docker и bash для надежности +CRON_CMD="0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin $BACKUP_SCRIPT >> $SCRIPT_DIR/backups/cron.log 2>&1" + +if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then + echo "⚠️ Запись в crontab уже существует" + echo "" + echo "Текущий crontab:" + crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT" + echo "" + read -p "Заменить существующую запись? (y/N): " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + # Удалить старую запись + crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" - + # Добавить новую + (crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" - + echo "✓ Запись в crontab обновлена" + else + echo "Отменено. Существующая запись сохранена." + exit 0 + fi +else + # Добавить новую запись + (crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" - + echo "✓ Запись в crontab добавлена" +fi + +echo "" +echo "==========================================" +echo "Настройка завершена!" +echo "==========================================" +echo "" +echo "Расписание бэкапов:" +echo " - Каждый день в 00:00 (полночь)" +echo " - Каждый день в 12:00 (полдень)" +echo "" +echo "Проверить crontab:" +echo " crontab -u $CRON_USER -l" +echo "" +echo "Просмотр логов бэкапов:" +echo " tail -f $SCRIPT_DIR/backups/backup.log" +echo " tail -f $SCRIPT_DIR/backups/cron.log" +echo "" +echo "Удалить автоматический бэкап:" +echo " crontab -u $CRON_USER -l | grep -v '$BACKUP_SCRIPT' | crontab -u $CRON_USER -"