tur
Deploy to Production / deploy-production (push) Successful in 29s Details

This commit is contained in:
root 2026-02-23 23:21:14 +03:00
parent a167683bd9
commit 835bd76479
59 changed files with 3000 additions and 2322 deletions

62
DOCKER-SERVER-CONFIG.md Normal file
View File

@ -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 48 GB на серверах с 8 GB RAM — для стабильности при пиковых нагрузках.

View File

@ -1,148 +1,148 @@
# PROD Окружение - Инструкция по управлению # PROD Окружение - Инструкция по управлению
## ⚠️ ВАЖНО: Защита данных ## ⚠️ ВАЖНО: Защита данных
**PROD окружение использует отдельную сеть (`prod_network`) и именованные volumes для изоляции от dev.** **PROD окружение использует отдельную сеть (`prod_network`) и именованные volumes для изоляции от dev.**
### Что было исправлено: ### Что было исправлено:
1. ✅ **Отдельная сеть** - `prod_network` вместо общей `dev_network` 1. ✅ **Отдельная сеть** - `prod_network` вместо общей `dev_network`
2. ✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_` 2. ✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_`
3. ✅ **Полные имена контейнеров** - в `DATABASE_URL` и `REDIS_URL` используются полные имена контейнеров 3. ✅ **Полные имена контейнеров** - в `DATABASE_URL` и `REDIS_URL` используются полные имена контейнеров
4. ✅ **Изоляция от dev** - prod не может случайно подключиться к dev БД 4. ✅ **Изоляция от dev** - prod не может случайно подключиться к dev БД
## 📋 Основные команды ## 📋 Основные команды
### Безопасная остановка (СОХРАНЯЕТ данные БД): ### Безопасная остановка (СОХРАНЯЕТ данные БД):
```bash ```bash
# Использовать скрипт из /var/www/service # Использовать скрипт из /var/www/service
/var/www/service/platform/safe-down-prod.sh /var/www/service/platform/safe-down-prod.sh
# Или вручную # Или вручную
cd /var/www/platform/prod cd /var/www/platform/prod
docker compose down docker compose down
``` ```
### ⚠️ ОСТОРОЖНО: Полная очистка (УДАЛЯЕТ данные БД): ### ⚠️ ОСТОРОЖНО: Полная очистка (УДАЛЯЕТ данные БД):
```bash ```bash
# Сначала создайте бэкап! # Сначала создайте бэкап!
/var/www/service/backup/backup-prod-db.sh /var/www/service/backup/backup-prod-db.sh
# Затем можно удалить volumes # Затем можно удалить volumes
cd /var/www/platform/prod cd /var/www/platform/prod
docker compose down --volumes docker compose down --volumes
``` ```
### Запуск: ### Запуск:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
### Создание бэкапа БД: ### Создание бэкапа БД:
```bash ```bash
# Бэкап PROD БД # Бэкап PROD БД
/var/www/service/backup/backup-all-db.sh /var/www/service/backup/backup-all-db.sh
# Альтернативный скрипт для PROD БД # Альтернативный скрипт для PROD БД
/var/www/service/backup/backup-prod-db.sh /var/www/service/backup/backup-prod-db.sh
# Автоматический бэкап PROD БД (для cron) # Автоматический бэкап PROD БД (для cron)
/var/www/service/backup/backup-db-auto.sh /var/www/service/backup/backup-db-auto.sh
``` ```
**Примечание:** DEV БД не бэкапится, так как это окружение разработки. **Примечание:** DEV БД не бэкапится, так как это окружение разработки.
### Настройка автоматического бэкапа PROD БД (2 раза в день: 00:00 и 12:00): ### Настройка автоматического бэкапа PROD БД (2 раза в день: 00:00 и 12:00):
```bash ```bash
# Установить автоматический бэкап PROD БД # Установить автоматический бэкап PROD БД
/var/www/service/backup/setup-cron-backup.sh /var/www/service/backup/setup-cron-backup.sh
# Удалить автоматический бэкап # Удалить автоматический бэкап
/var/www/service/backup/remove-cron-backup.sh /var/www/service/backup/remove-cron-backup.sh
# Проверить расписание # Проверить расписание
crontab -l | grep backup-db-auto crontab -l | grep backup-db-auto
# Просмотр логов # Просмотр логов
tail -f /var/www/platform/prod/backups/backup.log tail -f /var/www/platform/prod/backups/backup.log
``` ```
**Примечание:** Автоматически бэкапится только PROD БД. DEV БД не бэкапится. **Примечание:** Автоматически бэкапится только PROD БД. DEV БД не бэкапится.
### Полная пересборка PROD (с бэкапом): ### Полная пересборка PROD (с бэкапом):
```bash ```bash
/var/www/service/platform/rebuild-prod.sh /var/www/service/platform/rebuild-prod.sh
``` ```
Этот скрипт: Этот скрипт:
1. Создаёт бэкап БД 1. Создаёт бэкап БД
2. Останавливает контейнеры 2. Останавливает контейнеры
3. Пересобирает образы без кэша 3. Пересобирает образы без кэша
4. Запускает контейнеры 4. Запускает контейнеры
5. Применяет миграции 5. Применяет миграции
### Применение миграций: ### Применение миграций:
```bash ```bash
docker exec platform_prod_web python manage.py migrate docker exec platform_prod_web python manage.py migrate
``` ```
### Создание суперпользователя: ### Создание суперпользователя:
```bash ```bash
docker exec -it platform_prod_web python manage.py createsuperuser docker exec -it platform_prod_web python manage.py createsuperuser
``` ```
## 🔧 Структура volumes ## 🔧 Структура volumes
- `platform_prod_postgres_data` - данные PostgreSQL БД - `platform_prod_postgres_data` - данные PostgreSQL БД
- `platform_prod_redis_data` - данные Redis - `platform_prod_redis_data` - данные Redis
- `platform_prod_front_material_node_modules` - node_modules для frontend - `platform_prod_front_material_node_modules` - node_modules для frontend
- `platform_prod_front_material_next` - кэш Next.js - `platform_prod_front_material_next` - кэш Next.js
## 🌐 Сеть ## 🌐 Сеть
- **Prod сеть**: `platform_prod_network` (изолирована от dev) - **Prod сеть**: `platform_prod_network` (изолирована от dev)
- **Dev сеть**: `dev_network` (отдельная) - **Dev сеть**: `dev_network` (отдельная)
## 🔗 Подключения ## 🔗 Подключения
Все сервисы используют полные имена контейнеров: Все сервисы используют полные имена контейнеров:
- БД: `platform_prod_db` (не `db`) - БД: `platform_prod_db` (не `db`)
- Redis: `platform_prod_redis` (не `redis`) - Redis: `platform_prod_redis` (не `redis`)
Это гарантирует, что даже при запуске dev и prod одновременно, они не будут конфликтовать. Это гарантирует, что даже при запуске dev и prod одновременно, они не будут конфликтовать.
## 📝 Что делать если данные потеряны ## 📝 Что делать если данные потеряны
1. Проверьте бэкапы: `ls -la ./backups/` 1. Проверьте бэкапы: `ls -la ./backups/`
2. Если бэкапа нет, но данные есть в dev БД, можно скопировать: 2. Если бэкапа нет, но данные есть в dev БД, можно скопировать:
```bash ```bash
# Создать бэкап из dev # Создать бэкап из dev
docker exec platform_dev_db pg_dump -U platform_dev_user -d platform_dev_db > /tmp/dev_backup.sql docker exec platform_dev_db pg_dump -U platform_dev_user -d platform_dev_db > /tmp/dev_backup.sql
# Применить миграции в prod # Применить миграции в prod
docker exec platform_prod_web python manage.py migrate 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 docker exec -i platform_prod_db psql -U platform_prod_user -d platform_prod_db < /tmp/dev_backup.sql
``` ```
3. Если данных нет нигде - создайте пользователей заново через `createsuperuser` 3. Если данных нет нигде - создайте пользователей заново через `createsuperuser`
## 🚨 Частые ошибки ## 🚨 Частые ошибки
### ❌ НЕ делайте: ### ❌ НЕ делайте:
- `docker compose down --volumes` без бэкапа - `docker compose down --volumes` без бэкапа
- Использование коротких имен (`db`, `redis`) в переменных окружения - Использование коротких имен (`db`, `redis`) в переменных окружения
- Общая сеть для dev и prod - Общая сеть для dev и prod
### ✅ Делайте: ### ✅ Делайте:
- Всегда используйте `docker compose down` (без `--volumes`) - Всегда используйте `docker compose down` (без `--volumes`)
- Регулярно создавайте бэкапы PROD БД: `/var/www/service/backup/backup-all-db.sh` - Регулярно создавайте бэкапы PROD БД: `/var/www/service/backup/backup-all-db.sh`
- Используйте полные имена контейнеров в конфигурации - Используйте полные имена контейнеров в конфигурации
## 📁 Расположение скриптов ## 📁 Расположение скриптов
Все служебные скрипты перенесены в `/var/www/service/`: Все служебные скрипты перенесены в `/var/www/service/`:
- **Бэкапы**: `/var/www/service/backup/` - **Бэкапы**: `/var/www/service/backup/`
- **Управление платформой**: `/var/www/service/platform/` - **Управление платформой**: `/var/www/service/platform/`
Подробнее: `/var/www/service/README.md` Подробнее: `/var/www/service/README.md`

View File

@ -1,98 +1,98 @@
# Инструкция по пересборке PROD и созданию бэкапов # Инструкция по пересборке PROD и созданию бэкапов
## 🎯 Что нужно сделать: ## 🎯 Что нужно сделать:
### 1. Создать бэкап PROD БД ### 1. Создать бэкап PROD БД
```bash ```bash
# Сделать скрипты исполняемыми (первый раз) # Сделать скрипты исполняемыми (первый раз)
chmod +x /var/www/service/backup/*.sh chmod +x /var/www/service/backup/*.sh
chmod +x /var/www/service/platform/*.sh chmod +x /var/www/service/platform/*.sh
# Создать бэкап PROD БД # Создать бэкап PROD БД
/var/www/service/backup/backup-all-db.sh /var/www/service/backup/backup-all-db.sh
``` ```
Это создаст бэкап: Это создаст бэкап:
- `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz` - `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
**Примечание:** DEV БД не бэкапится, так как это окружение разработки. **Примечание:** DEV БД не бэкапится, так как это окружение разработки.
### 2. Пересобрать PROD окружение ### 2. Пересобрать PROD окружение
```bash ```bash
# Автоматическая пересборка (с бэкапом) # Автоматическая пересборка (с бэкапом)
/var/www/service/platform/rebuild-prod.sh /var/www/service/platform/rebuild-prod.sh
``` ```
Или вручную: Или вручную:
```bash ```bash
cd /var/www/platform/prod cd /var/www/platform/prod
# Остановить контейнеры # Остановить контейнеры
docker compose down docker compose down
# Пересобрать без кэша # Пересобрать без кэша
docker compose build --no-cache --pull docker compose build --no-cache --pull
# Запустить # Запустить
docker compose up -d docker compose up -d
# Подождать запуска БД # Подождать запуска БД
sleep 10 sleep 10
# Применить миграции # Применить миграции
docker exec platform_prod_web python manage.py migrate docker exec platform_prod_web python manage.py migrate
# Проверить статус # Проверить статус
docker compose ps docker compose ps
``` ```
### 3. Проверить, что всё работает ### 3. Проверить, что всё работает
```bash ```bash
# Проверить логи # Проверить логи
docker compose logs -f 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_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 "Таблица не создана, нужно применить миграции" 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. Если пользователей нет - создать суперпользователя ### 4. Если пользователей нет - создать суперпользователя
```bash ```bash
docker exec -it platform_prod_web python manage.py createsuperuser docker exec -it platform_prod_web python manage.py createsuperuser
``` ```
## 📋 Что было исправлено: ## 📋 Что было исправлено:
**Отдельная сеть для prod** - `prod_network` (изолирована от dev) **Отдельная сеть для prod** - `prod_network` (изолирована от dev)
**Именованные volumes** - все volumes имеют префикс `platform_prod_` **Именованные volumes** - все volumes имеют префикс `platform_prod_`
**Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis` **Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis`
**Защита от случайного удаления данных** - volumes не удаляются при `docker compose down` **Защита от случайного удаления данных** - volumes не удаляются при `docker compose down`
## ⚠️ Важно: ## ⚠️ Важно:
- **НЕ используйте** `docker compose down --volumes` без бэкапа! - **НЕ используйте** `docker compose down --volumes` без бэкапа!
- Всегда создавайте бэкапы перед пересборкой - Всегда создавайте бэкапы перед пересборкой
- Используйте `./safe-down.sh` для безопасной остановки - Используйте `./safe-down.sh` для безопасной остановки
## 🔄 Восстановление из бэкапа (если нужно): ## 🔄 Восстановление из бэкапа (если нужно):
```bash ```bash
# Восстановить PROD БД # Восстановить 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 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/`:
- **Бэкапы**: `/var/www/service/backup/` - **Бэкапы**: `/var/www/service/backup/`
- **Управление платформой**: `/var/www/service/platform/` - **Управление платформой**: `/var/www/service/platform/`
Подробнее: `/var/www/service/README.md` Подробнее: `/var/www/service/README.md`

View File

@ -84,6 +84,10 @@ class UserAdmin(BaseUserAdmin):
'telegram_notifications' 'telegram_notifications'
) )
}), }),
(_('Онбординг'), {
'fields': ('onboarding_tours_seen',),
'description': 'Прогресс подсказок по платформе (JSON). Чтобы сбросить — очистите поле или укажите {}.'
}),
(_('Блокировка'), { (_('Блокировка'), {
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
'classes': ('collapse',) 'classes': ('collapse',)
@ -142,6 +146,10 @@ class MentorAdmin(BaseUserAdmin):
'notifications_enabled', 'email_notifications', 'telegram_notifications', 'notifications_enabled', 'email_notifications', 'telegram_notifications',
'ai_trust_draft', 'ai_trust_publish') 'ai_trust_draft', 'ai_trust_publish')
}), }),
(_('Онбординг'), {
'fields': ('onboarding_tours_seen',),
'description': 'Прогресс подсказок (JSON). Чтобы сбросить — очистите или введите {}.'
}),
(_('Важные даты'), { (_('Важные даты'), {
'fields': ('last_login', 'last_activity', 'date_joined', 'created_at', 'updated_at'), 'fields': ('last_login', 'last_activity', 'date_joined', 'created_at', 'updated_at'),
'classes': ('collapse',) 'classes': ('collapse',)

View File

@ -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="Просмотренные туры онбординга",
),
),
]

View File

@ -241,6 +241,15 @@ class User(AbstractUser):
verbose_name='Последняя активность' 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( notifications_enabled = models.BooleanField(
default=True, default=True,

View File

@ -568,6 +568,7 @@ class ProfileViewSet(viewsets.ViewSet):
'ai_trust_draft': getattr(user, 'ai_trust_draft', False), 'ai_trust_draft': getattr(user, 'ai_trust_draft', False),
'ai_trust_publish': getattr(user, 'ai_trust_publish', False), 'ai_trust_publish': getattr(user, 'ai_trust_publish', False),
} }
settings['onboarding_tours_seen'] = getattr(user, 'onboarding_tours_seen', {}) or {}
return Response(settings) return Response(settings)
@ -842,6 +843,14 @@ class ProfileViewSet(viewsets.ViewSet):
if 'ai_trust_publish' in mentor_ai: if 'ai_trust_publish' in mentor_ai:
user.ai_trust_publish = bool(mentor_ai['ai_trust_publish']) 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() user.save()
return Response({'message': 'Настройки успешно обновлены'}) return Response({'message': 'Настройки успешно обновлены'})

View File

@ -38,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer):
'country', 'city', 'country', 'city',
'email_verified', 'is_active', 'email_verified', 'is_active',
'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором 'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором
'onboarding_tours_seen',
'invitation_link_token', 'invitation_link', 'invitation_link_token', 'invitation_link',
'login_token', 'login_link', 'login_token', 'login_link',
'notifications_enabled', 'email_notifications', 'telegram_notifications', 'notifications_enabled', 'email_notifications', 'telegram_notifications',

View File

@ -1,65 +1,65 @@
#!/bin/bash #!/bin/bash
# Скрипт для создания бэкапов БД PROD и DEV # Скрипт для создания бэкапов БД PROD и DEV
set -e set -e
BACKUP_DIR="./backups" BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "==========================================" echo "=========================================="
echo "Создание бэкапов БД (PROD и DEV)" echo "Создание бэкапов БД (PROD и DEV)"
echo "==========================================" echo "=========================================="
echo "" echo ""
# Создать директорию для бэкапов # Создать директорию для бэкапов
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
# Функция для создания бэкапа # Функция для создания бэкапа
backup_db() { backup_db() {
local CONTAINER_NAME=$1 local CONTAINER_NAME=$1
local DB_USER=$2 local DB_USER=$2
local DB_NAME=$3 local DB_NAME=$3
local BACKUP_NAME=$4 local BACKUP_NAME=$4
echo "Создание бэкапа: $BACKUP_NAME" echo "Создание бэкапа: $BACKUP_NAME"
# Проверить, что контейнер запущен # Проверить, что контейнер запущен
if ! docker ps | grep -q "$CONTAINER_NAME"; then if ! docker ps | grep -q "$CONTAINER_NAME"; then
echo "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..." echo "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..."
return 1 return 1
fi fi
BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz" 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 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) BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)" echo " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)"
return 0 return 0
else else
echo " ✗ Ошибка создания бэкапа для $BACKUP_NAME" echo " ✗ Ошибка создания бэкапа для $BACKUP_NAME"
return 1 return 1
fi fi
} }
# Бэкап PROD БД # Бэкап PROD БД
echo "--- PROD БД ---" echo "--- PROD БД ---"
backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db" backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"
echo "" echo ""
# Бэкап DEV БД # Бэкап DEV БД
echo "--- DEV БД ---" echo "--- DEV БД ---"
backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db" backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Бэкапы сохранены в: $BACKUP_DIR" echo "Бэкапы сохранены в: $BACKUP_DIR"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "Для восстановления PROD БД:" 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 " gunzip < $BACKUP_DIR/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres"
echo "" echo ""
echo "Для восстановления DEV БД:" echo "Для восстановления DEV БД:"
echo " gunzip < $BACKUP_DIR/platform_dev_db_*.sql.gz | docker exec -i platform_dev_db psql -U platform_dev_user -d postgres" echo " gunzip < $BACKUP_DIR/platform_dev_db_*.sql.gz | docker exec -i platform_dev_db psql -U platform_dev_user -d postgres"

View File

@ -1,99 +1,99 @@
#!/bin/bash #!/bin/bash
# Автоматический скрипт для создания бэкапов БД PROD и DEV # Автоматический скрипт для создания бэкапов БД PROD и DEV
# Запускается через cron дважды в день (00:00 и 12:00) # Запускается через cron дважды в день (00:00 и 12:00)
set -e set -e
BACKUP_DIR="/var/www/platform/prod/backups" BACKUP_DIR="/var/www/platform/prod/backups"
LOG_FILE="/var/www/platform/prod/backups/backup.log" LOG_FILE="/var/www/platform/prod/backups/backup.log"
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DATE=$(date +%Y-%m-%d\ %H:%M:%S) DATE=$(date +%Y-%m-%d\ %H:%M:%S)
# Создать директорию для бэкапов и логов # Создать директорию для бэкапов и логов
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
# Функция для логирования # Функция для логирования
log() { log() {
echo "[$DATE] $1" | tee -a "$LOG_FILE" echo "[$DATE] $1" | tee -a "$LOG_FILE"
} }
log "==========================================" log "=========================================="
log "Начало автоматического бэкапа БД" log "Начало автоматического бэкапа БД"
log "==========================================" log "=========================================="
# Функция для создания бэкапа # Функция для создания бэкапа
backup_db() { backup_db() {
local CONTAINER_NAME=$1 local CONTAINER_NAME=$1
local DB_USER=$2 local DB_USER=$2
local DB_NAME=$3 local DB_NAME=$3
local BACKUP_NAME=$4 local BACKUP_NAME=$4
log "Создание бэкапа: $BACKUP_NAME" log "Создание бэкапа: $BACKUP_NAME"
# Проверить, что контейнер запущен # Проверить, что контейнер запущен
if ! docker ps | grep -q "$CONTAINER_NAME"; then if ! docker ps | grep -q "$CONTAINER_NAME"; then
log "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..." log "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..."
return 1 return 1
fi fi
BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz" 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 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) BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
log " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)" log " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)"
# Проверить размер файла (должен быть больше 0) # Проверить размер файла (должен быть больше 0)
if [ ! -s "$BACKUP_FILE" ]; then if [ ! -s "$BACKUP_FILE" ]; then
log " ✗ ОШИБКА: Бэкап пустой!" log " ✗ ОШИБКА: Бэкап пустой!"
rm -f "$BACKUP_FILE" rm -f "$BACKUP_FILE"
return 1 return 1
fi fi
return 0 return 0
else else
log " ✗ Ошибка создания бэкапа для $BACKUP_NAME" log " ✗ Ошибка создания бэкапа для $BACKUP_NAME"
return 1 return 1
fi fi
} }
# Бэкап PROD БД # Бэкап PROD БД
PROD_SUCCESS=false PROD_SUCCESS=false
if backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"; then if backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"; then
PROD_SUCCESS=true PROD_SUCCESS=true
fi fi
# Бэкап DEV БД # Бэкап DEV БД
DEV_SUCCESS=false DEV_SUCCESS=false
if backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"; then if backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"; then
DEV_SUCCESS=true DEV_SUCCESS=true
fi fi
# Очистка старых бэкапов (оставляем последние 30 дней) # Очистка старых бэкапов (оставляем последние 30 дней)
log "Очистка старых бэкапов (старше 30 дней)..." log "Очистка старых бэкапов (старше 30 дней)..."
find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +30 -delete 2>/dev/null || true 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) DELETED_COUNT=$(find "$BACKUP_DIR" -name "*.sql.gz" -type f 2>/dev/null | wc -l)
log "Осталось бэкапов: $DELETED_COUNT" log "Осталось бэкапов: $DELETED_COUNT"
# Итоги # Итоги
log "==========================================" log "=========================================="
if [ "$PROD_SUCCESS" = true ] && [ "$DEV_SUCCESS" = true ]; then if [ "$PROD_SUCCESS" = true ] && [ "$DEV_SUCCESS" = true ]; then
log "✓ Бэкапы созданы успешно (PROD и DEV)" log "✓ Бэкапы созданы успешно (PROD и DEV)"
elif [ "$PROD_SUCCESS" = true ]; then elif [ "$PROD_SUCCESS" = true ]; then
log "⚠️ Бэкап PROD создан, DEV пропущен" log "⚠️ Бэкап PROD создан, DEV пропущен"
elif [ "$DEV_SUCCESS" = true ]; then elif [ "$DEV_SUCCESS" = true ]; then
log "⚠️ Бэкап DEV создан, PROD пропущен" log "⚠️ Бэкап DEV создан, PROD пропущен"
else else
log "✗ Ошибка: бэкапы не созданы!" log "✗ Ошибка: бэкапы не созданы!"
exit 1 exit 1
fi fi
log "==========================================" log "=========================================="
# Проверка места на диске # Проверка места на диске
DISK_USAGE=$(df -h "$BACKUP_DIR" | tail -1 | awk '{print $5}' | sed 's/%//') DISK_USAGE=$(df -h "$BACKUP_DIR" | tail -1 | awk '{print $5}' | sed 's/%//')
if [ "$DISK_USAGE" -gt 80 ]; then if [ "$DISK_USAGE" -gt 80 ]; then
log "⚠️ ВНИМАНИЕ: Использовано дискового пространства: ${DISK_USAGE}%" log "⚠️ ВНИМАНИЕ: Использовано дискового пространства: ${DISK_USAGE}%"
fi fi
exit 0 exit 0

View File

@ -1,42 +1,42 @@
#!/bin/bash #!/bin/bash
# Скрипт для создания бэкапа БД PROD # Скрипт для создания бэкапа БД PROD
set -e set -e
BACKUP_DIR="./backups" BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/platform_prod_db_backup_$TIMESTAMP.sql.gz" BACKUP_FILE="$BACKUP_DIR/platform_prod_db_backup_$TIMESTAMP.sql.gz"
echo "==========================================" echo "=========================================="
echo "Создание бэкапа PROD БД" echo "Создание бэкапа PROD БД"
echo "==========================================" echo "=========================================="
echo "" echo ""
# Создать директорию для бэкапов # Создать директорию для бэкапов
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
# Проверить, что контейнер БД запущен # Проверить, что контейнер БД запущен
if ! docker ps | grep -q platform_prod_db; then if ! docker ps | grep -q platform_prod_db; then
echo "Ошибка: Контейнер platform_prod_db не запущен" echo "Ошибка: Контейнер platform_prod_db не запущен"
echo "Запустите БД: docker compose up -d db" echo "Запустите БД: docker compose up -d db"
exit 1 exit 1
fi fi
echo "Создание бэкапа..." echo "Создание бэкапа..."
echo "Файл: $BACKUP_FILE" echo "Файл: $BACKUP_FILE"
echo "" echo ""
# Создать бэкап # Создать бэкап
if docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "$BACKUP_FILE"; then 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) BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo "✓ Бэкап создан успешно" echo "✓ Бэкап создан успешно"
echo " Размер: $BACKUP_SIZE" echo " Размер: $BACKUP_SIZE"
echo " Файл: $BACKUP_FILE" echo " Файл: $BACKUP_FILE"
echo "" echo ""
echo "Для восстановления:" echo "Для восстановления:"
echo " gunzip < $BACKUP_FILE | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres" echo " gunzip < $BACKUP_FILE | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres"
else else
echo "✗ Ошибка создания бэкапа!" echo "✗ Ошибка создания бэкапа!"
exit 1 exit 1
fi fi

View File

@ -1,27 +1,27 @@
# LiveKit Server — поддержка 2K и высокого битрейта # LiveKit Server — поддержка 2K и высокого битрейта
# Ключи можно переопределить через LIVEKIT_KEYS в docker-compose # Ключи можно переопределить через LIVEKIT_KEYS в docker-compose
port: 7880 port: 7880
keys: keys:
APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
rtc: rtc:
port_range_start: 50000 port_range_start: 50000
port_range_end: 60000 port_range_end: 60000
tcp_port: 7881 tcp_port: 7881
use_external_ip: false use_external_ip: false
# Буферы для видео (по умолчанию 500) — чуть выше для 2K/высокого битрейта # Буферы для видео (по умолчанию 500) — чуть выше для 2K/высокого битрейта
packet_buffer_size_video: 600 packet_buffer_size_video: 600
packet_buffer_size_audio: 200 packet_buffer_size_audio: 200
congestion_control: congestion_control:
enabled: true enabled: true
allow_pause: true allow_pause: true
allow_tcp_fallback: true allow_tcp_fallback: true
room: room:
auto_create: true auto_create: true
empty_timeout: 300 empty_timeout: 300
max_participants: 50 max_participants: 50
logging: logging:
level: info level: info
sample: false sample: false

View File

@ -1,13 +1,13 @@
{ {
"builder": { "builder": {
"gc": { "gc": {
"defaultKeepStorage": "10GB", "defaultKeepStorage": "10GB",
"enabled": true "enabled": true
} }
}, },
"log-driver": "json-file", "log-driver": "json-file",
"log-opts": { "log-opts": {
"max-size": "10m", "max-size": "10m",
"max-file": "3" "max-file": "3"
} }
} }

View File

@ -1,25 +1,25 @@
node_modules node_modules
.next .next
.git .git
.gitignore .gitignore
*.md *.md
.env*.local .env*.local
.env .env
.env.* .env.*
.DS_Store .DS_Store
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.vercel .vercel
coverage coverage
.nyc_output .nyc_output
.vscode .vscode
.idea .idea
docs docs
.cursor .cursor
agent-transcripts agent-transcripts
__pycache__ __pycache__
*.pyc *.pyc
.pytest_cache .pytest_cache
.mypy_cache .mypy_cache

View File

@ -72,7 +72,8 @@ ENV NEXT_PUBLIC_EXCALIDRAW_URL=$NEXT_PUBLIC_EXCALIDRAW_URL
COPY package*.json ./ COPY package*.json ./
# Устанавливаем все зависимости для сборки # Устанавливаем все зависимости для сборки
RUN npm ci # npm install вместо npm ci: package-lock.json может быть не синхронизирован после добавления driver.js
RUN npm install
# Копируем исходный код # Копируем исходный код
COPY . . COPY . .

View File

@ -117,6 +117,7 @@ front_material/
├── contexts/ # React Context ├── contexts/ # React Context
│ ├── AuthContext.tsx # Контекст аутентификации │ ├── AuthContext.tsx # Контекст аутентификации
│ ├── OnboardingContext.tsx # Онбординг-туры (Driver.js, привязка к страницам и ролям)
│ ├── ThemeContext.tsx # Контекст темы (light/dark) │ ├── ThemeContext.tsx # Контекст темы (light/dark)
│ └── SelectedChildContext.tsx # Контекст выбранного ребенка (для родителей) │ └── SelectedChildContext.tsx # Контекст выбранного ребенка (для родителей)
@ -143,6 +144,7 @@ front_material/
├── lib/ # Утилиты ├── lib/ # Утилиты
│ ├── material-components.ts # Импорт всех Material компонентов │ ├── material-components.ts # Импорт всех Material компонентов
│ ├── onboarding-steps.ts # Шаги онбординга по страницам/ролям (ментор, студент, родитель)
│ └── utils.ts # Вспомогательные функции │ └── utils.ts # Вспомогательные функции
├── styles/ # CSS стили ├── styles/ # CSS стили

View File

@ -44,6 +44,7 @@ export interface User {
language?: string; language?: string;
city?: string; city?: string;
country?: string; country?: string;
onboarding_tours_seen?: Record<string, boolean>;
} }
/** /**

View File

@ -17,6 +17,9 @@ export interface MentorHomeworkAISettings {
ai_trust_publish?: boolean; ai_trust_publish?: boolean;
} }
/** Прогресс онбординга: страница → просмотрено */
export type OnboardingToursSeen = Record<string, boolean>;
export interface ProfileSettings { export interface ProfileSettings {
preferences: { preferences: {
timezone?: string; timezone?: string;
@ -30,6 +33,8 @@ export interface ProfileSettings {
}; };
/** Только для ментора: доверие AI при проверке ДЗ */ /** Только для ментора: доверие AI при проверке ДЗ */
mentor_homework_ai?: MentorHomeworkAISettings; mentor_homework_ai?: MentorHomeworkAISettings;
/** Просмотренные туры онбординга по страницам */
onboarding_tours_seen?: OnboardingToursSeen;
} }
export async function getProfileSettings(): Promise<ProfileSettings> { export async function getProfileSettings(): Promise<ProfileSettings> {

View File

@ -1,260 +1,260 @@
/** /**
* API модуль для расписания занятий * API модуль для расписания занятий
*/ */
import apiClient from '@/lib/api-client'; import apiClient from '@/lib/api-client';
export interface Lesson { export interface Lesson {
id: string; id: string;
title: string; title: string;
subject?: string; subject?: string;
description?: string; description?: string;
start_time: string; start_time: string;
end_time: string; end_time: string;
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
mentor?: { mentor?: {
id: string; id: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
}; };
client?: { client?: {
id: string; id: string;
user?: { user?: {
id: string; id: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
}; };
}; };
client_name?: string; client_name?: string;
mentor_notes?: string; mentor_notes?: string;
mentor_grade?: number; mentor_grade?: number;
school_grade?: number; school_grade?: number;
homework_text?: string; homework_text?: string;
price?: number; price?: number;
meeting_url?: string; meeting_url?: string;
duration?: number; duration?: number;
group?: number; group?: number;
group_name?: string; group_name?: string;
livekit_room_name?: string; livekit_room_name?: string;
completed_at?: string; completed_at?: string;
} }
/** Файл урока (для экрана завершения занятия) */ /** Файл урока (для экрана завершения занятия) */
export interface LessonFile { export interface LessonFile {
id: string | number; id: string | number;
lesson: string | number; lesson: string | number;
file?: string; file?: string;
material?: string | number; material?: string | number;
source?: 'uploaded' | 'material'; source?: 'uploaded' | 'material';
filename: string; filename: string;
file_size?: number; file_size?: number;
file_size_display?: string; file_size_display?: string;
file_url?: string; file_url?: string;
description?: string; description?: string;
uploaded_by?: number; uploaded_by?: number;
uploaded_by_name?: string; uploaded_by_name?: string;
created_at?: string; created_at?: string;
} }
export interface CreateLessonFileData { export interface CreateLessonFileData {
lesson: string; lesson: string;
file?: File; file?: File;
material?: string; material?: string;
filename?: string; filename?: string;
description?: string; description?: string;
} }
/** /**
* Получить список занятий * Получить список занятий
* Для родителя передать child_id (user_id ребёнка). * Для родителя передать child_id (user_id ребёнка).
* Для ментора передать client_id (Client.id) занятия конкретного студента. * Для ментора передать client_id (Client.id) занятия конкретного студента.
*/ */
export async function getLessons(params?: { export async function getLessons(params?: {
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
status?: string; status?: string;
child_id?: string; child_id?: string;
client_id?: string; client_id?: string;
}): Promise<{ results: Lesson[]; count?: number }> { }): Promise<{ results: Lesson[]; count?: number }> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params?.start_date) queryParams.append('start_date', params.start_date); if (params?.start_date) queryParams.append('start_date', params.start_date);
if (params?.end_date) queryParams.append('end_date', params.end_date); if (params?.end_date) queryParams.append('end_date', params.end_date);
if (params?.status) queryParams.append('status', params.status); if (params?.status) queryParams.append('status', params.status);
if (params?.child_id) queryParams.append('child_id', params.child_id); if (params?.child_id) queryParams.append('child_id', params.child_id);
if (params?.client_id) queryParams.append('client_id', params.client_id); if (params?.client_id) queryParams.append('client_id', params.client_id);
const queryString = queryParams.toString(); const queryString = queryParams.toString();
const url = `/schedule/lessons/${queryString ? `?${queryString}` : ''}`; const url = `/schedule/lessons/${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<Lesson[] | { results: Lesson[]; count?: number }>(url); const response = await apiClient.get<Lesson[] | { results: Lesson[]; count?: number }>(url);
if (Array.isArray(response.data)) { if (Array.isArray(response.data)) {
return { results: response.data }; return { results: response.data };
} }
return response.data; return response.data;
} }
/** Ответ calendar API */ /** Ответ calendar API */
interface CalendarResponse { interface CalendarResponse {
success: boolean; success: boolean;
data: { start_date: string; end_date: string; lessons: Lesson[]; total: number }; data: { start_date: string; end_date: string; lessons: Lesson[]; total: number };
} }
/** /**
* Занятия для календаря (лёгкий endpoint по диапазону дат). * Занятия для календаря (лёгкий endpoint по диапазону дат).
* Для родителя передать child_id (user_id ребёнка). * Для родителя передать child_id (user_id ребёнка).
*/ */
export async function getLessonsCalendar(params: { export async function getLessonsCalendar(params: {
start_date: string; start_date: string;
end_date: string; end_date: string;
status?: string; status?: string;
child_id?: string; child_id?: string;
}): Promise<{ lessons: Lesson[] }> { }): Promise<{ lessons: Lesson[] }> {
const q = new URLSearchParams({ start_date: params.start_date, end_date: params.end_date }); const q = new URLSearchParams({ start_date: params.start_date, end_date: params.end_date });
if (params.status) q.append('status', params.status); if (params.status) q.append('status', params.status);
if (params.child_id) q.append('child_id', params.child_id); if (params.child_id) q.append('child_id', params.child_id);
// cache: false — после создания/редактирования/удаления занятия интерфейс должен обновиться без перезагрузки // cache: false — после создания/редактирования/удаления занятия интерфейс должен обновиться без перезагрузки
const res = await apiClient.get<CalendarResponse>(`/schedule/lessons/calendar/?${q}`, { cache: false }); const res = await apiClient.get<CalendarResponse>(`/schedule/lessons/calendar/?${q}`, { cache: false });
const lessons = res.data?.data?.lessons; const lessons = res.data?.data?.lessons;
return { lessons: Array.isArray(lessons) ? lessons : [] }; return { lessons: Array.isArray(lessons) ? lessons : [] };
} }
/** /**
* Получить занятие по ID * Получить занятие по ID
*/ */
export async function getLesson(id: string): Promise<Lesson> { export async function getLesson(id: string): Promise<Lesson> {
const response = await apiClient.get<Lesson>(`/schedule/lessons/${id}/`); const response = await apiClient.get<Lesson>(`/schedule/lessons/${id}/`);
return response.data; return response.data;
} }
export interface CreateLessonData { export interface CreateLessonData {
client: string; client: string;
title?: string; title?: string;
description?: string; description?: string;
start_time: string; start_time: string;
duration: number; duration: number;
price?: number; price?: number;
is_recurring?: boolean; is_recurring?: boolean;
subject_id?: number; subject_id?: number;
mentor_subject_id?: number; mentor_subject_id?: number;
subject_name?: string; subject_name?: string;
} }
export interface UpdateLessonData { export interface UpdateLessonData {
title?: string; title?: string;
description?: string; description?: string;
start_time?: string; start_time?: string;
duration?: number; duration?: number;
price?: number; price?: number;
/** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */ /** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
} }
/** /**
* Создать занятие * Создать занятие
*/ */
export async function createLesson(data: CreateLessonData): Promise<Lesson> { export async function createLesson(data: CreateLessonData): Promise<Lesson> {
const response = await apiClient.post<Lesson>('/schedule/lessons/', data); const response = await apiClient.post<Lesson>('/schedule/lessons/', data);
return response.data; return response.data;
} }
/** /**
* Обновить занятие * Обновить занятие
*/ */
export async function updateLesson(id: string, data: UpdateLessonData): Promise<Lesson> { export async function updateLesson(id: string, data: UpdateLessonData): Promise<Lesson> {
const response = await apiClient.patch<Lesson>(`/schedule/lessons/${id}/`, data); const response = await apiClient.patch<Lesson>(`/schedule/lessons/${id}/`, data);
return response.data; return response.data;
} }
/** /**
* Удалить занятие * Удалить занятие
*/ */
export async function deleteLesson(id: string, deleteAllFuture = false): Promise<void> { export async function deleteLesson(id: string, deleteAllFuture = false): Promise<void> {
await apiClient.delete(`/schedule/lessons/${id}/`, { await apiClient.delete(`/schedule/lessons/${id}/`, {
data: { delete_all_future: deleteAllFuture }, data: { delete_all_future: deleteAllFuture },
}); });
} }
/** Ответ API завершения занятия */ /** Ответ API завершения занятия */
export interface CompleteLessonResponse { export interface CompleteLessonResponse {
success: boolean; success: boolean;
message?: string; message?: string;
data?: Lesson; data?: Lesson;
} }
/** /**
* Завершить занятие / обновить обратную связь. * Завершить занятие / обновить обратную связь.
* lessonFileIds ID файлов урока, которые нужно привязать к ДЗ (только они попадут в «Файлы задания»). * lessonFileIds ID файлов урока, которые нужно привязать к ДЗ (только они попадут в «Файлы задания»).
*/ */
export async function completeLesson( export async function completeLesson(
id: string, id: string,
notes?: string, notes?: string,
mentorGrade?: number, mentorGrade?: number,
schoolGrade?: number, schoolGrade?: number,
homeworkText?: string, homeworkText?: string,
hasHomeworkFiles?: boolean, hasHomeworkFiles?: boolean,
lessonFileIds?: number[] lessonFileIds?: number[]
): Promise<CompleteLessonResponse> { ): Promise<CompleteLessonResponse> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
notes: notes ?? '', notes: notes ?? '',
mentor_grade: mentorGrade, mentor_grade: mentorGrade,
school_grade: schoolGrade, school_grade: schoolGrade,
homework_text: homeworkText, homework_text: homeworkText,
has_homework_files: hasHomeworkFiles, has_homework_files: hasHomeworkFiles,
}; };
if (lessonFileIds != null) { if (lessonFileIds != null) {
body.lesson_file_ids = lessonFileIds; body.lesson_file_ids = lessonFileIds;
} }
const response = await apiClient.post<CompleteLessonResponse>(`/schedule/lessons/${id}/complete/`, body); const response = await apiClient.post<CompleteLessonResponse>(`/schedule/lessons/${id}/complete/`, body);
return response.data; return response.data;
} }
/** /**
* Получить файлы урока (для экрана завершения занятия). * Получить файлы урока (для экрана завершения занятия).
*/ */
export async function getLessonFiles(lessonId: string): Promise<LessonFile[]> { export async function getLessonFiles(lessonId: string): Promise<LessonFile[]> {
const response = await apiClient.get<LessonFile[] | { results: LessonFile[] }>( const response = await apiClient.get<LessonFile[] | { results: LessonFile[] }>(
`/schedule/lesson-files/?lesson=${lessonId}` `/schedule/lesson-files/?lesson=${lessonId}`
); );
const data = response.data; const data = response.data;
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
return (data as { results: LessonFile[] })?.results ?? []; return (data as { results: LessonFile[] })?.results ?? [];
} }
/** /**
* Создать файл урока (загрузка файла или привязка материала). * Создать файл урока (загрузка файла или привязка материала).
*/ */
export async function createLessonFile(data: CreateLessonFileData): Promise<LessonFile> { export async function createLessonFile(data: CreateLessonFileData): Promise<LessonFile> {
const formData = new FormData(); const formData = new FormData();
formData.append('lesson', data.lesson); formData.append('lesson', data.lesson);
if (data.file) formData.append('file', data.file); if (data.file) formData.append('file', data.file);
if (data.material) formData.append('material', data.material); if (data.material) formData.append('material', data.material);
if (data.filename) formData.append('filename', data.filename); if (data.filename) formData.append('filename', data.filename);
if (data.description) formData.append('description', data.description); if (data.description) formData.append('description', data.description);
const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, { const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
return response.data; return response.data;
} }
/** /**
* Удалить файл урока. * Удалить файл урока.
*/ */
export async function deleteLessonFile(fileId: string): Promise<void> { export async function deleteLessonFile(fileId: string): Promise<void> {
await apiClient.delete(`/schedule/lesson-files/${fileId}/`); await apiClient.delete(`/schedule/lesson-files/${fileId}/`);
} }
/** /**
* Прикрепить файл к уроку (для ДЗ при завершении занятия). * Прикрепить файл к уроку (для ДЗ при завершении занятия).
* Возвращает созданный LessonFile (нужен id для передачи в complete как lesson_file_ids). * Возвращает созданный LessonFile (нужен id для передачи в complete как lesson_file_ids).
*/ */
export async function uploadLessonFile(lessonId: number | string, file: File): Promise<LessonFile> { export async function uploadLessonFile(lessonId: number | string, file: File): Promise<LessonFile> {
const formData = new FormData(); const formData = new FormData();
formData.append('lesson', String(lessonId)); formData.append('lesson', String(lessonId));
formData.append('file', file); formData.append('file', file);
const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, { const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
return response.data; return response.data;
} }

View File

@ -1,145 +1,145 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { requestPasswordReset } from '@/api/auth'; import { requestPasswordReset } from '@/api/auth';
import { getErrorMessage } from '@/lib/error-utils'; import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => { const loadMaterialComponents = async () => {
await Promise.all([ await Promise.all([
import('@material/web/textfield/filled-text-field.js'), import('@material/web/textfield/filled-text-field.js'),
import('@material/web/button/filled-button.js'), import('@material/web/button/filled-button.js'),
import('@material/web/button/text-button.js'), import('@material/web/button/text-button.js'),
]); ]);
}; };
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [componentsLoaded, setComponentsLoaded] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false);
useEffect(() => { useEffect(() => {
loadMaterialComponents() loadMaterialComponents()
.then(() => setComponentsLoaded(true)) .then(() => setComponentsLoaded(true))
.catch((err) => { .catch((err) => {
console.error('Error loading components:', err); console.error('Error loading components:', err);
setComponentsLoaded(true); setComponentsLoaded(true);
}); });
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
setSuccess(false); setSuccess(false);
try { try {
await requestPasswordReset({ email }); await requestPasswordReset({ email });
setSuccess(true); setSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.')); setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (!componentsLoaded) { if (!componentsLoaded) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
<div <div
style={{ style={{
width: '40px', width: '40px',
height: '40px', height: '40px',
border: '3px solid #e0e0e0', border: '3px solid #e0e0e0',
borderTopColor: 'var(--md-sys-color-primary, #6750a4)', borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
borderRadius: '50%', borderRadius: '50%',
animation: 'spin 0.8s linear infinite', animation: 'spin 0.8s linear infinite',
}} }}
/> />
</div> </div>
); );
} }
return ( return (
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}> <p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
Восстановление пароля Восстановление пароля
</p> </p>
{success ? ( {success ? (
<> <>
<div <div
style={{ style={{
padding: '16px', padding: '16px',
marginBottom: '24px', marginBottom: '24px',
background: '#e8f5e9', background: '#e8f5e9',
color: '#2e7d32', color: '#2e7d32',
borderRadius: '12px', borderRadius: '12px',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.5', lineHeight: '1.5',
}} }}
> >
Инструкции по восстановлению пароля отправлены на ваш email. Инструкции по восстановлению пароля отправлены на ваш email.
</div> </div>
<md-filled-button <md-filled-button
onClick={() => router.push('/login')} onClick={() => router.push('/login')}
style={{ width: '100%', height: '48px' }} style={{ width: '100%', height: '48px' }}
> >
Вернуться к входу Вернуться к входу
</md-filled-button> </md-filled-button>
</> </>
) : ( ) : (
<> <>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '20px' }}> <p style={{ fontSize: '14px', color: '#666', marginBottom: '20px' }}>
Введите ваш email для восстановления пароля Введите ваш email для восстановления пароля
</p> </p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<md-filled-text-field <md-filled-text-field
label="Email" label="Email"
type="email" type="email"
value={email} value={email}
onInput={(e: any) => setEmail(e.target.value || '')} onInput={(e: any) => setEmail(e.target.value || '')}
required required
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</div> </div>
{error && ( {error && (
<div <div
style={{ style={{
padding: '12px 16px', padding: '12px 16px',
marginBottom: '20px', marginBottom: '20px',
background: '#ffebee', background: '#ffebee',
color: '#c62828', color: '#c62828',
borderRadius: '12px', borderRadius: '12px',
fontSize: '14px', fontSize: '14px',
}} }}
> >
{error} {error}
</div> </div>
)} )}
<md-filled-button <md-filled-button
type="submit" type="submit"
disabled={loading} disabled={loading}
style={{ width: '100%', height: '48px', marginBottom: '16px' }} style={{ width: '100%', height: '48px', marginBottom: '16px' }}
> >
{loading ? 'Отправка...' : 'Отправить'} {loading ? 'Отправка...' : 'Отправить'}
</md-filled-button> </md-filled-button>
<div style={{ textAlign: 'center', marginTop: '20px' }}> <div style={{ textAlign: 'center', marginTop: '20px' }}>
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}> <md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
Вернуться к входу Вернуться к входу
</md-text-button> </md-text-button>
</div> </div>
</form> </form>
</> </>
)} )}
</div> </div>
); );
} }

View File

@ -1,214 +1,214 @@
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { confirmPasswordReset } from '@/api/auth'; import { confirmPasswordReset } from '@/api/auth';
import { getErrorMessage } from '@/lib/error-utils'; import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => { const loadMaterialComponents = async () => {
await Promise.all([ await Promise.all([
import('@material/web/textfield/filled-text-field.js'), import('@material/web/textfield/filled-text-field.js'),
import('@material/web/button/filled-button.js'), import('@material/web/button/filled-button.js'),
import('@material/web/button/text-button.js'), import('@material/web/button/text-button.js'),
]); ]);
}; };
function ResetPasswordContent() { function ResetPasswordContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [componentsLoaded, setComponentsLoaded] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false);
useEffect(() => { useEffect(() => {
loadMaterialComponents().then(() => setComponentsLoaded(true)); loadMaterialComponents().then(() => setComponentsLoaded(true));
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) { if (!token) {
setError('Отсутствует ссылка для сброса пароля. Запросите восстановление пароля снова.'); setError('Отсутствует ссылка для сброса пароля. Запросите восстановление пароля снова.');
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Пароли не совпадают'); setError('Пароли не совпадают');
return; return;
} }
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await confirmPasswordReset(token, password, confirmPassword); await confirmPasswordReset(token, password, confirmPassword);
setSuccess(true); setSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.')); setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (!componentsLoaded) { if (!componentsLoaded) {
return ( return (
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}> <h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
Uchill Uchill
</h1> </h1>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>Загрузка...</p> <p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>Загрузка...</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
<div <div
style={{ style={{
width: '40px', width: '40px',
height: '40px', height: '40px',
border: '3px solid #e0e0e0', border: '3px solid #e0e0e0',
borderTopColor: 'var(--md-sys-color-primary, #6750a4)', borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
borderRadius: '50%', borderRadius: '50%',
animation: 'spin 0.8s linear infinite', animation: 'spin 0.8s linear infinite',
}} }}
/> />
</div> </div>
</div> </div>
); );
} }
if (!token) { if (!token) {
return ( return (
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}> <h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
Uchill Uchill
</h1> </h1>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}> <p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
Сброс пароля Сброс пароля
</p> </p>
<div <div
style={{ style={{
padding: '16px', padding: '16px',
marginBottom: '24px', marginBottom: '24px',
background: '#ffebee', background: '#ffebee',
color: '#c62828', color: '#c62828',
borderRadius: '12px', borderRadius: '12px',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.5', lineHeight: '1.5',
}} }}
> >
Отсутствует ссылка для сброса пароля. Перейдите по ссылке из письма или запросите восстановление пароля снова. Отсутствует ссылка для сброса пароля. Перейдите по ссылке из письма или запросите восстановление пароля снова.
</div> </div>
<md-filled-button onClick={() => router.push('/forgot-password')} style={{ width: '100%', height: '48px' }}> <md-filled-button onClick={() => router.push('/forgot-password')} style={{ width: '100%', height: '48px' }}>
Восстановить пароль Восстановить пароль
</md-filled-button> </md-filled-button>
<div style={{ textAlign: 'center', marginTop: '20px' }}> <div style={{ textAlign: 'center', marginTop: '20px' }}>
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}> <md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
На страницу входа На страницу входа
</md-text-button> </md-text-button>
</div> </div>
</div> </div>
); );
} }
if (success) { if (success) {
return ( return (
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}> <h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
Uchill Uchill
</h1> </h1>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}> <p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
Сброс пароля Сброс пароля
</p> </p>
<div <div
style={{ style={{
padding: '16px', padding: '16px',
marginBottom: '24px', marginBottom: '24px',
background: '#e8f5e9', background: '#e8f5e9',
color: '#2e7d32', color: '#2e7d32',
borderRadius: '12px', borderRadius: '12px',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.5', lineHeight: '1.5',
}} }}
> >
Пароль успешно изменён. Войдите с новым паролем. Пароль успешно изменён. Войдите с новым паролем.
</div> </div>
<md-filled-button onClick={() => router.push('/login')} style={{ width: '100%', height: '48px' }}> <md-filled-button onClick={() => router.push('/login')} style={{ width: '100%', height: '48px' }}>
Войти Войти
</md-filled-button> </md-filled-button>
</div> </div>
); );
} }
return ( return (
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}> <p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
Введите новый пароль Введите новый пароль
</p> </p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<md-filled-text-field <md-filled-text-field
label="Новый пароль" label="Новый пароль"
type="password" type="password"
value={password} value={password}
onInput={(e: any) => setPassword(e.target.value || '')} onInput={(e: any) => setPassword(e.target.value || '')}
required required
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<md-filled-text-field <md-filled-text-field
label="Подтвердите пароль" label="Подтвердите пароль"
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onInput={(e: any) => setConfirmPassword(e.target.value || '')} onInput={(e: any) => setConfirmPassword(e.target.value || '')}
required required
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</div> </div>
{error && ( {error && (
<div <div
style={{ style={{
padding: '12px 16px', padding: '12px 16px',
marginBottom: '20px', marginBottom: '20px',
background: '#ffebee', background: '#ffebee',
color: '#c62828', color: '#c62828',
borderRadius: '12px', borderRadius: '12px',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.5', lineHeight: '1.5',
}} }}
> >
{error} {error}
</div> </div>
)} )}
<md-filled-button <md-filled-button
type="submit" type="submit"
disabled={loading} disabled={loading}
style={{ width: '100%', height: '48px', marginBottom: '16px' }} style={{ width: '100%', height: '48px', marginBottom: '16px' }}
> >
{loading ? 'Сохранение...' : 'Сохранить пароль'} {loading ? 'Сохранение...' : 'Сохранить пароль'}
</md-filled-button> </md-filled-button>
<div style={{ textAlign: 'center', marginTop: '20px' }}> <div style={{ textAlign: 'center', marginTop: '20px' }}>
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}> <md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
На страницу входа На страницу входа
</md-text-button> </md-text-button>
</div> </div>
</form> </form>
</div> </div>
); );
} }
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p> <p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p>
</div> </div>
} }
> >
<ResetPasswordContent /> <ResetPasswordContent />
</Suspense> </Suspense>
); );
} }

View File

@ -1,174 +1,174 @@
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { verifyEmail } from '@/api/auth'; import { verifyEmail } from '@/api/auth';
import { getErrorMessage } from '@/lib/error-utils'; import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => { const loadMaterialComponents = async () => {
await Promise.all([ await Promise.all([
import('@material/web/button/filled-button.js'), import('@material/web/button/filled-button.js'),
import('@material/web/button/text-button.js'), import('@material/web/button/text-button.js'),
]); ]);
}; };
function VerifyEmailContent() { function VerifyEmailContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [componentsLoaded, setComponentsLoaded] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false);
useEffect(() => { useEffect(() => {
loadMaterialComponents().then(() => setComponentsLoaded(true)); loadMaterialComponents().then(() => setComponentsLoaded(true));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!componentsLoaded || !token) { if (!componentsLoaded || !token) {
if (!token) { if (!token) {
setStatus('error'); setStatus('error');
setMessage('Отсутствует ссылка для подтверждения. Проверьте письмо или запросите новое.'); setMessage('Отсутствует ссылка для подтверждения. Проверьте письмо или запросите новое.');
} }
return; return;
} }
let cancelled = false; let cancelled = false;
verifyEmail(token) verifyEmail(token)
.then((res) => { .then((res) => {
if (cancelled) return; if (cancelled) return;
if (res.success) { if (res.success) {
setStatus('success'); setStatus('success');
setMessage('Email успешно подтверждён. Теперь вы можете войти в аккаунт.'); setMessage('Email успешно подтверждён. Теперь вы можете войти в аккаунт.');
} else { } else {
setStatus('error'); setStatus('error');
setMessage(res.message || 'Не удалось подтвердить email.'); setMessage(res.message || 'Не удалось подтвердить email.');
} }
}) })
.catch((err: any) => { .catch((err: any) => {
if (cancelled) return; if (cancelled) return;
setStatus('error'); setStatus('error');
setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.')); setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.'));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [token, componentsLoaded]); }, [token, componentsLoaded]);
if (!componentsLoaded) { if (!componentsLoaded) {
return ( return (
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}> <h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
Uchill Uchill
</h1> </h1>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}> <p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
Подтверждение email... Подтверждение email...
</p> </p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
<div <div
style={{ style={{
width: '40px', width: '40px',
height: '40px', height: '40px',
border: '3px solid #e0e0e0', border: '3px solid #e0e0e0',
borderTopColor: 'var(--md-sys-color-primary, #6750a4)', borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
borderRadius: '50%', borderRadius: '50%',
animation: 'spin 0.8s linear infinite', animation: 'spin 0.8s linear infinite',
}} }}
/> />
</div> </div>
</div> </div>
); );
} }
return ( return (
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}> <p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
Подтверждение email Подтверждение email
</p> </p>
{status === 'loading' && ( {status === 'loading' && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
<div <div
style={{ style={{
width: '40px', width: '40px',
height: '40px', height: '40px',
border: '3px solid #e0e0e0', border: '3px solid #e0e0e0',
borderTopColor: 'var(--md-sys-color-primary, #6750a4)', borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
borderRadius: '50%', borderRadius: '50%',
animation: 'spin 0.8s linear infinite', animation: 'spin 0.8s linear infinite',
}} }}
/> />
</div> </div>
)} )}
{status === 'success' && ( {status === 'success' && (
<> <>
<div <div
style={{ style={{
padding: '16px', padding: '16px',
marginBottom: '24px', marginBottom: '24px',
background: '#e8f5e9', background: '#e8f5e9',
color: '#2e7d32', color: '#2e7d32',
borderRadius: '12px', borderRadius: '12px',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.5', lineHeight: '1.5',
}} }}
> >
{message} {message}
</div> </div>
<md-filled-button <md-filled-button
onClick={() => router.push('/login')} onClick={() => router.push('/login')}
style={{ width: '100%', height: '48px' }} style={{ width: '100%', height: '48px' }}
> >
Войти в аккаунт Войти в аккаунт
</md-filled-button> </md-filled-button>
</> </>
)} )}
{status === 'error' && ( {status === 'error' && (
<> <>
<div <div
style={{ style={{
padding: '16px', padding: '16px',
marginBottom: '24px', marginBottom: '24px',
background: '#ffebee', background: '#ffebee',
color: '#c62828', color: '#c62828',
borderRadius: '12px', borderRadius: '12px',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.5', lineHeight: '1.5',
}} }}
> >
{message} {message}
</div> </div>
<md-filled-button <md-filled-button
onClick={() => router.push('/login')} onClick={() => router.push('/login')}
style={{ width: '100%', height: '48px', marginBottom: '12px' }} style={{ width: '100%', height: '48px', marginBottom: '12px' }}
> >
На страницу входа На страницу входа
</md-filled-button> </md-filled-button>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<md-text-button onClick={() => router.push('/register')} style={{ fontSize: '14px' }}> <md-text-button onClick={() => router.push('/register')} style={{ fontSize: '14px' }}>
Зарегистрироваться Зарегистрироваться
</md-text-button> </md-text-button>
</div> </div>
</> </>
)} )}
</div> </div>
); );
} }
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: '400px' }}>
<p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p> <p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p>
</div> </div>
} }
> >
<VerifyEmailContent /> <VerifyEmailContent />
</Suspense> </Suspense>
); );
} }

View File

@ -174,7 +174,7 @@ export default function AnalyticsPage() {
); );
return ( return (
<DashboardLayout className="ios26-dashboard-analytics"> <DashboardLayout className="ios26-dashboard-analytics" data-tour="analytics-root">
<div className="ios26-analytics-swiper-wrap"> <div className="ios26-analytics-swiper-wrap">
<Swiper <Swiper
onSwiper={setSwiperInstance} onSwiper={setSwiperInstance}

View File

@ -189,7 +189,7 @@ export default function ChatPage() {
}, [mobileShowChat]); }, [mobileShowChat]);
return ( return (
<div className="ios26-dashboard ios26-chat-page" style={{ padding: isMobile ? '8px' : '16px' }}> <div className="ios26-dashboard ios26-chat-page" data-tour="chat-root" style={{ padding: isMobile ? '8px' : '16px' }}>
<Box <Box
className="ios26-chat-layout" className="ios26-chat-layout"
sx={{ sx={{

View File

@ -201,7 +201,7 @@ export default function FeedbackPage() {
}; };
return ( return (
<DashboardLayout className="ios26-dashboard ios26-feedback-page"> <DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="feedback-root">
{error && ( {error && (
<div <div
style={{ style={{

View File

@ -10,6 +10,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { NavBadgesProvider } from '@/contexts/NavBadgesContext'; import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
import { SelectedChildProvider } from '@/contexts/SelectedChildContext'; import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
import { OnboardingProvider } from '@/contexts/OnboardingContext';
import { getNavBadges } from '@/api/navBadges'; import { getNavBadges } from '@/api/navBadges';
import { getActiveSubscription } from '@/api/subscriptions'; import { getActiveSubscription } from '@/api/subscriptions';
import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals'; import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals';
@ -148,6 +149,7 @@ export default function ProtectedLayout({
return ( return (
<NavBadgesProvider refreshNavBadges={refreshNavBadges}> <NavBadgesProvider refreshNavBadges={refreshNavBadges}>
<SelectedChildProvider> <SelectedChildProvider>
<OnboardingProvider>
<div className="protected-layout-root"> <div className="protected-layout-root">
{!isFullWidthPage && <TopNavigationBar user={user} />} {!isFullWidthPage && <TopNavigationBar user={user} />}
<main <main
@ -176,6 +178,7 @@ export default function ProtectedLayout({
<NotificationBell /> <NotificationBell />
)} )}
</div> </div>
</OnboardingProvider>
</SelectedChildProvider> </SelectedChildProvider>
</NavBadgesProvider> </NavBadgesProvider>
); );

View File

@ -573,7 +573,7 @@ export default function MaterialsPage() {
} }
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }} data-tour="materials-root">
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -587,6 +587,7 @@ export default function MaterialsPage() {
{!isClient && ( {!isClient && (
<button <button
type="button" type="button"
data-tour="materials-add"
onClick={() => setAddPanelOpen(true)} onClick={() => setAddPanelOpen(true)}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',

View File

@ -288,7 +288,7 @@ export default function MyProgressPage() {
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<DashboardLayout className="ios26-dashboard-grid"> <DashboardLayout className="ios26-dashboard-grid" data-tour="my-progress-root">
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */} {/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
<Panel padding="md"> <Panel padding="md">
<SectionHeader <SectionHeader

View File

@ -10,7 +10,7 @@ const ProfilePaymentTab = dynamic(
export default function PaymentPage() { export default function PaymentPage() {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }} data-tour="payment-root">
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}> <h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}>
Подписки и оплата Подписки и оплата
</h1> </h1>

View File

@ -24,6 +24,7 @@ import { ProfilePaymentTab } from '@/components/profile/ProfilePaymentTab';
import { NotificationSettingsSection } from '@/components/profile/NotificationSettingsSection'; import { NotificationSettingsSection } from '@/components/profile/NotificationSettingsSection';
import { ParentChildNotificationSettings } from '@/components/profile/ParentChildNotificationSettings'; import { ParentChildNotificationSettings } from '@/components/profile/ParentChildNotificationSettings';
import { TelegramSection } from '@/components/profile/TelegramSection'; import { TelegramSection } from '@/components/profile/TelegramSection';
import { OnboardingTipsSection } from '@/components/profile/OnboardingTipsSection';
import { Switch } from '@/components/common/Switch'; import { Switch } from '@/components/common/Switch';
function getAvatarUrl(user: { avatar_url?: string | null; avatar?: string | null } | null): string | null { function getAvatarUrl(user: { avatar_url?: string | null; avatar?: string | null } | null): string | null {
@ -382,6 +383,7 @@ function ProfilePage() {
return ( return (
<div <div
className="page-profile" className="page-profile"
data-tour="profile-root"
style={{ style={{
padding: 24, padding: 24,
position: 'relative', position: 'relative',
@ -893,6 +895,11 @@ function ProfilePage() {
{saving ? 'Сохранение...' : saveSuccess ? 'Профиль успешно обновлён' : 'Сохранить'} {saving ? 'Сохранение...' : saveSuccess ? 'Профиль успешно обновлён' : 'Сохранить'}
</button> </button>
</div> </div>
{(user?.role === 'mentor' || user?.role === 'client') && (
<div style={{ marginBottom: 24 }}>
<OnboardingTipsSection />
</div>
)}
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 16px 0', color: '#282C32' }}> <h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 16px 0', color: '#282C32' }}>
Настройки уведомлений Настройки уведомлений
</h2> </h2>

View File

@ -6,6 +6,7 @@ export default function ReferralsPage() {
return ( return (
<div <div
className="page-referrals" className="page-referrals"
data-tour="referrals-root"
style={{ style={{
padding: 24, padding: 24,
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)', background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',

View File

@ -122,9 +122,11 @@ export default function RequestMentorPage() {
style={{ style={{
padding: '24px', padding: '24px',
}} }}
data-tour="request-mentor-root"
> >
{/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */} {/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */}
<div <div
data-tour="request-mentor-tabs"
style={{ style={{
display: 'flex', display: 'flex',
gap: 4, gap: 4,

View File

@ -113,42 +113,62 @@ export default function SchedulePage() {
})(); })();
}, [isFormVisible]); }, [isFormVisible]);
const loadLessons = useCallback(async () => { const loadLessons = useCallback(
const start = startOfMonth(subMonths(visibleMonth, 1)); async (merge?: boolean) => {
const end = endOfMonth(addMonths(visibleMonth, 1)); const start = startOfMonth(subMonths(visibleMonth, 1));
const isInitial = !hasLoadedLessonsOnceRef.current; const end = endOfMonth(addMonths(visibleMonth, 1));
try { const doMerge = merge ?? hasLoadedLessonsOnceRef.current;
if (isInitial) setLessonsLoading(true); const isInitial = !hasLoadedLessonsOnceRef.current && !doMerge;
setError(null); try {
const { lessons: lessonsData } = await getLessonsCalendar({ setLessonsLoading(true);
start_date: format(start, 'yyyy-MM-dd'), setError(null);
end_date: format(end, 'yyyy-MM-dd'), const { lessons: lessonsData } = await getLessonsCalendar({
...(selectedChild?.id && { child_id: selectedChild.id }), start_date: format(start, 'yyyy-MM-dd'),
}); end_date: format(end, 'yyyy-MM-dd'),
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({ ...(selectedChild?.id && { child_id: selectedChild.id }),
id: lesson.id, });
title: lesson.title, const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
start_time: lesson.start_time, id: lesson.id,
end_time: lesson.end_time, title: lesson.title,
status: lesson.status, start_time: lesson.start_time,
client: lesson.client?.id, end_time: lesson.end_time,
client_name: lesson.client_name ?? (lesson.client?.user status: lesson.status,
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim() client: lesson.client?.id,
: undefined), client_name: lesson.client_name ?? (lesson.client?.user
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name ? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim() : undefined),
: undefined), mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
subject: lesson.subject ?? lesson.subject_name ?? '', ? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
})); : undefined),
setLessons(mappedLessons); subject: lesson.subject ?? lesson.subject_name ?? '',
hasLoadedLessonsOnceRef.current = true; }));
} catch (err: any) { if (doMerge) {
console.error('Error loading lessons:', err); setLessons((prev) => {
setError(err?.message || 'Ошибка загрузки занятий'); const startStr = format(start, 'yyyy-MM-dd');
} finally { const endStr = format(end, 'yyyy-MM-dd');
if (isInitial) setLessonsLoading(false); const byId = new Map<string, CalendarLesson>();
} prev.forEach((l) => {
}, [visibleMonth, selectedChild?.id]); 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(() => { useEffect(() => {
loadLessons(); loadLessons();
@ -442,7 +462,7 @@ export default function SchedulePage() {
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента // чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
minHeight: 'min(calc(100vh - 160px), 600px)', minHeight: 'min(calc(100vh - 160px), 600px)',
}}> }}>
<div className="ios26-schedule-calendar-wrap"> <div className="ios26-schedule-calendar-wrap" data-tour="schedule-calendar">
<Calendar <Calendar
lessons={lessons} lessons={lessons}
lessonsLoading={lessonsLoading} lessonsLoading={lessonsLoading}
@ -454,7 +474,7 @@ export default function SchedulePage() {
userTimezone={user?.timezone} userTimezone={user?.timezone}
/> />
</div> </div>
<div className="ios26-schedule-right-wrap"> <div className="ios26-schedule-right-wrap" data-tour="schedule-form">
<CheckLesson <CheckLesson
selectedDate={selectedDate} selectedDate={selectedDate}
displayDate={displayDate} displayDate={displayDate}

View File

@ -414,6 +414,7 @@ export default function StudentsPage() {
return ( return (
<div <div
className="page-students" className="page-students"
data-tour="students-list"
style={{ style={{
padding: '24px', padding: '24px',
}} }}

View File

@ -96,13 +96,14 @@ export const Calendar: React.FC<CalendarProps> = ({
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{lessonsLoading ? ( {lessonsLoading && lessons.length === 0 ? (
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />
) : ( ) : (
<LessonsCalendar <LessonsCalendar
lessons={mappedLessons} lessons={mappedLessons}
selectedDate={selectedDate} selectedDate={selectedDate}
userTimezone={userTimezone} userTimezone={userTimezone}
loading={lessonsLoading}
onSelectSlot={(date) => { onSelectSlot={(date) => {
try { try {
const d = startOfDay(date); const d = startOfDay(date);

View File

@ -60,6 +60,7 @@ export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMo
return ( return (
<Box <Box
className="ios-glass-panel" className="ios-glass-panel"
data-tour="chat-list"
sx={{ sx={{
borderRadius: '20px', borderRadius: '20px',
p: 2, p: 2,

File diff suppressed because it is too large Load Diff

View File

@ -74,13 +74,18 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
} }
return ( return (
<div style={{ <div
width: '100%', data-tour="client-lessons"
maxWidth: '100%', style={{
padding: '16px', width: '100%',
}}> maxWidth: '100%',
padding: '16px',
}}
>
{/* Статистика студента */} {/* Статистика студента */}
<div style={{ <div
data-tour="client-stats"
style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '16px', gap: '16px',
@ -140,7 +145,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
{/* Следующее занятие */} {/* Следующее занятие */}
{stats?.next_lesson && ( {stats?.next_lesson && (
<div style={{ <div
data-tour="client-next-lesson"
style={{
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: '20px', borderRadius: '20px',
padding: '24px', padding: '24px',
@ -168,7 +175,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
marginBottom: '24px' marginBottom: '24px'
}}> }}>
{/* Домашние задания */} {/* Домашние задания */}
<div style={{ <div
data-tour="client-homework"
style={{
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: '20px', borderRadius: '20px',
padding: '24px', padding: '24px',
@ -206,7 +215,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
</div> </div>
{/* Ближайшие занятия */} {/* Ближайшие занятия */}
<div style={{ <div
data-tour="client-upcoming"
style={{
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: '20px', borderRadius: '20px',
padding: '24px', padding: '24px',

View File

@ -26,6 +26,7 @@ import { ru } from 'date-fns/locale';
import { Box, IconButton, Typography } from '@mui/material'; import { Box, IconButton, Typography } from '@mui/material';
import { ChevronLeft, ChevronRight } from '@mui/icons-material'; import { ChevronLeft, ChevronRight } from '@mui/icons-material';
import { parseISOToUserTimezone } from '@/utils/timezone'; import { parseISOToUserTimezone } from '@/utils/timezone';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
interface Lesson { interface Lesson {
id: string; id: string;
@ -48,6 +49,8 @@ interface LessonsCalendarProps {
onMonthChange?: (start: Date, end: Date) => void; onMonthChange?: (start: Date, end: Date) => void;
selectedDate?: Date; selectedDate?: Date;
userTimezone?: string; userTimezone?: string;
/** Идёт загрузка данных (запрос нового месяца) — блокирует навигацию */
loading?: boolean;
} }
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
@ -57,6 +60,7 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
onMonthChange, onMonthChange,
selectedDate, selectedDate,
userTimezone, userTimezone,
loading = false,
}) => { }) => {
const safeSelectedDate = useMemo(() => { const safeSelectedDate = useMemo(() => {
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate); if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
@ -176,24 +180,30 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<IconButton <IconButton
onClick={goPrevMonth} onClick={loading ? undefined : goPrevMonth}
size="small" size="small"
disabled={loading}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)', backgroundColor: 'var(--md-sys-color-surface)',
opacity: loading ? 0.6 : 1,
pointerEvents: loading ? 'none' : 'auto',
}} }}
> >
<ChevronLeft fontSize="small" /> <ChevronLeft fontSize="small" />
</IconButton> </IconButton>
<IconButton <IconButton
onClick={goToday} onClick={loading ? undefined : goToday}
size="small" size="small"
disabled={loading}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
px: 1.25, px: 1.25,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)', backgroundColor: 'var(--md-sys-color-surface)',
opacity: loading ? 0.6 : 1,
pointerEvents: loading ? 'none' : 'auto',
}} }}
> >
<Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}> <Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
@ -201,16 +211,24 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
</Typography> </Typography>
</IconButton> </IconButton>
<IconButton <IconButton
onClick={goNextMonth} onClick={loading ? undefined : goNextMonth}
size="small" size="small"
disabled={loading}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)', backgroundColor: 'var(--md-sys-color-surface)',
opacity: loading ? 0.6 : 1,
pointerEvents: loading ? 'none' : 'auto',
}} }}
> >
<ChevronRight fontSize="small" /> <ChevronRight fontSize="small" />
</IconButton> </IconButton>
{loading && (
<Box sx={{ ml: 0.5, display: 'flex', alignItems: 'center' }}>
<LoadingSpinner size="small" inline />
</Box>
)}
</Box> </Box>
</Box> </Box>

View File

@ -140,7 +140,7 @@ export const ExtraStatsSection: React.FC<ExtraStatsSectionProps> = ({ stats, loa
const rows = buildRows(stats, loading).slice(0, 9); const rows = buildRows(stats, loading).slice(0, 9);
return ( return (
<Panel padding="md"> <Panel padding="md" data-tour="mentor-extrastats">
<SectionHeader title="Статистика" /> <SectionHeader title="Статистика" />
<div className="ios26-stat-grid"> <div className="ios26-stat-grid">
{rows.map((row, index) => { {rows.map((row, index) => {

View File

@ -37,7 +37,7 @@ export const IncomeSection: React.FC<IncomeSectionProps> = ({
const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0); const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0);
return ( return (
<Panel padding="md"> <Panel padding="md" data-tour="mentor-income">
<SectionHeader <SectionHeader
title="Динамика доходов" title="Динамика доходов"
trailing={ trailing={

View File

@ -125,7 +125,7 @@ export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> =
flipped={flipped} flipped={flipped}
onFlippedChange={setFlipped} onFlippedChange={setFlipped}
front={ front={
<Panel padding="md"> <Panel padding="md" data-tour="mentor-submissions">
<SectionHeader title="Последние сданные ДЗ" /> <SectionHeader title="Последние сданные ДЗ" />
{loading && !data ? ( {loading && !data ? (
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />

View File

@ -107,7 +107,7 @@ export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
setFlipped(v); setFlipped(v);
}} }}
front={ front={
<Panel padding="md"> <Panel padding="md" data-tour="mentor-upcoming">
<SectionHeader title="Ближайшие занятия" /> <SectionHeader title="Ближайшие занятия" />
{loading && !data ? ( {loading && !data ? (
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />

View File

@ -11,11 +11,13 @@ export interface DashboardLayoutProps {
children: React.ReactNode; children: React.ReactNode;
/** Дополнительный класс для контейнера */ /** Дополнительный класс для контейнера */
className?: string; className?: string;
/** data-tour для онбординга */
'data-tour'?: string;
} }
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, className = '' }) => { export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, className = '', 'data-tour': dataTour }) => {
return ( return (
<div className={`ios26-dashboard ${className}`.trim()}> <div className={`ios26-dashboard ${className}`.trim()} data-tour={dataTour}>
{children} {children}
</div> </div>
); );

View File

@ -1,83 +1,83 @@
/** /**
* Карточка с лицевой и обратной стороной (переключение без анимации переворота). * Карточка с лицевой и обратной стороной (переключение без анимации переворота).
*/ */
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
export interface FlipCardProps { export interface FlipCardProps {
/** Контент лицевой стороны */ /** Контент лицевой стороны */
front: React.ReactNode; front: React.ReactNode;
/** Контент обратной стороны */ /** Контент обратной стороны */
back: React.ReactNode; back: React.ReactNode;
/** Высота карточки */ /** Высота карточки */
height?: string | number; height?: string | number;
/** Дополнительный класс */ /** Дополнительный класс */
className?: string; className?: string;
/** Управляемый режим (если задан) */ /** Управляемый режим (если задан) */
flipped?: boolean; flipped?: boolean;
/** Коллбек при смене состояния */ /** Коллбек при смене состояния */
onFlippedChange?: (flipped: boolean) => void; onFlippedChange?: (flipped: boolean) => void;
} }
export const FlipCard: React.FC<FlipCardProps> = ({ export const FlipCard: React.FC<FlipCardProps> = ({
front, front,
back, back,
height = 'auto', height = 'auto',
className = '', className = '',
flipped, flipped,
onFlippedChange, onFlippedChange,
}) => { }) => {
const [internalFlipped, setInternalFlipped] = useState(false); const [internalFlipped, setInternalFlipped] = useState(false);
const isControlled = useMemo(() => flipped !== undefined, [flipped]); const isControlled = useMemo(() => flipped !== undefined, [flipped]);
const isFlipped = isControlled ? (flipped as boolean) : internalFlipped; const isFlipped = isControlled ? (flipped as boolean) : internalFlipped;
const setFlipped = (next: boolean) => { const setFlipped = (next: boolean) => {
if (!isControlled) setInternalFlipped(next); if (!isControlled) setInternalFlipped(next);
onFlippedChange?.(next); onFlippedChange?.(next);
}; };
return ( return (
<div <div
className={`flip-card ${className}`.trim()} className={`flip-card ${className}`.trim()}
style={{ style={{
position: 'relative', position: 'relative',
height: typeof height === 'number' ? `${height}px` : height, height: typeof height === 'number' ? `${height}px` : height,
width: '100%', width: '100%',
...(height === 'auto' && { minHeight: 340 }), ...(height === 'auto' && { minHeight: 340 }),
}} }}
> >
<div <div
className="flip-card-front" className="flip-card-front"
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
height: '100%', height: '100%',
opacity: isFlipped ? 0 : 1, opacity: isFlipped ? 0 : 1,
visibility: isFlipped ? 'hidden' : 'visible', visibility: isFlipped ? 'hidden' : 'visible',
transition: 'opacity 0.2s ease', transition: 'opacity 0.2s ease',
}} }}
> >
{front} {front}
</div> </div>
<div <div
className="flip-card-back" className="flip-card-back"
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
height: '100%', height: '100%',
opacity: isFlipped ? 1 : 0, opacity: isFlipped ? 1 : 0,
visibility: isFlipped ? 'visible' : 'hidden', visibility: isFlipped ? 'visible' : 'hidden',
transition: 'opacity 0.2s ease', transition: 'opacity 0.2s ease',
}} }}
> >
{back} {back}
</div> </div>
</div> </div>
); );
}; };

View File

@ -16,6 +16,8 @@ export interface PanelProps {
/** Внутренние отступы. По умолчанию 24px */ /** Внутренние отступы. По умолчанию 24px */
padding?: 'none' | 'sm' | 'md' | 'lg'; padding?: 'none' | 'sm' | 'md' | 'lg';
style?: React.CSSProperties; style?: React.CSSProperties;
/** Атрибут для онбординга (data-tour) */
'data-tour'?: string;
} }
const paddingMap = { const paddingMap = {
@ -31,10 +33,12 @@ export const Panel: React.FC<PanelProps> = ({
interactive = false, interactive = false,
padding = 'md', padding = 'md',
style, style,
'data-tour': dataTour,
}) => { }) => {
const p = paddingMap[padding]; const p = paddingMap[padding];
return ( return (
<div <div
data-tour={dataTour}
className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()} className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()}
style={{ style={{
padding: p ? `${p}px` : 0, padding: p ? `${p}px` : 0,

View File

@ -219,7 +219,7 @@ export function HomeworkPageContent() {
); );
return ( return (
<DashboardLayout className="ios26-dashboard ios26-feedback-page"> <DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="homework-root">
{error && ( {error && (
<div <div
style={{ style={{

View File

@ -91,6 +91,7 @@ import { getOrCreateLessonChat } from '@/api/chat';
import type { Chat } from '@/api/chat'; import type { Chat } from '@/api/chat';
import { ChatWindow } from '@/components/chat/ChatWindow'; import { ChatWindow } from '@/components/chat/ChatWindow';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useOnboarding } from '@/contexts/OnboardingContext';
import { getAvatarUrl } from '@/api/profile'; import { getAvatarUrl } from '@/api/profile';
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
import { getNavBadges } from '@/api/navBadges'; import { getNavBadges } from '@/api/navBadges';
@ -817,6 +818,7 @@ export default function LiveKitRoomContent() {
const accessToken = searchParams.get('token'); const accessToken = searchParams.get('token');
const lessonIdParam = searchParams.get('lesson_id'); const lessonIdParam = searchParams.get('lesson_id');
const { user } = useAuth(); const { user } = useAuth();
const onboarding = useOnboarding();
const [serverUrl, setServerUrl] = useState<string>(''); const [serverUrl, setServerUrl] = useState<string>('');
const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams)); 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(() => { useEffect(() => {
const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null; const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
if (id && !isNaN(id)) { if (id && !isNaN(id)) {

View File

@ -45,7 +45,7 @@ export function ChildSelectorCompact() {
const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?'; const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?';
return ( return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}> <div ref={ref} data-tour="parent-child-selector" style={{ position: 'relative', flexShrink: 0 }}>
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}

View File

@ -164,6 +164,7 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
<div <div
data-notification-bell data-notification-bell
data-tour="notifications-bell"
style={ style={
embedded embedded
? { ? {

View File

@ -0,0 +1,149 @@
'use client';
import { useState, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useOnboarding } from '@/contexts/OnboardingContext';
import { useAuth } from '@/contexts/AuthContext';
import { getOnboardingKey } from '@/lib/onboarding-steps';
const PAGE_LABELS: Record<string, string> = {
dashboard: 'Главная',
schedule: 'Расписание',
students: 'Студенты',
materials: 'Материалы',
homework: 'Домашние задания',
feedback: 'Обратная связь',
analytics: 'Аналитика',
payment: 'Тарифы',
referrals: 'Рефералы',
profile: 'Профиль',
chat: 'Чат',
'my-progress': 'Прогресс',
'request-mentor': 'Мои менторы',
};
const MENTOR_PAGES = ['dashboard', 'schedule', 'students', 'materials', 'homework', 'feedback', 'analytics', 'payment', 'referrals', 'profile'];
const CLIENT_PAGES = ['dashboard', 'schedule', 'chat', 'materials', 'homework', 'my-progress', 'request-mentor', 'profile'];
export function OnboardingTipsSection() {
const onboarding = useOnboarding();
const { user } = useAuth();
const pathname = usePathname();
const router = useRouter();
const [progress, setProgress] = useState({ seen: 0, total: 0 });
const [expanded, setExpanded] = useState(false);
const role = user?.role === 'mentor' ? 'mentor' : user?.role === 'client' ? 'client' : user?.role === 'parent' ? 'parent' : null;
const pages = role === 'mentor' ? MENTOR_PAGES : role === 'client' ? CLIENT_PAGES : MENTOR_PAGES;
useEffect(() => {
if (!onboarding) return;
onboarding.refreshProgress().then(() => {
setProgress(onboarding.getProgress());
});
}, [onboarding, pathname]);
if (!onboarding || !role) return null;
const currentKey = getOnboardingKey(pathname || '', role as 'mentor' | 'client' | 'parent');
const handleShowAgain = () => {
if (currentKey) onboarding.runTourManually(currentKey, { force: true });
};
const handleShowOnPage = (pageKey: string) => {
setExpanded(false);
const path = pageKey === 'dashboard' ? '/dashboard' : `/${pageKey}`;
router.push(path);
setTimeout(() => onboarding.runTourManually(pageKey, { force: true }), 800);
};
if (progress.total === 0) return null;
return (
<div
className="ios26-panel"
style={{
padding: 20,
borderRadius: 16,
background: 'var(--md-sys-color-surface-container-low)',
border: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
}}
>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 12px 0', color: 'var(--md-sys-color-on-surface)' }}>
Подсказки по платформе
</h3>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: '0 0 16px 0', lineHeight: 1.5 }}>
Пройдено {progress.seen} из {progress.total} страниц
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{currentKey && (
<button
type="button"
onClick={handleShowAgain}
style={{
padding: '10px 16px',
fontSize: 14,
fontWeight: 600,
color: 'var(--md-sys-color-on-primary)',
background: 'var(--md-sys-color-primary)',
border: 'none',
borderRadius: 12,
cursor: 'pointer',
}}
>
Показать подсказки снова
</button>
)}
<button
type="button"
onClick={() => setExpanded(!expanded)}
style={{
padding: '10px 16px',
fontSize: 14,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: '1px solid var(--md-sys-color-primary)',
borderRadius: 12,
cursor: 'pointer',
}}
>
{expanded ? 'Свернуть' : 'Подсказки на другой странице'}
</button>
</div>
{expanded && (
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
display: 'flex',
flexWrap: 'wrap',
gap: 8,
}}
>
{pages.map((key) => (
<button
key={key}
type="button"
onClick={() => handleShowOnPage(key)}
style={{
padding: '8px 12px',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
background: 'var(--md-sys-color-surface-container-high)',
border: 'none',
borderRadius: 10,
cursor: 'pointer',
}}
>
{PAGE_LABELS[key] || key}
</button>
))}
</div>
)}
</div>
);
}

View File

@ -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<void>;
runTourManually: (pageKey: string, options?: { force?: boolean }) => void;
getProgress: () => { seen: number; total: number };
refreshProgress: () => Promise<void>;
}
const OnboardingContext = createContext<OnboardingContextType | null>(null);
export function useOnboarding() {
return useContext(OnboardingContext);
}
export function OnboardingProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user } = useAuth();
const toursSeenRef = useRef<Record<string, boolean>>({});
const driverRef = useRef<Driver | null>(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 (
<OnboardingContext.Provider value={value}>
{children}
</OnboardingContext.Provider>
);
}

View File

@ -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'); // для текущей роли
```

View File

@ -25,6 +25,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"driver.js": "^1.3.1",
"livekit-client": "^2.16.0", "livekit-client": "^2.16.0",
"next": "^16.1.4", "next": "^16.1.4",
"react": "^19", "react": "^19",

View File

@ -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;
}

View File

@ -1,66 +1,66 @@
#!/bin/bash #!/bin/bash
# Скрипт для полной пересборки PROD окружения с бэкапом БД # Скрипт для полной пересборки PROD окружения с бэкапом БД
set -e set -e
echo "==========================================" echo "=========================================="
echo "Полная пересборка PROD окружения" echo "Полная пересборка PROD окружения"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "⚠️ ВНИМАНИЕ: Это пересоберёт все контейнеры без кэша" echo "⚠️ ВНИМАНИЕ: Это пересоберёт все контейнеры без кэша"
echo "" echo ""
cd "$(dirname "$0")" cd "$(dirname "$0")"
# Шаг 1: Создать бэкап БД # Шаг 1: Создать бэкап БД
echo "Шаг 1: Создание бэкапа БД..." echo "Шаг 1: Создание бэкапа БД..."
if [ -f "./backup-all-db.sh" ]; then if [ -f "./backup-all-db.sh" ]; then
./backup-all-db.sh ./backup-all-db.sh
else else
echo "⚠️ Скрипт backup-all-db.sh не найден, создаём бэкап вручную..." echo "⚠️ Скрипт backup-all-db.sh не найден, создаём бэкап вручную..."
mkdir -p ./backups mkdir -p ./backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
if docker ps | grep -q platform_prod_db; then 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" 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" echo "✓ Бэкап создан: ./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz"
else else
echo "⚠️ Контейнер БД не запущен, пропускаем бэкап" echo "⚠️ Контейнер БД не запущен, пропускаем бэкап"
fi fi
fi fi
echo "" echo ""
echo "Шаг 2: Остановка контейнеров..." echo "Шаг 2: Остановка контейнеров..."
docker compose down docker compose down
echo "" echo ""
echo "Шаг 3: Пересборка образов без кэша..." echo "Шаг 3: Пересборка образов без кэша..."
echo "Это может занять несколько минут..." echo "Это может занять несколько минут..."
docker compose build --no-cache --pull docker compose build --no-cache --pull
echo "" echo ""
echo "Шаг 4: Запуск контейнеров..." echo "Шаг 4: Запуск контейнеров..."
docker compose up -d docker compose up -d
echo "" echo ""
echo "Шаг 5: Ожидание запуска БД..." echo "Шаг 5: Ожидание запуска БД..."
sleep 10 sleep 10
echo "" echo ""
echo "Шаг 6: Применение миграций..." echo "Шаг 6: Применение миграций..."
docker exec platform_prod_web python manage.py migrate docker exec platform_prod_web python manage.py migrate
echo "" echo ""
echo "Шаг 7: Проверка статуса контейнеров..." echo "Шаг 7: Проверка статуса контейнеров..."
docker compose ps docker compose ps
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "✓ Пересборка завершена!" echo "✓ Пересборка завершена!"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "Проверьте логи:" echo "Проверьте логи:"
echo " docker compose logs -f" echo " docker compose logs -f"
echo "" echo ""
echo "Если нужно создать суперпользователя:" echo "Если нужно создать суперпользователя:"
echo " docker exec -it platform_prod_web python manage.py createsuperuser" echo " docker exec -it platform_prod_web python manage.py createsuperuser"

View File

@ -1,31 +1,31 @@
#!/bin/bash #!/bin/bash
# Скрипт для удаления автоматического бэкапа из cron # Скрипт для удаления автоматического бэкапа из cron
set -e set -e
SCRIPT_DIR="/var/www/platform/prod" SCRIPT_DIR="/var/www/platform/prod"
BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh" BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh"
CRON_USER="root" CRON_USER="root"
echo "==========================================" echo "=========================================="
echo "Удаление автоматического бэкапа из cron" echo "Удаление автоматического бэкапа из cron"
echo "==========================================" echo "=========================================="
echo "" echo ""
# Проверить, есть ли запись в crontab # Проверить, есть ли запись в crontab
if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then
echo "Найдена запись в crontab:" echo "Найдена запись в crontab:"
crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT" crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT"
echo "" echo ""
read -p "Удалить? (y/N): " -n 1 -r read -p "Удалить? (y/N): " -n 1 -r
echo "" echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then 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 | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" -
echo "✓ Запись удалена из crontab" echo "✓ Запись удалена из crontab"
else else
echo "Отменено." echo "Отменено."
fi fi
else else
echo "Запись в crontab не найдена." echo "Запись в crontab не найдена."
fi fi

View File

@ -1,26 +1,26 @@
#!/bin/bash #!/bin/bash
# Безопасная остановка PROD окружения # Безопасная остановка PROD окружения
# Этот скрипт останавливает контейнеры БЕЗ удаления volumes (данных БД) # Этот скрипт останавливает контейнеры БЕЗ удаления volumes (данных БД)
set -e set -e
echo "==========================================" echo "=========================================="
echo "Безопасная остановка PROD окружения" echo "Безопасная остановка PROD окружения"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "Это остановит контейнеры, но СОХРАНИТ данные БД и Redis" echo "Это остановит контейнеры, но СОХРАНИТ данные БД и Redis"
echo "" echo ""
cd "$(dirname "$0")" cd "$(dirname "$0")"
# Остановить контейнеры без удаления volumes # Остановить контейнеры без удаления volumes
docker compose down docker compose down
echo "" echo ""
echo "✓ Контейнеры остановлены" echo "✓ Контейнеры остановлены"
echo "✓ Volumes сохранены (данные БД не потеряны)" echo "✓ Volumes сохранены (данные БД не потеряны)"
echo "" echo ""
echo "Для запуска: docker compose up -d" echo "Для запуска: docker compose up -d"
echo "Для полной очистки (с удалением данных): docker compose down --volumes" echo "Для полной очистки (с удалением данных): docker compose down --volumes"
echo " (ВНИМАНИЕ: это удалит все данные БД!)" echo " (ВНИМАНИЕ: это удалит все данные БД!)"

View File

@ -1,79 +1,79 @@
#!/bin/bash #!/bin/bash
# Скрипт для настройки автоматического бэкапа БД через cron # Скрипт для настройки автоматического бэкапа БД через cron
# Запускается дважды в день: в 00:00 и 12:00 # Запускается дважды в день: в 00:00 и 12:00
set -e set -e
SCRIPT_DIR="/var/www/platform/prod" SCRIPT_DIR="/var/www/platform/prod"
BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh" BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh"
CRON_USER="root" CRON_USER="root"
echo "==========================================" echo "=========================================="
echo "Настройка автоматического бэкапа БД" echo "Настройка автоматического бэкапа БД"
echo "==========================================" echo "=========================================="
echo "" echo ""
# Проверить, что скрипт существует # Проверить, что скрипт существует
if [ ! -f "$BACKUP_SCRIPT" ]; then if [ ! -f "$BACKUP_SCRIPT" ]; then
echo "Ошибка: Скрипт $BACKUP_SCRIPT не найден!" echo "Ошибка: Скрипт $BACKUP_SCRIPT не найден!"
exit 1 exit 1
fi fi
# Сделать скрипт исполняемым # Сделать скрипт исполняемым
chmod +x "$BACKUP_SCRIPT" chmod +x "$BACKUP_SCRIPT"
echo "✓ Скрипт сделан исполняемым" echo "✓ Скрипт сделан исполняемым"
# Создать директорию для бэкапов # Создать директорию для бэкапов
mkdir -p "$SCRIPT_DIR/backups" mkdir -p "$SCRIPT_DIR/backups"
echo "✓ Директория для бэкапов создана" echo "✓ Директория для бэкапов создана"
# Найти путь к docker (для cron) # Найти путь к docker (для cron)
DOCKER_PATH=$(which docker 2>/dev/null || echo "/usr/bin/docker") DOCKER_PATH=$(which docker 2>/dev/null || echo "/usr/bin/docker")
# Проверить, есть ли уже запись в crontab # Проверить, есть ли уже запись в crontab
# Используем PATH с docker и bash для надежности # Используем 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" 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 if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then
echo "⚠️ Запись в crontab уже существует" echo "⚠️ Запись в crontab уже существует"
echo "" echo ""
echo "Текущий crontab:" echo "Текущий crontab:"
crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT" crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT"
echo "" echo ""
read -p "Заменить существующую запись? (y/N): " -n 1 -r read -p "Заменить существующую запись? (y/N): " -n 1 -r
echo "" echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then 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 | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" -
# Добавить новую # Добавить новую
(crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" - (crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" -
echo "✓ Запись в crontab обновлена" echo "✓ Запись в crontab обновлена"
else else
echo "Отменено. Существующая запись сохранена." echo "Отменено. Существующая запись сохранена."
exit 0 exit 0
fi fi
else else
# Добавить новую запись # Добавить новую запись
(crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" - (crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" -
echo "✓ Запись в crontab добавлена" echo "✓ Запись в crontab добавлена"
fi fi
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Настройка завершена!" echo "Настройка завершена!"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "Расписание бэкапов:" echo "Расписание бэкапов:"
echo " - Каждый день в 00:00 (полночь)" echo " - Каждый день в 00:00 (полночь)"
echo " - Каждый день в 12:00 (полдень)" echo " - Каждый день в 12:00 (полдень)"
echo "" echo ""
echo "Проверить crontab:" echo "Проверить crontab:"
echo " crontab -u $CRON_USER -l" echo " crontab -u $CRON_USER -l"
echo "" echo ""
echo "Просмотр логов бэкапов:" echo "Просмотр логов бэкапов:"
echo " tail -f $SCRIPT_DIR/backups/backup.log" echo " tail -f $SCRIPT_DIR/backups/backup.log"
echo " tail -f $SCRIPT_DIR/backups/cron.log" echo " tail -f $SCRIPT_DIR/backups/cron.log"
echo "" echo ""
echo "Удалить автоматический бэкап:" echo "Удалить автоматический бэкап:"
echo " crontab -u $CRON_USER -l | grep -v '$BACKUP_SCRIPT' | crontab -u $CRON_USER -" echo " crontab -u $CRON_USER -l | grep -v '$BACKUP_SCRIPT' | crontab -u $CRON_USER -"