tur
Deploy to Production / deploy-production (push) Successful in 29s
Details
Deploy to Production / deploy-production (push) Successful in 29s
Details
This commit is contained in:
parent
a167683bd9
commit
835bd76479
|
|
@ -0,0 +1,62 @@
|
|||
# Конфигурация Docker на сервере
|
||||
|
||||
## Рекомендации для серверов с ограниченной RAM (8 GB)
|
||||
|
||||
### 1. Ограничение BuildKit cache
|
||||
|
||||
Чтобы BuildKit cache не раздувался до 80+ GB:
|
||||
|
||||
```bash
|
||||
# Создать или отредактировать
|
||||
sudo nano /etc/docker/daemon.json
|
||||
```
|
||||
|
||||
Содержимое:
|
||||
|
||||
```json
|
||||
{
|
||||
"builder": {
|
||||
"gc": {
|
||||
"defaultKeepStorage": "10GB",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "10m",
|
||||
"max-file": "3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Затем перезапуск:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### 2. Очистка build cache
|
||||
|
||||
Если нужно освободить место вручную:
|
||||
|
||||
```bash
|
||||
# Удалить неиспользуемый build cache (осторожно: следующая сборка будет дольше)
|
||||
docker builder prune -af
|
||||
|
||||
# Или с ограничением по возрасту (старше 7 дней)
|
||||
docker builder prune -af --filter "until=168h"
|
||||
```
|
||||
|
||||
### 3. Frontend Dockerfile — лимит памяти Node.js
|
||||
|
||||
В `front_material/Dockerfile` уже задано:
|
||||
|
||||
```dockerfile
|
||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||
```
|
||||
|
||||
Это ограничивает heap Node.js до 2 GB при сборке и снижает риск OOM и тяжёлого swapping на машинах с 8 GB RAM.
|
||||
|
||||
### 4. Swap
|
||||
|
||||
Рекомендуется swap 4–8 GB на серверах с 8 GB RAM — для стабильности при пиковых нагрузках.
|
||||
296
README-PROD.md
296
README-PROD.md
|
|
@ -1,148 +1,148 @@
|
|||
# PROD Окружение - Инструкция по управлению
|
||||
|
||||
## ⚠️ ВАЖНО: Защита данных
|
||||
|
||||
**PROD окружение использует отдельную сеть (`prod_network`) и именованные volumes для изоляции от dev.**
|
||||
|
||||
### Что было исправлено:
|
||||
|
||||
1. ✅ **Отдельная сеть** - `prod_network` вместо общей `dev_network`
|
||||
2. ✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_`
|
||||
3. ✅ **Полные имена контейнеров** - в `DATABASE_URL` и `REDIS_URL` используются полные имена контейнеров
|
||||
4. ✅ **Изоляция от dev** - prod не может случайно подключиться к dev БД
|
||||
|
||||
## 📋 Основные команды
|
||||
|
||||
### Безопасная остановка (СОХРАНЯЕТ данные БД):
|
||||
```bash
|
||||
# Использовать скрипт из /var/www/service
|
||||
/var/www/service/platform/safe-down-prod.sh
|
||||
|
||||
# Или вручную
|
||||
cd /var/www/platform/prod
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### ⚠️ ОСТОРОЖНО: Полная очистка (УДАЛЯЕТ данные БД):
|
||||
```bash
|
||||
# Сначала создайте бэкап!
|
||||
/var/www/service/backup/backup-prod-db.sh
|
||||
|
||||
# Затем можно удалить volumes
|
||||
cd /var/www/platform/prod
|
||||
docker compose down --volumes
|
||||
```
|
||||
|
||||
### Запуск:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Создание бэкапа БД:
|
||||
```bash
|
||||
# Бэкап PROD БД
|
||||
/var/www/service/backup/backup-all-db.sh
|
||||
|
||||
# Альтернативный скрипт для PROD БД
|
||||
/var/www/service/backup/backup-prod-db.sh
|
||||
|
||||
# Автоматический бэкап PROD БД (для cron)
|
||||
/var/www/service/backup/backup-db-auto.sh
|
||||
```
|
||||
|
||||
**Примечание:** DEV БД не бэкапится, так как это окружение разработки.
|
||||
|
||||
### Настройка автоматического бэкапа PROD БД (2 раза в день: 00:00 и 12:00):
|
||||
```bash
|
||||
# Установить автоматический бэкап PROD БД
|
||||
/var/www/service/backup/setup-cron-backup.sh
|
||||
|
||||
# Удалить автоматический бэкап
|
||||
/var/www/service/backup/remove-cron-backup.sh
|
||||
|
||||
# Проверить расписание
|
||||
crontab -l | grep backup-db-auto
|
||||
|
||||
# Просмотр логов
|
||||
tail -f /var/www/platform/prod/backups/backup.log
|
||||
```
|
||||
|
||||
**Примечание:** Автоматически бэкапится только PROD БД. DEV БД не бэкапится.
|
||||
|
||||
### Полная пересборка PROD (с бэкапом):
|
||||
```bash
|
||||
/var/www/service/platform/rebuild-prod.sh
|
||||
```
|
||||
Этот скрипт:
|
||||
1. Создаёт бэкап БД
|
||||
2. Останавливает контейнеры
|
||||
3. Пересобирает образы без кэша
|
||||
4. Запускает контейнеры
|
||||
5. Применяет миграции
|
||||
|
||||
### Применение миграций:
|
||||
```bash
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
```
|
||||
|
||||
### Создание суперпользователя:
|
||||
```bash
|
||||
docker exec -it platform_prod_web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## 🔧 Структура volumes
|
||||
|
||||
- `platform_prod_postgres_data` - данные PostgreSQL БД
|
||||
- `platform_prod_redis_data` - данные Redis
|
||||
- `platform_prod_front_material_node_modules` - node_modules для frontend
|
||||
- `platform_prod_front_material_next` - кэш Next.js
|
||||
|
||||
## 🌐 Сеть
|
||||
|
||||
- **Prod сеть**: `platform_prod_network` (изолирована от dev)
|
||||
- **Dev сеть**: `dev_network` (отдельная)
|
||||
|
||||
## 🔗 Подключения
|
||||
|
||||
Все сервисы используют полные имена контейнеров:
|
||||
- БД: `platform_prod_db` (не `db`)
|
||||
- Redis: `platform_prod_redis` (не `redis`)
|
||||
|
||||
Это гарантирует, что даже при запуске dev и prod одновременно, они не будут конфликтовать.
|
||||
|
||||
## 📝 Что делать если данные потеряны
|
||||
|
||||
1. Проверьте бэкапы: `ls -la ./backups/`
|
||||
2. Если бэкапа нет, но данные есть в dev БД, можно скопировать:
|
||||
```bash
|
||||
# Создать бэкап из dev
|
||||
docker exec platform_dev_db pg_dump -U platform_dev_user -d platform_dev_db > /tmp/dev_backup.sql
|
||||
|
||||
# Применить миграции в prod
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
|
||||
# Восстановить данные (осторожно!)
|
||||
docker exec -i platform_prod_db psql -U platform_prod_user -d platform_prod_db < /tmp/dev_backup.sql
|
||||
```
|
||||
3. Если данных нет нигде - создайте пользователей заново через `createsuperuser`
|
||||
|
||||
## 🚨 Частые ошибки
|
||||
|
||||
### ❌ НЕ делайте:
|
||||
- `docker compose down --volumes` без бэкапа
|
||||
- Использование коротких имен (`db`, `redis`) в переменных окружения
|
||||
- Общая сеть для dev и prod
|
||||
|
||||
### ✅ Делайте:
|
||||
- Всегда используйте `docker compose down` (без `--volumes`)
|
||||
- Регулярно создавайте бэкапы PROD БД: `/var/www/service/backup/backup-all-db.sh`
|
||||
- Используйте полные имена контейнеров в конфигурации
|
||||
|
||||
## 📁 Расположение скриптов
|
||||
|
||||
Все служебные скрипты перенесены в `/var/www/service/`:
|
||||
|
||||
- **Бэкапы**: `/var/www/service/backup/`
|
||||
- **Управление платформой**: `/var/www/service/platform/`
|
||||
|
||||
Подробнее: `/var/www/service/README.md`
|
||||
# PROD Окружение - Инструкция по управлению
|
||||
|
||||
## ⚠️ ВАЖНО: Защита данных
|
||||
|
||||
**PROD окружение использует отдельную сеть (`prod_network`) и именованные volumes для изоляции от dev.**
|
||||
|
||||
### Что было исправлено:
|
||||
|
||||
1. ✅ **Отдельная сеть** - `prod_network` вместо общей `dev_network`
|
||||
2. ✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_`
|
||||
3. ✅ **Полные имена контейнеров** - в `DATABASE_URL` и `REDIS_URL` используются полные имена контейнеров
|
||||
4. ✅ **Изоляция от dev** - prod не может случайно подключиться к dev БД
|
||||
|
||||
## 📋 Основные команды
|
||||
|
||||
### Безопасная остановка (СОХРАНЯЕТ данные БД):
|
||||
```bash
|
||||
# Использовать скрипт из /var/www/service
|
||||
/var/www/service/platform/safe-down-prod.sh
|
||||
|
||||
# Или вручную
|
||||
cd /var/www/platform/prod
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### ⚠️ ОСТОРОЖНО: Полная очистка (УДАЛЯЕТ данные БД):
|
||||
```bash
|
||||
# Сначала создайте бэкап!
|
||||
/var/www/service/backup/backup-prod-db.sh
|
||||
|
||||
# Затем можно удалить volumes
|
||||
cd /var/www/platform/prod
|
||||
docker compose down --volumes
|
||||
```
|
||||
|
||||
### Запуск:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Создание бэкапа БД:
|
||||
```bash
|
||||
# Бэкап PROD БД
|
||||
/var/www/service/backup/backup-all-db.sh
|
||||
|
||||
# Альтернативный скрипт для PROD БД
|
||||
/var/www/service/backup/backup-prod-db.sh
|
||||
|
||||
# Автоматический бэкап PROD БД (для cron)
|
||||
/var/www/service/backup/backup-db-auto.sh
|
||||
```
|
||||
|
||||
**Примечание:** DEV БД не бэкапится, так как это окружение разработки.
|
||||
|
||||
### Настройка автоматического бэкапа PROD БД (2 раза в день: 00:00 и 12:00):
|
||||
```bash
|
||||
# Установить автоматический бэкап PROD БД
|
||||
/var/www/service/backup/setup-cron-backup.sh
|
||||
|
||||
# Удалить автоматический бэкап
|
||||
/var/www/service/backup/remove-cron-backup.sh
|
||||
|
||||
# Проверить расписание
|
||||
crontab -l | grep backup-db-auto
|
||||
|
||||
# Просмотр логов
|
||||
tail -f /var/www/platform/prod/backups/backup.log
|
||||
```
|
||||
|
||||
**Примечание:** Автоматически бэкапится только PROD БД. DEV БД не бэкапится.
|
||||
|
||||
### Полная пересборка PROD (с бэкапом):
|
||||
```bash
|
||||
/var/www/service/platform/rebuild-prod.sh
|
||||
```
|
||||
Этот скрипт:
|
||||
1. Создаёт бэкап БД
|
||||
2. Останавливает контейнеры
|
||||
3. Пересобирает образы без кэша
|
||||
4. Запускает контейнеры
|
||||
5. Применяет миграции
|
||||
|
||||
### Применение миграций:
|
||||
```bash
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
```
|
||||
|
||||
### Создание суперпользователя:
|
||||
```bash
|
||||
docker exec -it platform_prod_web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## 🔧 Структура volumes
|
||||
|
||||
- `platform_prod_postgres_data` - данные PostgreSQL БД
|
||||
- `platform_prod_redis_data` - данные Redis
|
||||
- `platform_prod_front_material_node_modules` - node_modules для frontend
|
||||
- `platform_prod_front_material_next` - кэш Next.js
|
||||
|
||||
## 🌐 Сеть
|
||||
|
||||
- **Prod сеть**: `platform_prod_network` (изолирована от dev)
|
||||
- **Dev сеть**: `dev_network` (отдельная)
|
||||
|
||||
## 🔗 Подключения
|
||||
|
||||
Все сервисы используют полные имена контейнеров:
|
||||
- БД: `platform_prod_db` (не `db`)
|
||||
- Redis: `platform_prod_redis` (не `redis`)
|
||||
|
||||
Это гарантирует, что даже при запуске dev и prod одновременно, они не будут конфликтовать.
|
||||
|
||||
## 📝 Что делать если данные потеряны
|
||||
|
||||
1. Проверьте бэкапы: `ls -la ./backups/`
|
||||
2. Если бэкапа нет, но данные есть в dev БД, можно скопировать:
|
||||
```bash
|
||||
# Создать бэкап из dev
|
||||
docker exec platform_dev_db pg_dump -U platform_dev_user -d platform_dev_db > /tmp/dev_backup.sql
|
||||
|
||||
# Применить миграции в prod
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
|
||||
# Восстановить данные (осторожно!)
|
||||
docker exec -i platform_prod_db psql -U platform_prod_user -d platform_prod_db < /tmp/dev_backup.sql
|
||||
```
|
||||
3. Если данных нет нигде - создайте пользователей заново через `createsuperuser`
|
||||
|
||||
## 🚨 Частые ошибки
|
||||
|
||||
### ❌ НЕ делайте:
|
||||
- `docker compose down --volumes` без бэкапа
|
||||
- Использование коротких имен (`db`, `redis`) в переменных окружения
|
||||
- Общая сеть для dev и prod
|
||||
|
||||
### ✅ Делайте:
|
||||
- Всегда используйте `docker compose down` (без `--volumes`)
|
||||
- Регулярно создавайте бэкапы PROD БД: `/var/www/service/backup/backup-all-db.sh`
|
||||
- Используйте полные имена контейнеров в конфигурации
|
||||
|
||||
## 📁 Расположение скриптов
|
||||
|
||||
Все служебные скрипты перенесены в `/var/www/service/`:
|
||||
|
||||
- **Бэкапы**: `/var/www/service/backup/`
|
||||
- **Управление платформой**: `/var/www/service/platform/`
|
||||
|
||||
Подробнее: `/var/www/service/README.md`
|
||||
|
|
|
|||
|
|
@ -1,98 +1,98 @@
|
|||
# Инструкция по пересборке PROD и созданию бэкапов
|
||||
|
||||
## 🎯 Что нужно сделать:
|
||||
|
||||
### 1. Создать бэкап PROD БД
|
||||
|
||||
```bash
|
||||
# Сделать скрипты исполняемыми (первый раз)
|
||||
chmod +x /var/www/service/backup/*.sh
|
||||
chmod +x /var/www/service/platform/*.sh
|
||||
|
||||
# Создать бэкап PROD БД
|
||||
/var/www/service/backup/backup-all-db.sh
|
||||
```
|
||||
|
||||
Это создаст бэкап:
|
||||
- `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
|
||||
|
||||
**Примечание:** DEV БД не бэкапится, так как это окружение разработки.
|
||||
|
||||
### 2. Пересобрать PROD окружение
|
||||
|
||||
```bash
|
||||
# Автоматическая пересборка (с бэкапом)
|
||||
/var/www/service/platform/rebuild-prod.sh
|
||||
```
|
||||
|
||||
Или вручную:
|
||||
|
||||
```bash
|
||||
cd /var/www/platform/prod
|
||||
|
||||
# Остановить контейнеры
|
||||
docker compose down
|
||||
|
||||
# Пересобрать без кэша
|
||||
docker compose build --no-cache --pull
|
||||
|
||||
# Запустить
|
||||
docker compose up -d
|
||||
|
||||
# Подождать запуска БД
|
||||
sleep 10
|
||||
|
||||
# Применить миграции
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
|
||||
# Проверить статус
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 3. Проверить, что всё работает
|
||||
|
||||
```bash
|
||||
# Проверить логи
|
||||
docker compose logs -f
|
||||
|
||||
# Проверить подключение к БД
|
||||
docker exec platform_prod_web python manage.py shell -c "from django.db import connection; print('DB:', connection.settings_dict['NAME'])"
|
||||
|
||||
# Проверить количество пользователей (если таблица существует)
|
||||
docker exec platform_prod_db psql -U platform_prod_user -d platform_prod_db -c "SELECT COUNT(*) FROM users_user;" 2>/dev/null || echo "Таблица не создана, нужно применить миграции"
|
||||
```
|
||||
|
||||
### 4. Если пользователей нет - создать суперпользователя
|
||||
|
||||
```bash
|
||||
docker exec -it platform_prod_web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## 📋 Что было исправлено:
|
||||
|
||||
✅ **Отдельная сеть для prod** - `prod_network` (изолирована от dev)
|
||||
✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_`
|
||||
✅ **Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis`
|
||||
✅ **Защита от случайного удаления данных** - volumes не удаляются при `docker compose down`
|
||||
|
||||
## ⚠️ Важно:
|
||||
|
||||
- **НЕ используйте** `docker compose down --volumes` без бэкапа!
|
||||
- Всегда создавайте бэкапы перед пересборкой
|
||||
- Используйте `./safe-down.sh` для безопасной остановки
|
||||
|
||||
## 🔄 Восстановление из бэкапа (если нужно):
|
||||
|
||||
```bash
|
||||
# Восстановить PROD БД
|
||||
gunzip < /var/www/platform/prod/backups/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres
|
||||
```
|
||||
|
||||
## 📁 Расположение скриптов
|
||||
|
||||
Все служебные скрипты находятся в `/var/www/service/`:
|
||||
|
||||
- **Бэкапы**: `/var/www/service/backup/`
|
||||
- **Управление платформой**: `/var/www/service/platform/`
|
||||
|
||||
Подробнее: `/var/www/service/README.md`
|
||||
# Инструкция по пересборке PROD и созданию бэкапов
|
||||
|
||||
## 🎯 Что нужно сделать:
|
||||
|
||||
### 1. Создать бэкап PROD БД
|
||||
|
||||
```bash
|
||||
# Сделать скрипты исполняемыми (первый раз)
|
||||
chmod +x /var/www/service/backup/*.sh
|
||||
chmod +x /var/www/service/platform/*.sh
|
||||
|
||||
# Создать бэкап PROD БД
|
||||
/var/www/service/backup/backup-all-db.sh
|
||||
```
|
||||
|
||||
Это создаст бэкап:
|
||||
- `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
|
||||
|
||||
**Примечание:** DEV БД не бэкапится, так как это окружение разработки.
|
||||
|
||||
### 2. Пересобрать PROD окружение
|
||||
|
||||
```bash
|
||||
# Автоматическая пересборка (с бэкапом)
|
||||
/var/www/service/platform/rebuild-prod.sh
|
||||
```
|
||||
|
||||
Или вручную:
|
||||
|
||||
```bash
|
||||
cd /var/www/platform/prod
|
||||
|
||||
# Остановить контейнеры
|
||||
docker compose down
|
||||
|
||||
# Пересобрать без кэша
|
||||
docker compose build --no-cache --pull
|
||||
|
||||
# Запустить
|
||||
docker compose up -d
|
||||
|
||||
# Подождать запуска БД
|
||||
sleep 10
|
||||
|
||||
# Применить миграции
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
|
||||
# Проверить статус
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 3. Проверить, что всё работает
|
||||
|
||||
```bash
|
||||
# Проверить логи
|
||||
docker compose logs -f
|
||||
|
||||
# Проверить подключение к БД
|
||||
docker exec platform_prod_web python manage.py shell -c "from django.db import connection; print('DB:', connection.settings_dict['NAME'])"
|
||||
|
||||
# Проверить количество пользователей (если таблица существует)
|
||||
docker exec platform_prod_db psql -U platform_prod_user -d platform_prod_db -c "SELECT COUNT(*) FROM users_user;" 2>/dev/null || echo "Таблица не создана, нужно применить миграции"
|
||||
```
|
||||
|
||||
### 4. Если пользователей нет - создать суперпользователя
|
||||
|
||||
```bash
|
||||
docker exec -it platform_prod_web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## 📋 Что было исправлено:
|
||||
|
||||
✅ **Отдельная сеть для prod** - `prod_network` (изолирована от dev)
|
||||
✅ **Именованные volumes** - все volumes имеют префикс `platform_prod_`
|
||||
✅ **Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis`
|
||||
✅ **Защита от случайного удаления данных** - volumes не удаляются при `docker compose down`
|
||||
|
||||
## ⚠️ Важно:
|
||||
|
||||
- **НЕ используйте** `docker compose down --volumes` без бэкапа!
|
||||
- Всегда создавайте бэкапы перед пересборкой
|
||||
- Используйте `./safe-down.sh` для безопасной остановки
|
||||
|
||||
## 🔄 Восстановление из бэкапа (если нужно):
|
||||
|
||||
```bash
|
||||
# Восстановить PROD БД
|
||||
gunzip < /var/www/platform/prod/backups/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres
|
||||
```
|
||||
|
||||
## 📁 Расположение скриптов
|
||||
|
||||
Все служебные скрипты находятся в `/var/www/service/`:
|
||||
|
||||
- **Бэкапы**: `/var/www/service/backup/`
|
||||
- **Управление платформой**: `/var/www/service/platform/`
|
||||
|
||||
Подробнее: `/var/www/service/README.md`
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ class UserAdmin(BaseUserAdmin):
|
|||
'telegram_notifications'
|
||||
)
|
||||
}),
|
||||
(_('Онбординг'), {
|
||||
'fields': ('onboarding_tours_seen',),
|
||||
'description': 'Прогресс подсказок по платформе (JSON). Чтобы сбросить — очистите поле или укажите {}.'
|
||||
}),
|
||||
(_('Блокировка'), {
|
||||
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
|
||||
'classes': ('collapse',)
|
||||
|
|
@ -142,6 +146,10 @@ class MentorAdmin(BaseUserAdmin):
|
|||
'notifications_enabled', 'email_notifications', 'telegram_notifications',
|
||||
'ai_trust_draft', 'ai_trust_publish')
|
||||
}),
|
||||
(_('Онбординг'), {
|
||||
'fields': ('onboarding_tours_seen',),
|
||||
'description': 'Прогресс подсказок (JSON). Чтобы сбросить — очистите или введите {}.'
|
||||
}),
|
||||
(_('Важные даты'), {
|
||||
'fields': ('last_login', 'last_activity', 'date_joined', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
|
|
|
|||
|
|
@ -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="Просмотренные туры онбординга",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -241,6 +241,15 @@ class User(AbstractUser):
|
|||
verbose_name='Последняя активность'
|
||||
)
|
||||
|
||||
# Прогресс онбординга (какие страницы уже показывали подсказки)
|
||||
# Формат: {"dashboard": true, "schedule": true, "students": false, ...}
|
||||
onboarding_tours_seen = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name='Просмотренные туры онбординга',
|
||||
help_text='Страницы, для которых уже показан приветственный тур',
|
||||
)
|
||||
|
||||
# Настройки уведомлений
|
||||
notifications_enabled = models.BooleanField(
|
||||
default=True,
|
||||
|
|
|
|||
|
|
@ -568,6 +568,7 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
'ai_trust_draft': getattr(user, 'ai_trust_draft', False),
|
||||
'ai_trust_publish': getattr(user, 'ai_trust_publish', False),
|
||||
}
|
||||
settings['onboarding_tours_seen'] = getattr(user, 'onboarding_tours_seen', {}) or {}
|
||||
|
||||
return Response(settings)
|
||||
|
||||
|
|
@ -842,6 +843,14 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
if 'ai_trust_publish' in mentor_ai:
|
||||
user.ai_trust_publish = bool(mentor_ai['ai_trust_publish'])
|
||||
|
||||
# Онбординг: отметка просмотренных туров
|
||||
if 'onboarding_tours_seen' in request.data:
|
||||
tours = request.data['onboarding_tours_seen']
|
||||
if isinstance(tours, dict):
|
||||
current = getattr(user, 'onboarding_tours_seen', None) or {}
|
||||
merged = {**current, **{k: bool(v) for k, v in tours.items()}}
|
||||
user.onboarding_tours_seen = merged
|
||||
|
||||
user.save()
|
||||
|
||||
return Response({'message': 'Настройки успешно обновлены'})
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
'country', 'city',
|
||||
'email_verified', 'is_active',
|
||||
'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором
|
||||
'onboarding_tours_seen',
|
||||
'invitation_link_token', 'invitation_link',
|
||||
'login_token', 'login_link',
|
||||
'notifications_enabled', 'email_notifications', 'telegram_notifications',
|
||||
|
|
|
|||
130
backup-all-db.sh
130
backup-all-db.sh
|
|
@ -1,65 +1,65 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Скрипт для создания бэкапов БД PROD и DEV
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="./backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
echo "=========================================="
|
||||
echo "Создание бэкапов БД (PROD и DEV)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Создать директорию для бэкапов
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Функция для создания бэкапа
|
||||
backup_db() {
|
||||
local CONTAINER_NAME=$1
|
||||
local DB_USER=$2
|
||||
local DB_NAME=$3
|
||||
local BACKUP_NAME=$4
|
||||
|
||||
echo "Создание бэкапа: $BACKUP_NAME"
|
||||
|
||||
# Проверить, что контейнер запущен
|
||||
if ! docker ps | grep -q "$CONTAINER_NAME"; then
|
||||
echo "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz"
|
||||
|
||||
# Создать бэкап
|
||||
if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Ошибка создания бэкапа для $BACKUP_NAME"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Бэкап PROD БД
|
||||
echo "--- PROD БД ---"
|
||||
backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"
|
||||
|
||||
echo ""
|
||||
|
||||
# Бэкап DEV БД
|
||||
echo "--- DEV БД ---"
|
||||
backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Бэкапы сохранены в: $BACKUP_DIR"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Для восстановления PROD БД:"
|
||||
echo " gunzip < $BACKUP_DIR/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres"
|
||||
echo ""
|
||||
echo "Для восстановления DEV БД:"
|
||||
echo " gunzip < $BACKUP_DIR/platform_dev_db_*.sql.gz | docker exec -i platform_dev_db psql -U platform_dev_user -d postgres"
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для создания бэкапов БД PROD и DEV
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="./backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
echo "=========================================="
|
||||
echo "Создание бэкапов БД (PROD и DEV)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Создать директорию для бэкапов
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Функция для создания бэкапа
|
||||
backup_db() {
|
||||
local CONTAINER_NAME=$1
|
||||
local DB_USER=$2
|
||||
local DB_NAME=$3
|
||||
local BACKUP_NAME=$4
|
||||
|
||||
echo "Создание бэкапа: $BACKUP_NAME"
|
||||
|
||||
# Проверить, что контейнер запущен
|
||||
if ! docker ps | grep -q "$CONTAINER_NAME"; then
|
||||
echo "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz"
|
||||
|
||||
# Создать бэкап
|
||||
if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Ошибка создания бэкапа для $BACKUP_NAME"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Бэкап PROD БД
|
||||
echo "--- PROD БД ---"
|
||||
backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"
|
||||
|
||||
echo ""
|
||||
|
||||
# Бэкап DEV БД
|
||||
echo "--- DEV БД ---"
|
||||
backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Бэкапы сохранены в: $BACKUP_DIR"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Для восстановления PROD БД:"
|
||||
echo " gunzip < $BACKUP_DIR/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres"
|
||||
echo ""
|
||||
echo "Для восстановления DEV БД:"
|
||||
echo " gunzip < $BACKUP_DIR/platform_dev_db_*.sql.gz | docker exec -i platform_dev_db psql -U platform_dev_user -d postgres"
|
||||
|
|
|
|||
|
|
@ -1,99 +1,99 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Автоматический скрипт для создания бэкапов БД PROD и DEV
|
||||
# Запускается через cron дважды в день (00:00 и 12:00)
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="/var/www/platform/prod/backups"
|
||||
LOG_FILE="/var/www/platform/prod/backups/backup.log"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
DATE=$(date +%Y-%m-%d\ %H:%M:%S)
|
||||
|
||||
# Создать директорию для бэкапов и логов
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Функция для логирования
|
||||
log() {
|
||||
echo "[$DATE] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log "=========================================="
|
||||
log "Начало автоматического бэкапа БД"
|
||||
log "=========================================="
|
||||
|
||||
# Функция для создания бэкапа
|
||||
backup_db() {
|
||||
local CONTAINER_NAME=$1
|
||||
local DB_USER=$2
|
||||
local DB_NAME=$3
|
||||
local BACKUP_NAME=$4
|
||||
|
||||
log "Создание бэкапа: $BACKUP_NAME"
|
||||
|
||||
# Проверить, что контейнер запущен
|
||||
if ! docker ps | grep -q "$CONTAINER_NAME"; then
|
||||
log "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz"
|
||||
|
||||
# Создать бэкап
|
||||
if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
log " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)"
|
||||
|
||||
# Проверить размер файла (должен быть больше 0)
|
||||
if [ ! -s "$BACKUP_FILE" ]; then
|
||||
log " ✗ ОШИБКА: Бэкап пустой!"
|
||||
rm -f "$BACKUP_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
log " ✗ Ошибка создания бэкапа для $BACKUP_NAME"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Бэкап PROD БД
|
||||
PROD_SUCCESS=false
|
||||
if backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"; then
|
||||
PROD_SUCCESS=true
|
||||
fi
|
||||
|
||||
# Бэкап DEV БД
|
||||
DEV_SUCCESS=false
|
||||
if backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"; then
|
||||
DEV_SUCCESS=true
|
||||
fi
|
||||
|
||||
# Очистка старых бэкапов (оставляем последние 30 дней)
|
||||
log "Очистка старых бэкапов (старше 30 дней)..."
|
||||
find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +30 -delete 2>/dev/null || true
|
||||
DELETED_COUNT=$(find "$BACKUP_DIR" -name "*.sql.gz" -type f 2>/dev/null | wc -l)
|
||||
log "Осталось бэкапов: $DELETED_COUNT"
|
||||
|
||||
# Итоги
|
||||
log "=========================================="
|
||||
if [ "$PROD_SUCCESS" = true ] && [ "$DEV_SUCCESS" = true ]; then
|
||||
log "✓ Бэкапы созданы успешно (PROD и DEV)"
|
||||
elif [ "$PROD_SUCCESS" = true ]; then
|
||||
log "⚠️ Бэкап PROD создан, DEV пропущен"
|
||||
elif [ "$DEV_SUCCESS" = true ]; then
|
||||
log "⚠️ Бэкап DEV создан, PROD пропущен"
|
||||
else
|
||||
log "✗ Ошибка: бэкапы не созданы!"
|
||||
exit 1
|
||||
fi
|
||||
log "=========================================="
|
||||
|
||||
# Проверка места на диске
|
||||
DISK_USAGE=$(df -h "$BACKUP_DIR" | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||
if [ "$DISK_USAGE" -gt 80 ]; then
|
||||
log "⚠️ ВНИМАНИЕ: Использовано дискового пространства: ${DISK_USAGE}%"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
#!/bin/bash
|
||||
|
||||
# Автоматический скрипт для создания бэкапов БД PROD и DEV
|
||||
# Запускается через cron дважды в день (00:00 и 12:00)
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="/var/www/platform/prod/backups"
|
||||
LOG_FILE="/var/www/platform/prod/backups/backup.log"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
DATE=$(date +%Y-%m-%d\ %H:%M:%S)
|
||||
|
||||
# Создать директорию для бэкапов и логов
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Функция для логирования
|
||||
log() {
|
||||
echo "[$DATE] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log "=========================================="
|
||||
log "Начало автоматического бэкапа БД"
|
||||
log "=========================================="
|
||||
|
||||
# Функция для создания бэкапа
|
||||
backup_db() {
|
||||
local CONTAINER_NAME=$1
|
||||
local DB_USER=$2
|
||||
local DB_NAME=$3
|
||||
local BACKUP_NAME=$4
|
||||
|
||||
log "Создание бэкапа: $BACKUP_NAME"
|
||||
|
||||
# Проверить, что контейнер запущен
|
||||
if ! docker ps | grep -q "$CONTAINER_NAME"; then
|
||||
log "⚠️ Контейнер $CONTAINER_NAME не запущен, пропускаем..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}_${TIMESTAMP}.sql.gz"
|
||||
|
||||
# Создать бэкап
|
||||
if docker exec "$CONTAINER_NAME" pg_dumpall -U "$DB_USER" -c 2>/dev/null | gzip > "$BACKUP_FILE"; then
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
log " ✓ Бэкап создан: $BACKUP_FILE ($BACKUP_SIZE)"
|
||||
|
||||
# Проверить размер файла (должен быть больше 0)
|
||||
if [ ! -s "$BACKUP_FILE" ]; then
|
||||
log " ✗ ОШИБКА: Бэкап пустой!"
|
||||
rm -f "$BACKUP_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
log " ✗ Ошибка создания бэкапа для $BACKUP_NAME"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Бэкап PROD БД
|
||||
PROD_SUCCESS=false
|
||||
if backup_db "platform_prod_db" "platform_prod_user" "platform_prod_db" "platform_prod_db"; then
|
||||
PROD_SUCCESS=true
|
||||
fi
|
||||
|
||||
# Бэкап DEV БД
|
||||
DEV_SUCCESS=false
|
||||
if backup_db "platform_dev_db" "platform_dev_user" "platform_dev_db" "platform_dev_db"; then
|
||||
DEV_SUCCESS=true
|
||||
fi
|
||||
|
||||
# Очистка старых бэкапов (оставляем последние 30 дней)
|
||||
log "Очистка старых бэкапов (старше 30 дней)..."
|
||||
find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +30 -delete 2>/dev/null || true
|
||||
DELETED_COUNT=$(find "$BACKUP_DIR" -name "*.sql.gz" -type f 2>/dev/null | wc -l)
|
||||
log "Осталось бэкапов: $DELETED_COUNT"
|
||||
|
||||
# Итоги
|
||||
log "=========================================="
|
||||
if [ "$PROD_SUCCESS" = true ] && [ "$DEV_SUCCESS" = true ]; then
|
||||
log "✓ Бэкапы созданы успешно (PROD и DEV)"
|
||||
elif [ "$PROD_SUCCESS" = true ]; then
|
||||
log "⚠️ Бэкап PROD создан, DEV пропущен"
|
||||
elif [ "$DEV_SUCCESS" = true ]; then
|
||||
log "⚠️ Бэкап DEV создан, PROD пропущен"
|
||||
else
|
||||
log "✗ Ошибка: бэкапы не созданы!"
|
||||
exit 1
|
||||
fi
|
||||
log "=========================================="
|
||||
|
||||
# Проверка места на диске
|
||||
DISK_USAGE=$(df -h "$BACKUP_DIR" | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||
if [ "$DISK_USAGE" -gt 80 ]; then
|
||||
log "⚠️ ВНИМАНИЕ: Использовано дискового пространства: ${DISK_USAGE}%"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
|
|
|||
84
backup-db.sh
84
backup-db.sh
|
|
@ -1,42 +1,42 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Скрипт для создания бэкапа БД PROD
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="./backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/platform_prod_db_backup_$TIMESTAMP.sql.gz"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Создание бэкапа PROD БД"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Создать директорию для бэкапов
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Проверить, что контейнер БД запущен
|
||||
if ! docker ps | grep -q platform_prod_db; then
|
||||
echo "Ошибка: Контейнер platform_prod_db не запущен"
|
||||
echo "Запустите БД: docker compose up -d db"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Создание бэкапа..."
|
||||
echo "Файл: $BACKUP_FILE"
|
||||
echo ""
|
||||
|
||||
# Создать бэкап
|
||||
if docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "$BACKUP_FILE"; then
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo "✓ Бэкап создан успешно"
|
||||
echo " Размер: $BACKUP_SIZE"
|
||||
echo " Файл: $BACKUP_FILE"
|
||||
echo ""
|
||||
echo "Для восстановления:"
|
||||
echo " gunzip < $BACKUP_FILE | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres"
|
||||
else
|
||||
echo "✗ Ошибка создания бэкапа!"
|
||||
exit 1
|
||||
fi
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для создания бэкапа БД PROD
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="./backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/platform_prod_db_backup_$TIMESTAMP.sql.gz"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Создание бэкапа PROD БД"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Создать директорию для бэкапов
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Проверить, что контейнер БД запущен
|
||||
if ! docker ps | grep -q platform_prod_db; then
|
||||
echo "Ошибка: Контейнер platform_prod_db не запущен"
|
||||
echo "Запустите БД: docker compose up -d db"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Создание бэкапа..."
|
||||
echo "Файл: $BACKUP_FILE"
|
||||
echo ""
|
||||
|
||||
# Создать бэкап
|
||||
if docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "$BACKUP_FILE"; then
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo "✓ Бэкап создан успешно"
|
||||
echo " Размер: $BACKUP_SIZE"
|
||||
echo " Файл: $BACKUP_FILE"
|
||||
echo ""
|
||||
echo "Для восстановления:"
|
||||
echo " gunzip < $BACKUP_FILE | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres"
|
||||
else
|
||||
echo "✗ Ошибка создания бэкапа!"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
# LiveKit Server — поддержка 2K и высокого битрейта
|
||||
# Ключи можно переопределить через LIVEKIT_KEYS в docker-compose
|
||||
|
||||
port: 7880
|
||||
keys:
|
||||
APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
|
||||
rtc:
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
tcp_port: 7881
|
||||
use_external_ip: false
|
||||
# Буферы для видео (по умолчанию 500) — чуть выше для 2K/высокого битрейта
|
||||
packet_buffer_size_video: 600
|
||||
packet_buffer_size_audio: 200
|
||||
congestion_control:
|
||||
enabled: true
|
||||
allow_pause: true
|
||||
allow_tcp_fallback: true
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
empty_timeout: 300
|
||||
max_participants: 50
|
||||
|
||||
logging:
|
||||
level: info
|
||||
sample: false
|
||||
# LiveKit Server — поддержка 2K и высокого битрейта
|
||||
# Ключи можно переопределить через LIVEKIT_KEYS в docker-compose
|
||||
|
||||
port: 7880
|
||||
keys:
|
||||
APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
|
||||
rtc:
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
tcp_port: 7881
|
||||
use_external_ip: false
|
||||
# Буферы для видео (по умолчанию 500) — чуть выше для 2K/высокого битрейта
|
||||
packet_buffer_size_video: 600
|
||||
packet_buffer_size_audio: 200
|
||||
congestion_control:
|
||||
enabled: true
|
||||
allow_pause: true
|
||||
allow_tcp_fallback: true
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
empty_timeout: 300
|
||||
max_participants: 50
|
||||
|
||||
logging:
|
||||
level: info
|
||||
sample: false
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"builder": {
|
||||
"gc": {
|
||||
"defaultKeepStorage": "10GB",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "10m",
|
||||
"max-file": "3"
|
||||
}
|
||||
}
|
||||
{
|
||||
"builder": {
|
||||
"gc": {
|
||||
"defaultKeepStorage": "10GB",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "10m",
|
||||
"max-file": "3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*.local
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.vercel
|
||||
coverage
|
||||
.nyc_output
|
||||
.vscode
|
||||
.idea
|
||||
docs
|
||||
.cursor
|
||||
agent-transcripts
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*.local
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.vercel
|
||||
coverage
|
||||
.nyc_output
|
||||
.vscode
|
||||
.idea
|
||||
docs
|
||||
.cursor
|
||||
agent-transcripts
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ ENV NEXT_PUBLIC_EXCALIDRAW_URL=$NEXT_PUBLIC_EXCALIDRAW_URL
|
|||
COPY package*.json ./
|
||||
|
||||
# Устанавливаем все зависимости для сборки
|
||||
RUN npm ci
|
||||
# npm install вместо npm ci: package-lock.json может быть не синхронизирован после добавления driver.js
|
||||
RUN npm install
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ front_material/
|
|||
│
|
||||
├── contexts/ # React Context
|
||||
│ ├── AuthContext.tsx # Контекст аутентификации
|
||||
│ ├── OnboardingContext.tsx # Онбординг-туры (Driver.js, привязка к страницам и ролям)
|
||||
│ ├── ThemeContext.tsx # Контекст темы (light/dark)
|
||||
│ └── SelectedChildContext.tsx # Контекст выбранного ребенка (для родителей)
|
||||
│
|
||||
|
|
@ -143,6 +144,7 @@ front_material/
|
|||
│
|
||||
├── lib/ # Утилиты
|
||||
│ ├── material-components.ts # Импорт всех Material компонентов
|
||||
│ ├── onboarding-steps.ts # Шаги онбординга по страницам/ролям (ментор, студент, родитель)
|
||||
│ └── utils.ts # Вспомогательные функции
|
||||
│
|
||||
├── styles/ # CSS стили
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface User {
|
|||
language?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
onboarding_tours_seen?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export interface MentorHomeworkAISettings {
|
|||
ai_trust_publish?: boolean;
|
||||
}
|
||||
|
||||
/** Прогресс онбординга: страница → просмотрено */
|
||||
export type OnboardingToursSeen = Record<string, boolean>;
|
||||
|
||||
export interface ProfileSettings {
|
||||
preferences: {
|
||||
timezone?: string;
|
||||
|
|
@ -30,6 +33,8 @@ export interface ProfileSettings {
|
|||
};
|
||||
/** Только для ментора: доверие AI при проверке ДЗ */
|
||||
mentor_homework_ai?: MentorHomeworkAISettings;
|
||||
/** Просмотренные туры онбординга по страницам */
|
||||
onboarding_tours_seen?: OnboardingToursSeen;
|
||||
}
|
||||
|
||||
export async function getProfileSettings(): Promise<ProfileSettings> {
|
||||
|
|
|
|||
|
|
@ -1,260 +1,260 @@
|
|||
/**
|
||||
* API модуль для расписания занятий
|
||||
*/
|
||||
|
||||
import apiClient from '@/lib/api-client';
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
subject?: string;
|
||||
description?: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
||||
mentor?: {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
};
|
||||
client?: {
|
||||
id: string;
|
||||
user?: {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
client_name?: string;
|
||||
mentor_notes?: string;
|
||||
mentor_grade?: number;
|
||||
school_grade?: number;
|
||||
homework_text?: string;
|
||||
price?: number;
|
||||
meeting_url?: string;
|
||||
duration?: number;
|
||||
group?: number;
|
||||
group_name?: string;
|
||||
livekit_room_name?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
/** Файл урока (для экрана завершения занятия) */
|
||||
export interface LessonFile {
|
||||
id: string | number;
|
||||
lesson: string | number;
|
||||
file?: string;
|
||||
material?: string | number;
|
||||
source?: 'uploaded' | 'material';
|
||||
filename: string;
|
||||
file_size?: number;
|
||||
file_size_display?: string;
|
||||
file_url?: string;
|
||||
description?: string;
|
||||
uploaded_by?: number;
|
||||
uploaded_by_name?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateLessonFileData {
|
||||
lesson: string;
|
||||
file?: File;
|
||||
material?: string;
|
||||
filename?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список занятий
|
||||
* Для родителя передать child_id (user_id ребёнка).
|
||||
* Для ментора передать client_id (Client.id) — занятия конкретного студента.
|
||||
*/
|
||||
export async function getLessons(params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: string;
|
||||
child_id?: string;
|
||||
client_id?: string;
|
||||
}): Promise<{ results: Lesson[]; count?: number }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.start_date) queryParams.append('start_date', params.start_date);
|
||||
if (params?.end_date) queryParams.append('end_date', params.end_date);
|
||||
if (params?.status) queryParams.append('status', params.status);
|
||||
if (params?.child_id) queryParams.append('child_id', params.child_id);
|
||||
if (params?.client_id) queryParams.append('client_id', params.client_id);
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = `/schedule/lessons/${queryString ? `?${queryString}` : ''}`;
|
||||
const response = await apiClient.get<Lesson[] | { results: Lesson[]; count?: number }>(url);
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
return { results: response.data };
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Ответ calendar API */
|
||||
interface CalendarResponse {
|
||||
success: boolean;
|
||||
data: { start_date: string; end_date: string; lessons: Lesson[]; total: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Занятия для календаря (лёгкий endpoint по диапазону дат).
|
||||
* Для родителя передать child_id (user_id ребёнка).
|
||||
*/
|
||||
export async function getLessonsCalendar(params: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status?: string;
|
||||
child_id?: string;
|
||||
}): Promise<{ lessons: Lesson[] }> {
|
||||
const q = new URLSearchParams({ start_date: params.start_date, end_date: params.end_date });
|
||||
if (params.status) q.append('status', params.status);
|
||||
if (params.child_id) q.append('child_id', params.child_id);
|
||||
// cache: false — после создания/редактирования/удаления занятия интерфейс должен обновиться без перезагрузки
|
||||
const res = await apiClient.get<CalendarResponse>(`/schedule/lessons/calendar/?${q}`, { cache: false });
|
||||
const lessons = res.data?.data?.lessons;
|
||||
return { lessons: Array.isArray(lessons) ? lessons : [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить занятие по ID
|
||||
*/
|
||||
export async function getLesson(id: string): Promise<Lesson> {
|
||||
const response = await apiClient.get<Lesson>(`/schedule/lessons/${id}/`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export interface CreateLessonData {
|
||||
client: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
start_time: string;
|
||||
duration: number;
|
||||
price?: number;
|
||||
is_recurring?: boolean;
|
||||
subject_id?: number;
|
||||
mentor_subject_id?: number;
|
||||
subject_name?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLessonData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
start_time?: string;
|
||||
duration?: number;
|
||||
price?: number;
|
||||
/** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */
|
||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать занятие
|
||||
*/
|
||||
export async function createLesson(data: CreateLessonData): Promise<Lesson> {
|
||||
const response = await apiClient.post<Lesson>('/schedule/lessons/', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить занятие
|
||||
*/
|
||||
export async function updateLesson(id: string, data: UpdateLessonData): Promise<Lesson> {
|
||||
const response = await apiClient.patch<Lesson>(`/schedule/lessons/${id}/`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить занятие
|
||||
*/
|
||||
export async function deleteLesson(id: string, deleteAllFuture = false): Promise<void> {
|
||||
await apiClient.delete(`/schedule/lessons/${id}/`, {
|
||||
data: { delete_all_future: deleteAllFuture },
|
||||
});
|
||||
}
|
||||
|
||||
/** Ответ API завершения занятия */
|
||||
export interface CompleteLessonResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: Lesson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить занятие / обновить обратную связь.
|
||||
* lessonFileIds — ID файлов урока, которые нужно привязать к ДЗ (только они попадут в «Файлы задания»).
|
||||
*/
|
||||
export async function completeLesson(
|
||||
id: string,
|
||||
notes?: string,
|
||||
mentorGrade?: number,
|
||||
schoolGrade?: number,
|
||||
homeworkText?: string,
|
||||
hasHomeworkFiles?: boolean,
|
||||
lessonFileIds?: number[]
|
||||
): Promise<CompleteLessonResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
notes: notes ?? '',
|
||||
mentor_grade: mentorGrade,
|
||||
school_grade: schoolGrade,
|
||||
homework_text: homeworkText,
|
||||
has_homework_files: hasHomeworkFiles,
|
||||
};
|
||||
if (lessonFileIds != null) {
|
||||
body.lesson_file_ids = lessonFileIds;
|
||||
}
|
||||
const response = await apiClient.post<CompleteLessonResponse>(`/schedule/lessons/${id}/complete/`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить файлы урока (для экрана завершения занятия).
|
||||
*/
|
||||
export async function getLessonFiles(lessonId: string): Promise<LessonFile[]> {
|
||||
const response = await apiClient.get<LessonFile[] | { results: LessonFile[] }>(
|
||||
`/schedule/lesson-files/?lesson=${lessonId}`
|
||||
);
|
||||
const data = response.data;
|
||||
if (Array.isArray(data)) return data;
|
||||
return (data as { results: LessonFile[] })?.results ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать файл урока (загрузка файла или привязка материала).
|
||||
*/
|
||||
export async function createLessonFile(data: CreateLessonFileData): Promise<LessonFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('lesson', data.lesson);
|
||||
if (data.file) formData.append('file', data.file);
|
||||
if (data.material) formData.append('material', data.material);
|
||||
if (data.filename) formData.append('filename', data.filename);
|
||||
if (data.description) formData.append('description', data.description);
|
||||
const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить файл урока.
|
||||
*/
|
||||
export async function deleteLessonFile(fileId: string): Promise<void> {
|
||||
await apiClient.delete(`/schedule/lesson-files/${fileId}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепить файл к уроку (для ДЗ при завершении занятия).
|
||||
* Возвращает созданный LessonFile (нужен id для передачи в complete как lesson_file_ids).
|
||||
*/
|
||||
export async function uploadLessonFile(lessonId: number | string, file: File): Promise<LessonFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('lesson', String(lessonId));
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
/**
|
||||
* API модуль для расписания занятий
|
||||
*/
|
||||
|
||||
import apiClient from '@/lib/api-client';
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
subject?: string;
|
||||
description?: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
||||
mentor?: {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
};
|
||||
client?: {
|
||||
id: string;
|
||||
user?: {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
client_name?: string;
|
||||
mentor_notes?: string;
|
||||
mentor_grade?: number;
|
||||
school_grade?: number;
|
||||
homework_text?: string;
|
||||
price?: number;
|
||||
meeting_url?: string;
|
||||
duration?: number;
|
||||
group?: number;
|
||||
group_name?: string;
|
||||
livekit_room_name?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
/** Файл урока (для экрана завершения занятия) */
|
||||
export interface LessonFile {
|
||||
id: string | number;
|
||||
lesson: string | number;
|
||||
file?: string;
|
||||
material?: string | number;
|
||||
source?: 'uploaded' | 'material';
|
||||
filename: string;
|
||||
file_size?: number;
|
||||
file_size_display?: string;
|
||||
file_url?: string;
|
||||
description?: string;
|
||||
uploaded_by?: number;
|
||||
uploaded_by_name?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateLessonFileData {
|
||||
lesson: string;
|
||||
file?: File;
|
||||
material?: string;
|
||||
filename?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список занятий
|
||||
* Для родителя передать child_id (user_id ребёнка).
|
||||
* Для ментора передать client_id (Client.id) — занятия конкретного студента.
|
||||
*/
|
||||
export async function getLessons(params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: string;
|
||||
child_id?: string;
|
||||
client_id?: string;
|
||||
}): Promise<{ results: Lesson[]; count?: number }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.start_date) queryParams.append('start_date', params.start_date);
|
||||
if (params?.end_date) queryParams.append('end_date', params.end_date);
|
||||
if (params?.status) queryParams.append('status', params.status);
|
||||
if (params?.child_id) queryParams.append('child_id', params.child_id);
|
||||
if (params?.client_id) queryParams.append('client_id', params.client_id);
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = `/schedule/lessons/${queryString ? `?${queryString}` : ''}`;
|
||||
const response = await apiClient.get<Lesson[] | { results: Lesson[]; count?: number }>(url);
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
return { results: response.data };
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Ответ calendar API */
|
||||
interface CalendarResponse {
|
||||
success: boolean;
|
||||
data: { start_date: string; end_date: string; lessons: Lesson[]; total: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Занятия для календаря (лёгкий endpoint по диапазону дат).
|
||||
* Для родителя передать child_id (user_id ребёнка).
|
||||
*/
|
||||
export async function getLessonsCalendar(params: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status?: string;
|
||||
child_id?: string;
|
||||
}): Promise<{ lessons: Lesson[] }> {
|
||||
const q = new URLSearchParams({ start_date: params.start_date, end_date: params.end_date });
|
||||
if (params.status) q.append('status', params.status);
|
||||
if (params.child_id) q.append('child_id', params.child_id);
|
||||
// cache: false — после создания/редактирования/удаления занятия интерфейс должен обновиться без перезагрузки
|
||||
const res = await apiClient.get<CalendarResponse>(`/schedule/lessons/calendar/?${q}`, { cache: false });
|
||||
const lessons = res.data?.data?.lessons;
|
||||
return { lessons: Array.isArray(lessons) ? lessons : [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить занятие по ID
|
||||
*/
|
||||
export async function getLesson(id: string): Promise<Lesson> {
|
||||
const response = await apiClient.get<Lesson>(`/schedule/lessons/${id}/`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export interface CreateLessonData {
|
||||
client: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
start_time: string;
|
||||
duration: number;
|
||||
price?: number;
|
||||
is_recurring?: boolean;
|
||||
subject_id?: number;
|
||||
mentor_subject_id?: number;
|
||||
subject_name?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLessonData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
start_time?: string;
|
||||
duration?: number;
|
||||
price?: number;
|
||||
/** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */
|
||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать занятие
|
||||
*/
|
||||
export async function createLesson(data: CreateLessonData): Promise<Lesson> {
|
||||
const response = await apiClient.post<Lesson>('/schedule/lessons/', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить занятие
|
||||
*/
|
||||
export async function updateLesson(id: string, data: UpdateLessonData): Promise<Lesson> {
|
||||
const response = await apiClient.patch<Lesson>(`/schedule/lessons/${id}/`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить занятие
|
||||
*/
|
||||
export async function deleteLesson(id: string, deleteAllFuture = false): Promise<void> {
|
||||
await apiClient.delete(`/schedule/lessons/${id}/`, {
|
||||
data: { delete_all_future: deleteAllFuture },
|
||||
});
|
||||
}
|
||||
|
||||
/** Ответ API завершения занятия */
|
||||
export interface CompleteLessonResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: Lesson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить занятие / обновить обратную связь.
|
||||
* lessonFileIds — ID файлов урока, которые нужно привязать к ДЗ (только они попадут в «Файлы задания»).
|
||||
*/
|
||||
export async function completeLesson(
|
||||
id: string,
|
||||
notes?: string,
|
||||
mentorGrade?: number,
|
||||
schoolGrade?: number,
|
||||
homeworkText?: string,
|
||||
hasHomeworkFiles?: boolean,
|
||||
lessonFileIds?: number[]
|
||||
): Promise<CompleteLessonResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
notes: notes ?? '',
|
||||
mentor_grade: mentorGrade,
|
||||
school_grade: schoolGrade,
|
||||
homework_text: homeworkText,
|
||||
has_homework_files: hasHomeworkFiles,
|
||||
};
|
||||
if (lessonFileIds != null) {
|
||||
body.lesson_file_ids = lessonFileIds;
|
||||
}
|
||||
const response = await apiClient.post<CompleteLessonResponse>(`/schedule/lessons/${id}/complete/`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить файлы урока (для экрана завершения занятия).
|
||||
*/
|
||||
export async function getLessonFiles(lessonId: string): Promise<LessonFile[]> {
|
||||
const response = await apiClient.get<LessonFile[] | { results: LessonFile[] }>(
|
||||
`/schedule/lesson-files/?lesson=${lessonId}`
|
||||
);
|
||||
const data = response.data;
|
||||
if (Array.isArray(data)) return data;
|
||||
return (data as { results: LessonFile[] })?.results ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать файл урока (загрузка файла или привязка материала).
|
||||
*/
|
||||
export async function createLessonFile(data: CreateLessonFileData): Promise<LessonFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('lesson', data.lesson);
|
||||
if (data.file) formData.append('file', data.file);
|
||||
if (data.material) formData.append('material', data.material);
|
||||
if (data.filename) formData.append('filename', data.filename);
|
||||
if (data.description) formData.append('description', data.description);
|
||||
const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить файл урока.
|
||||
*/
|
||||
export async function deleteLessonFile(fileId: string): Promise<void> {
|
||||
await apiClient.delete(`/schedule/lesson-files/${fileId}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепить файл к уроку (для ДЗ при завершении занятия).
|
||||
* Возвращает созданный LessonFile (нужен id для передачи в complete как lesson_file_ids).
|
||||
*/
|
||||
export async function uploadLessonFile(lessonId: number | string, file: File): Promise<LessonFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('lesson', String(lessonId));
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post<LessonFile>('/schedule/lesson-files/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,145 +1,145 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { requestPasswordReset } from '@/api/auth';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
const loadMaterialComponents = async () => {
|
||||
await Promise.all([
|
||||
import('@material/web/textfield/filled-text-field.js'),
|
||||
import('@material/web/button/filled-button.js'),
|
||||
import('@material/web/button/text-button.js'),
|
||||
]);
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialComponents()
|
||||
.then(() => setComponentsLoaded(true))
|
||||
.catch((err) => {
|
||||
console.error('Error loading components:', err);
|
||||
setComponentsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
await requestPasswordReset({ email });
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!componentsLoaded) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Восстановление пароля
|
||||
</p>
|
||||
|
||||
{success ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Инструкции по восстановлению пароля отправлены на ваш email.
|
||||
</div>
|
||||
<md-filled-button
|
||||
onClick={() => router.push('/login')}
|
||||
style={{ width: '100%', height: '48px' }}
|
||||
>
|
||||
Вернуться к входу
|
||||
</md-filled-button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '20px' }}>
|
||||
Введите ваш email для восстановления пароля
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<md-filled-text-field
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onInput={(e: any) => setEmail(e.target.value || '')}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<md-filled-button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', height: '48px', marginBottom: '16px' }}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Отправить'}
|
||||
</md-filled-button>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||||
Вернуться к входу
|
||||
</md-text-button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { requestPasswordReset } from '@/api/auth';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
const loadMaterialComponents = async () => {
|
||||
await Promise.all([
|
||||
import('@material/web/textfield/filled-text-field.js'),
|
||||
import('@material/web/button/filled-button.js'),
|
||||
import('@material/web/button/text-button.js'),
|
||||
]);
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialComponents()
|
||||
.then(() => setComponentsLoaded(true))
|
||||
.catch((err) => {
|
||||
console.error('Error loading components:', err);
|
||||
setComponentsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
await requestPasswordReset({ email });
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!componentsLoaded) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Восстановление пароля
|
||||
</p>
|
||||
|
||||
{success ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Инструкции по восстановлению пароля отправлены на ваш email.
|
||||
</div>
|
||||
<md-filled-button
|
||||
onClick={() => router.push('/login')}
|
||||
style={{ width: '100%', height: '48px' }}
|
||||
>
|
||||
Вернуться к входу
|
||||
</md-filled-button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '20px' }}>
|
||||
Введите ваш email для восстановления пароля
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<md-filled-text-field
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onInput={(e: any) => setEmail(e.target.value || '')}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<md-filled-button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', height: '48px', marginBottom: '16px' }}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Отправить'}
|
||||
</md-filled-button>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||||
Вернуться к входу
|
||||
</md-text-button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,214 +1,214 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { confirmPasswordReset } from '@/api/auth';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
const loadMaterialComponents = async () => {
|
||||
await Promise.all([
|
||||
import('@material/web/textfield/filled-text-field.js'),
|
||||
import('@material/web/button/filled-button.js'),
|
||||
import('@material/web/button/text-button.js'),
|
||||
]);
|
||||
};
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialComponents().then(() => setComponentsLoaded(true));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) {
|
||||
setError('Отсутствует ссылка для сброса пароля. Запросите восстановление пароля снова.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await confirmPasswordReset(token, password, confirmPassword);
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!componentsLoaded) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>Загрузка...</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Сброс пароля
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Отсутствует ссылка для сброса пароля. Перейдите по ссылке из письма или запросите восстановление пароля снова.
|
||||
</div>
|
||||
<md-filled-button onClick={() => router.push('/forgot-password')} style={{ width: '100%', height: '48px' }}>
|
||||
Восстановить пароль
|
||||
</md-filled-button>
|
||||
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||||
На страницу входа
|
||||
</md-text-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Сброс пароля
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Пароль успешно изменён. Войдите с новым паролем.
|
||||
</div>
|
||||
<md-filled-button onClick={() => router.push('/login')} style={{ width: '100%', height: '48px' }}>
|
||||
Войти
|
||||
</md-filled-button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Введите новый пароль
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<md-filled-text-field
|
||||
label="Новый пароль"
|
||||
type="password"
|
||||
value={password}
|
||||
onInput={(e: any) => setPassword(e.target.value || '')}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<md-filled-text-field
|
||||
label="Подтвердите пароль"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onInput={(e: any) => setConfirmPassword(e.target.value || '')}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<md-filled-button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', height: '48px', marginBottom: '16px' }}
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Сохранить пароль'}
|
||||
</md-filled-button>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||||
На страницу входа
|
||||
</md-text-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { confirmPasswordReset } from '@/api/auth';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
const loadMaterialComponents = async () => {
|
||||
await Promise.all([
|
||||
import('@material/web/textfield/filled-text-field.js'),
|
||||
import('@material/web/button/filled-button.js'),
|
||||
import('@material/web/button/text-button.js'),
|
||||
]);
|
||||
};
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialComponents().then(() => setComponentsLoaded(true));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) {
|
||||
setError('Отсутствует ссылка для сброса пароля. Запросите восстановление пароля снова.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await confirmPasswordReset(token, password, confirmPassword);
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!componentsLoaded) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>Загрузка...</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Сброс пароля
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Отсутствует ссылка для сброса пароля. Перейдите по ссылке из письма или запросите восстановление пароля снова.
|
||||
</div>
|
||||
<md-filled-button onClick={() => router.push('/forgot-password')} style={{ width: '100%', height: '48px' }}>
|
||||
Восстановить пароль
|
||||
</md-filled-button>
|
||||
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||||
На страницу входа
|
||||
</md-text-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Сброс пароля
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Пароль успешно изменён. Войдите с новым паролем.
|
||||
</div>
|
||||
<md-filled-button onClick={() => router.push('/login')} style={{ width: '100%', height: '48px' }}>
|
||||
Войти
|
||||
</md-filled-button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Введите новый пароль
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<md-filled-text-field
|
||||
label="Новый пароль"
|
||||
type="password"
|
||||
value={password}
|
||||
onInput={(e: any) => setPassword(e.target.value || '')}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<md-filled-text-field
|
||||
label="Подтвердите пароль"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onInput={(e: any) => setConfirmPassword(e.target.value || '')}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<md-filled-button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', height: '48px', marginBottom: '16px' }}
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Сохранить пароль'}
|
||||
</md-filled-button>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||||
На страницу входа
|
||||
</md-text-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +1,174 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { verifyEmail } from '@/api/auth';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
const loadMaterialComponents = async () => {
|
||||
await Promise.all([
|
||||
import('@material/web/button/filled-button.js'),
|
||||
import('@material/web/button/text-button.js'),
|
||||
]);
|
||||
};
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState('');
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialComponents().then(() => setComponentsLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentsLoaded || !token) {
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setMessage('Отсутствует ссылка для подтверждения. Проверьте письмо или запросите новое.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
verifyEmail(token)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success) {
|
||||
setStatus('success');
|
||||
setMessage('Email успешно подтверждён. Теперь вы можете войти в аккаунт.');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage(res.message || 'Не удалось подтвердить email.');
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (cancelled) return;
|
||||
setStatus('error');
|
||||
setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.'));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, componentsLoaded]);
|
||||
|
||||
if (!componentsLoaded) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Подтверждение email...
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Подтверждение email
|
||||
</p>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
<md-filled-button
|
||||
onClick={() => router.push('/login')}
|
||||
style={{ width: '100%', height: '48px' }}
|
||||
>
|
||||
Войти в аккаунт
|
||||
</md-filled-button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
<md-filled-button
|
||||
onClick={() => router.push('/login')}
|
||||
style={{ width: '100%', height: '48px', marginBottom: '12px' }}
|
||||
>
|
||||
На страницу входа
|
||||
</md-filled-button>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<md-text-button onClick={() => router.push('/register')} style={{ fontSize: '14px' }}>
|
||||
Зарегистрироваться
|
||||
</md-text-button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { verifyEmail } from '@/api/auth';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
const loadMaterialComponents = async () => {
|
||||
await Promise.all([
|
||||
import('@material/web/button/filled-button.js'),
|
||||
import('@material/web/button/text-button.js'),
|
||||
]);
|
||||
};
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState('');
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialComponents().then(() => setComponentsLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentsLoaded || !token) {
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setMessage('Отсутствует ссылка для подтверждения. Проверьте письмо или запросите новое.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
verifyEmail(token)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success) {
|
||||
setStatus('success');
|
||||
setMessage('Email успешно подтверждён. Теперь вы можете войти в аккаунт.');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage(res.message || 'Не удалось подтвердить email.');
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (cancelled) return;
|
||||
setStatus('error');
|
||||
setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.'));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, componentsLoaded]);
|
||||
|
||||
if (!componentsLoaded) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '600', margin: '0 0 8px 0', color: '#1a1a1a' }}>
|
||||
Uchill
|
||||
</h1>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Подтверждение email...
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Подтверждение email
|
||||
</p>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
<md-filled-button
|
||||
onClick={() => router.push('/login')}
|
||||
style={{ width: '100%', height: '48px' }}
|
||||
>
|
||||
Войти в аккаунт
|
||||
</md-filled-button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
background: '#ffebee',
|
||||
color: '#c62828',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
<md-filled-button
|
||||
onClick={() => router.push('/login')}
|
||||
style={{ width: '100%', height: '48px', marginBottom: '12px' }}
|
||||
>
|
||||
На страницу входа
|
||||
</md-filled-button>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<md-text-button onClick={() => router.push('/register')} style={{ fontSize: '14px' }}>
|
||||
Зарегистрироваться
|
||||
</md-text-button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>Загрузка...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ export default function AnalyticsPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout className="ios26-dashboard-analytics">
|
||||
<DashboardLayout className="ios26-dashboard-analytics" data-tour="analytics-root">
|
||||
<div className="ios26-analytics-swiper-wrap">
|
||||
<Swiper
|
||||
onSwiper={setSwiperInstance}
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export default function ChatPage() {
|
|||
}, [mobileShowChat]);
|
||||
|
||||
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
|
||||
className="ios26-chat-layout"
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export default function FeedbackPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page">
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="feedback-root">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
|
||||
import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
|
||||
import { OnboardingProvider } from '@/contexts/OnboardingContext';
|
||||
import { getNavBadges } from '@/api/navBadges';
|
||||
import { getActiveSubscription } from '@/api/subscriptions';
|
||||
import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals';
|
||||
|
|
@ -148,6 +149,7 @@ export default function ProtectedLayout({
|
|||
return (
|
||||
<NavBadgesProvider refreshNavBadges={refreshNavBadges}>
|
||||
<SelectedChildProvider>
|
||||
<OnboardingProvider>
|
||||
<div className="protected-layout-root">
|
||||
{!isFullWidthPage && <TopNavigationBar user={user} />}
|
||||
<main
|
||||
|
|
@ -176,6 +178,7 @@ export default function ProtectedLayout({
|
|||
<NotificationBell />
|
||||
)}
|
||||
</div>
|
||||
</OnboardingProvider>
|
||||
</SelectedChildProvider>
|
||||
</NavBadgesProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -573,7 +573,7 @@ export default function MaterialsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: '24px' }} data-tour="materials-root">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -587,6 +587,7 @@ export default function MaterialsPage() {
|
|||
{!isClient && (
|
||||
<button
|
||||
type="button"
|
||||
data-tour="materials-add"
|
||||
onClick={() => setAddPanelOpen(true)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ export default function MyProgressPage() {
|
|||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<DashboardLayout className="ios26-dashboard-grid">
|
||||
<DashboardLayout className="ios26-dashboard-grid" data-tour="my-progress-root">
|
||||
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
|
||||
<Panel padding="md">
|
||||
<SectionHeader
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const ProfilePaymentTab = dynamic(
|
|||
|
||||
export default function PaymentPage() {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { ProfilePaymentTab } from '@/components/profile/ProfilePaymentTab';
|
|||
import { NotificationSettingsSection } from '@/components/profile/NotificationSettingsSection';
|
||||
import { ParentChildNotificationSettings } from '@/components/profile/ParentChildNotificationSettings';
|
||||
import { TelegramSection } from '@/components/profile/TelegramSection';
|
||||
import { OnboardingTipsSection } from '@/components/profile/OnboardingTipsSection';
|
||||
import { Switch } from '@/components/common/Switch';
|
||||
|
||||
function getAvatarUrl(user: { avatar_url?: string | null; avatar?: string | null } | null): string | null {
|
||||
|
|
@ -382,6 +383,7 @@ function ProfilePage() {
|
|||
return (
|
||||
<div
|
||||
className="page-profile"
|
||||
data-tour="profile-root"
|
||||
style={{
|
||||
padding: 24,
|
||||
position: 'relative',
|
||||
|
|
@ -893,6 +895,11 @@ function ProfilePage() {
|
|||
{saving ? 'Сохранение...' : saveSuccess ? 'Профиль успешно обновлён' : 'Сохранить'}
|
||||
</button>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export default function ReferralsPage() {
|
|||
return (
|
||||
<div
|
||||
className="page-referrals"
|
||||
data-tour="referrals-root"
|
||||
style={{
|
||||
padding: 24,
|
||||
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',
|
||||
|
|
|
|||
|
|
@ -122,9 +122,11 @@ export default function RequestMentorPage() {
|
|||
style={{
|
||||
padding: '24px',
|
||||
}}
|
||||
data-tour="request-mentor-root"
|
||||
>
|
||||
{/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */}
|
||||
<div
|
||||
data-tour="request-mentor-tabs"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
|
|
|
|||
|
|
@ -113,42 +113,62 @@ export default function SchedulePage() {
|
|||
})();
|
||||
}, [isFormVisible]);
|
||||
|
||||
const loadLessons = useCallback(async () => {
|
||||
const start = startOfMonth(subMonths(visibleMonth, 1));
|
||||
const end = endOfMonth(addMonths(visibleMonth, 1));
|
||||
const isInitial = !hasLoadedLessonsOnceRef.current;
|
||||
try {
|
||||
if (isInitial) setLessonsLoading(true);
|
||||
setError(null);
|
||||
const { lessons: lessonsData } = await getLessonsCalendar({
|
||||
start_date: format(start, 'yyyy-MM-dd'),
|
||||
end_date: format(end, 'yyyy-MM-dd'),
|
||||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||||
});
|
||||
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
|
||||
id: lesson.id,
|
||||
title: lesson.title,
|
||||
start_time: lesson.start_time,
|
||||
end_time: lesson.end_time,
|
||||
status: lesson.status,
|
||||
client: lesson.client?.id,
|
||||
client_name: lesson.client_name ?? (lesson.client?.user
|
||||
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
||||
: undefined),
|
||||
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
|
||||
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
|
||||
: undefined),
|
||||
subject: lesson.subject ?? lesson.subject_name ?? '',
|
||||
}));
|
||||
setLessons(mappedLessons);
|
||||
hasLoadedLessonsOnceRef.current = true;
|
||||
} catch (err: any) {
|
||||
console.error('Error loading lessons:', err);
|
||||
setError(err?.message || 'Ошибка загрузки занятий');
|
||||
} finally {
|
||||
if (isInitial) setLessonsLoading(false);
|
||||
}
|
||||
}, [visibleMonth, selectedChild?.id]);
|
||||
const loadLessons = useCallback(
|
||||
async (merge?: boolean) => {
|
||||
const start = startOfMonth(subMonths(visibleMonth, 1));
|
||||
const end = endOfMonth(addMonths(visibleMonth, 1));
|
||||
const doMerge = merge ?? hasLoadedLessonsOnceRef.current;
|
||||
const isInitial = !hasLoadedLessonsOnceRef.current && !doMerge;
|
||||
try {
|
||||
setLessonsLoading(true);
|
||||
setError(null);
|
||||
const { lessons: lessonsData } = await getLessonsCalendar({
|
||||
start_date: format(start, 'yyyy-MM-dd'),
|
||||
end_date: format(end, 'yyyy-MM-dd'),
|
||||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||||
});
|
||||
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
|
||||
id: lesson.id,
|
||||
title: lesson.title,
|
||||
start_time: lesson.start_time,
|
||||
end_time: lesson.end_time,
|
||||
status: lesson.status,
|
||||
client: lesson.client?.id,
|
||||
client_name: lesson.client_name ?? (lesson.client?.user
|
||||
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
||||
: undefined),
|
||||
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
|
||||
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
|
||||
: undefined),
|
||||
subject: lesson.subject ?? lesson.subject_name ?? '',
|
||||
}));
|
||||
if (doMerge) {
|
||||
setLessons((prev) => {
|
||||
const startStr = format(start, 'yyyy-MM-dd');
|
||||
const endStr = format(end, 'yyyy-MM-dd');
|
||||
const byId = new Map<string, CalendarLesson>();
|
||||
prev.forEach((l) => {
|
||||
const lessonDateStr = l.start_time?.slice(0, 10) ?? '';
|
||||
if (lessonDateStr < startStr || lessonDateStr > endStr) {
|
||||
byId.set(String(l.id), l);
|
||||
}
|
||||
});
|
||||
mappedLessons.forEach((l) => byId.set(String(l.id), l));
|
||||
return Array.from(byId.values());
|
||||
});
|
||||
} else {
|
||||
setLessons(mappedLessons);
|
||||
}
|
||||
hasLoadedLessonsOnceRef.current = true;
|
||||
} catch (err: any) {
|
||||
console.error('Error loading lessons:', err);
|
||||
setError(err?.message || 'Ошибка загрузки занятий');
|
||||
} finally {
|
||||
setLessonsLoading(false);
|
||||
}
|
||||
},
|
||||
[visibleMonth, selectedChild?.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadLessons();
|
||||
|
|
@ -442,7 +462,7 @@ export default function SchedulePage() {
|
|||
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
|
||||
minHeight: 'min(calc(100vh - 160px), 600px)',
|
||||
}}>
|
||||
<div className="ios26-schedule-calendar-wrap">
|
||||
<div className="ios26-schedule-calendar-wrap" data-tour="schedule-calendar">
|
||||
<Calendar
|
||||
lessons={lessons}
|
||||
lessonsLoading={lessonsLoading}
|
||||
|
|
@ -454,7 +474,7 @@ export default function SchedulePage() {
|
|||
userTimezone={user?.timezone}
|
||||
/>
|
||||
</div>
|
||||
<div className="ios26-schedule-right-wrap">
|
||||
<div className="ios26-schedule-right-wrap" data-tour="schedule-form">
|
||||
<CheckLesson
|
||||
selectedDate={selectedDate}
|
||||
displayDate={displayDate}
|
||||
|
|
|
|||
|
|
@ -414,6 +414,7 @@ export default function StudentsPage() {
|
|||
return (
|
||||
<div
|
||||
className="page-students"
|
||||
data-tour="students-list"
|
||||
style={{
|
||||
padding: '24px',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -96,13 +96,14 @@ export const Calendar: React.FC<CalendarProps> = ({
|
|||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{lessonsLoading ? (
|
||||
{lessonsLoading && lessons.length === 0 ? (
|
||||
<LoadingSpinner size="medium" />
|
||||
) : (
|
||||
<LessonsCalendar
|
||||
lessons={mappedLessons}
|
||||
selectedDate={selectedDate}
|
||||
userTimezone={userTimezone}
|
||||
loading={lessonsLoading}
|
||||
onSelectSlot={(date) => {
|
||||
try {
|
||||
const d = startOfDay(date);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMo
|
|||
return (
|
||||
<Box
|
||||
className="ios-glass-panel"
|
||||
data-tour="chat-list"
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
p: 2,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -74,13 +74,18 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
padding: '16px',
|
||||
}}>
|
||||
<div
|
||||
data-tour="client-lessons"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
{/* Статистика студента */}
|
||||
<div style={{
|
||||
<div
|
||||
data-tour="client-stats"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: '16px',
|
||||
|
|
@ -140,7 +145,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
|
|||
|
||||
{/* Следующее занятие */}
|
||||
{stats?.next_lesson && (
|
||||
<div style={{
|
||||
<div
|
||||
data-tour="client-next-lesson"
|
||||
style={{
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
borderRadius: '20px',
|
||||
padding: '24px',
|
||||
|
|
@ -168,7 +175,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
|
|||
marginBottom: '24px'
|
||||
}}>
|
||||
{/* Домашние задания */}
|
||||
<div style={{
|
||||
<div
|
||||
data-tour="client-homework"
|
||||
style={{
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
borderRadius: '20px',
|
||||
padding: '24px',
|
||||
|
|
@ -206,7 +215,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
|
|||
</div>
|
||||
|
||||
{/* Ближайшие занятия */}
|
||||
<div style={{
|
||||
<div
|
||||
data-tour="client-upcoming"
|
||||
style={{
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
borderRadius: '20px',
|
||||
padding: '24px',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { ru } from 'date-fns/locale';
|
|||
import { Box, IconButton, Typography } from '@mui/material';
|
||||
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
|
||||
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
|
|
@ -48,6 +49,8 @@ interface LessonsCalendarProps {
|
|||
onMonthChange?: (start: Date, end: Date) => void;
|
||||
selectedDate?: Date;
|
||||
userTimezone?: string;
|
||||
/** Идёт загрузка данных (запрос нового месяца) — блокирует навигацию */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||
|
|
@ -57,6 +60,7 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
|||
onMonthChange,
|
||||
selectedDate,
|
||||
userTimezone,
|
||||
loading = false,
|
||||
}) => {
|
||||
const safeSelectedDate = useMemo(() => {
|
||||
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
|
||||
|
|
@ -176,24 +180,30 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
|||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<IconButton
|
||||
onClick={goPrevMonth}
|
||||
onClick={loading ? undefined : goPrevMonth}
|
||||
size="small"
|
||||
disabled={loading}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
backgroundColor: 'var(--md-sys-color-surface)',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
pointerEvents: loading ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={goToday}
|
||||
onClick={loading ? undefined : goToday}
|
||||
size="small"
|
||||
disabled={loading}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
px: 1.25,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
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)' }}>
|
||||
|
|
@ -201,16 +211,24 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
|||
</Typography>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={goNextMonth}
|
||||
onClick={loading ? undefined : goNextMonth}
|
||||
size="small"
|
||||
disabled={loading}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
backgroundColor: 'var(--md-sys-color-surface)',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
pointerEvents: loading ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<ChevronRight fontSize="small" />
|
||||
</IconButton>
|
||||
{loading && (
|
||||
<Box sx={{ ml: 0.5, display: 'flex', alignItems: 'center' }}>
|
||||
<LoadingSpinner size="small" inline />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export const ExtraStatsSection: React.FC<ExtraStatsSectionProps> = ({ stats, loa
|
|||
const rows = buildRows(stats, loading).slice(0, 9);
|
||||
|
||||
return (
|
||||
<Panel padding="md">
|
||||
<Panel padding="md" data-tour="mentor-extrastats">
|
||||
<SectionHeader title="Статистика" />
|
||||
<div className="ios26-stat-grid">
|
||||
{rows.map((row, index) => {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const IncomeSection: React.FC<IncomeSectionProps> = ({
|
|||
const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0);
|
||||
|
||||
return (
|
||||
<Panel padding="md">
|
||||
<Panel padding="md" data-tour="mentor-income">
|
||||
<SectionHeader
|
||||
title="Динамика доходов"
|
||||
trailing={
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> =
|
|||
flipped={flipped}
|
||||
onFlippedChange={setFlipped}
|
||||
front={
|
||||
<Panel padding="md">
|
||||
<Panel padding="md" data-tour="mentor-submissions">
|
||||
<SectionHeader title="Последние сданные ДЗ" />
|
||||
{loading && !data ? (
|
||||
<LoadingSpinner size="medium" />
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
|
|||
setFlipped(v);
|
||||
}}
|
||||
front={
|
||||
<Panel padding="md">
|
||||
<Panel padding="md" data-tour="mentor-upcoming">
|
||||
<SectionHeader title="Ближайшие занятия" />
|
||||
{loading && !data ? (
|
||||
<LoadingSpinner size="medium" />
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ export interface DashboardLayoutProps {
|
|||
children: React.ReactNode;
|
||||
/** Дополнительный класс для контейнера */
|
||||
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 (
|
||||
<div className={`ios26-dashboard ${className}`.trim()}>
|
||||
<div className={`ios26-dashboard ${className}`.trim()} data-tour={dataTour}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,83 +1,83 @@
|
|||
/**
|
||||
* Карточка с лицевой и обратной стороной (переключение без анимации переворота).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
export interface FlipCardProps {
|
||||
/** Контент лицевой стороны */
|
||||
front: React.ReactNode;
|
||||
/** Контент обратной стороны */
|
||||
back: React.ReactNode;
|
||||
/** Высота карточки */
|
||||
height?: string | number;
|
||||
/** Дополнительный класс */
|
||||
className?: string;
|
||||
/** Управляемый режим (если задан) */
|
||||
flipped?: boolean;
|
||||
/** Коллбек при смене состояния */
|
||||
onFlippedChange?: (flipped: boolean) => void;
|
||||
}
|
||||
|
||||
export const FlipCard: React.FC<FlipCardProps> = ({
|
||||
front,
|
||||
back,
|
||||
height = 'auto',
|
||||
className = '',
|
||||
flipped,
|
||||
onFlippedChange,
|
||||
}) => {
|
||||
const [internalFlipped, setInternalFlipped] = useState(false);
|
||||
const isControlled = useMemo(() => flipped !== undefined, [flipped]);
|
||||
const isFlipped = isControlled ? (flipped as boolean) : internalFlipped;
|
||||
|
||||
const setFlipped = (next: boolean) => {
|
||||
if (!isControlled) setInternalFlipped(next);
|
||||
onFlippedChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flip-card ${className}`.trim()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
width: '100%',
|
||||
...(height === 'auto' && { minHeight: 340 }),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flip-card-front"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: isFlipped ? 0 : 1,
|
||||
visibility: isFlipped ? 'hidden' : 'visible',
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{front}
|
||||
</div>
|
||||
<div
|
||||
className="flip-card-back"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: isFlipped ? 1 : 0,
|
||||
visibility: isFlipped ? 'visible' : 'hidden',
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{back}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Карточка с лицевой и обратной стороной (переключение без анимации переворота).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
export interface FlipCardProps {
|
||||
/** Контент лицевой стороны */
|
||||
front: React.ReactNode;
|
||||
/** Контент обратной стороны */
|
||||
back: React.ReactNode;
|
||||
/** Высота карточки */
|
||||
height?: string | number;
|
||||
/** Дополнительный класс */
|
||||
className?: string;
|
||||
/** Управляемый режим (если задан) */
|
||||
flipped?: boolean;
|
||||
/** Коллбек при смене состояния */
|
||||
onFlippedChange?: (flipped: boolean) => void;
|
||||
}
|
||||
|
||||
export const FlipCard: React.FC<FlipCardProps> = ({
|
||||
front,
|
||||
back,
|
||||
height = 'auto',
|
||||
className = '',
|
||||
flipped,
|
||||
onFlippedChange,
|
||||
}) => {
|
||||
const [internalFlipped, setInternalFlipped] = useState(false);
|
||||
const isControlled = useMemo(() => flipped !== undefined, [flipped]);
|
||||
const isFlipped = isControlled ? (flipped as boolean) : internalFlipped;
|
||||
|
||||
const setFlipped = (next: boolean) => {
|
||||
if (!isControlled) setInternalFlipped(next);
|
||||
onFlippedChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flip-card ${className}`.trim()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
width: '100%',
|
||||
...(height === 'auto' && { minHeight: 340 }),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flip-card-front"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: isFlipped ? 0 : 1,
|
||||
visibility: isFlipped ? 'hidden' : 'visible',
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{front}
|
||||
</div>
|
||||
<div
|
||||
className="flip-card-back"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: isFlipped ? 1 : 0,
|
||||
visibility: isFlipped ? 'visible' : 'hidden',
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{back}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface PanelProps {
|
|||
/** Внутренние отступы. По умолчанию 24px */
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
style?: React.CSSProperties;
|
||||
/** Атрибут для онбординга (data-tour) */
|
||||
'data-tour'?: string;
|
||||
}
|
||||
|
||||
const paddingMap = {
|
||||
|
|
@ -31,10 +33,12 @@ export const Panel: React.FC<PanelProps> = ({
|
|||
interactive = false,
|
||||
padding = 'md',
|
||||
style,
|
||||
'data-tour': dataTour,
|
||||
}) => {
|
||||
const p = paddingMap[padding];
|
||||
return (
|
||||
<div
|
||||
data-tour={dataTour}
|
||||
className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()}
|
||||
style={{
|
||||
padding: p ? `${p}px` : 0,
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ export function HomeworkPageContent() {
|
|||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page">
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="homework-root">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import { getOrCreateLessonChat } from '@/api/chat';
|
|||
import type { Chat } from '@/api/chat';
|
||||
import { ChatWindow } from '@/components/chat/ChatWindow';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useOnboarding } from '@/contexts/OnboardingContext';
|
||||
import { getAvatarUrl } from '@/api/profile';
|
||||
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
|
||||
import { getNavBadges } from '@/api/navBadges';
|
||||
|
|
@ -817,6 +818,7 @@ export default function LiveKitRoomContent() {
|
|||
const accessToken = searchParams.get('token');
|
||||
const lessonIdParam = searchParams.get('lesson_id');
|
||||
const { user } = useAuth();
|
||||
const onboarding = useOnboarding();
|
||||
|
||||
const [serverUrl, setServerUrl] = useState<string>('');
|
||||
const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
|
||||
|
|
@ -923,6 +925,15 @@ export default function LiveKitRoomContent() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Подсказка по видеозвонку для студента (один раз, после входа в комнату)
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'client' || !onboarding || showPreJoin) return;
|
||||
const t = setTimeout(() => {
|
||||
onboarding.runTourManually('livekit');
|
||||
}, 3500);
|
||||
return () => clearTimeout(t);
|
||||
}, [user?.role, onboarding, showPreJoin]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
|
||||
if (id && !isNaN(id)) {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function ChildSelectorCompact() {
|
|||
const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?';
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div ref={ref} data-tour="parent-child-selector" style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
|
|||
|
||||
<div
|
||||
data-notification-bell
|
||||
data-tour="notifications-bell"
|
||||
style={
|
||||
embedded
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'); // для текущей роли
|
||||
```
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
"axios": "^1.7.9",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"driver.js": "^1.3.1",
|
||||
"livekit-client": "^2.16.0",
|
||||
"next": "^16.1.4",
|
||||
"react": "^19",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
132
rebuild-prod.sh
132
rebuild-prod.sh
|
|
@ -1,66 +1,66 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Скрипт для полной пересборки PROD окружения с бэкапом БД
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Полная пересборка PROD окружения"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "⚠️ ВНИМАНИЕ: Это пересоберёт все контейнеры без кэша"
|
||||
echo ""
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Шаг 1: Создать бэкап БД
|
||||
echo "Шаг 1: Создание бэкапа БД..."
|
||||
if [ -f "./backup-all-db.sh" ]; then
|
||||
./backup-all-db.sh
|
||||
else
|
||||
echo "⚠️ Скрипт backup-all-db.sh не найден, создаём бэкап вручную..."
|
||||
mkdir -p ./backups
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
if docker ps | grep -q platform_prod_db; then
|
||||
docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz"
|
||||
echo "✓ Бэкап создан: ./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz"
|
||||
else
|
||||
echo "⚠️ Контейнер БД не запущен, пропускаем бэкап"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Шаг 2: Остановка контейнеров..."
|
||||
docker compose down
|
||||
|
||||
echo ""
|
||||
echo "Шаг 3: Пересборка образов без кэша..."
|
||||
echo "Это может занять несколько минут..."
|
||||
docker compose build --no-cache --pull
|
||||
|
||||
echo ""
|
||||
echo "Шаг 4: Запуск контейнеров..."
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "Шаг 5: Ожидание запуска БД..."
|
||||
sleep 10
|
||||
|
||||
echo ""
|
||||
echo "Шаг 6: Применение миграций..."
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
|
||||
echo ""
|
||||
echo "Шаг 7: Проверка статуса контейнеров..."
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✓ Пересборка завершена!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Проверьте логи:"
|
||||
echo " docker compose logs -f"
|
||||
echo ""
|
||||
echo "Если нужно создать суперпользователя:"
|
||||
echo " docker exec -it platform_prod_web python manage.py createsuperuser"
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для полной пересборки PROD окружения с бэкапом БД
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Полная пересборка PROD окружения"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "⚠️ ВНИМАНИЕ: Это пересоберёт все контейнеры без кэша"
|
||||
echo ""
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Шаг 1: Создать бэкап БД
|
||||
echo "Шаг 1: Создание бэкапа БД..."
|
||||
if [ -f "./backup-all-db.sh" ]; then
|
||||
./backup-all-db.sh
|
||||
else
|
||||
echo "⚠️ Скрипт backup-all-db.sh не найден, создаём бэкап вручную..."
|
||||
mkdir -p ./backups
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
if docker ps | grep -q platform_prod_db; then
|
||||
docker exec platform_prod_db pg_dumpall -U platform_prod_user -c | gzip > "./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz"
|
||||
echo "✓ Бэкап создан: ./backups/platform_prod_db_backup_${TIMESTAMP}.sql.gz"
|
||||
else
|
||||
echo "⚠️ Контейнер БД не запущен, пропускаем бэкап"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Шаг 2: Остановка контейнеров..."
|
||||
docker compose down
|
||||
|
||||
echo ""
|
||||
echo "Шаг 3: Пересборка образов без кэша..."
|
||||
echo "Это может занять несколько минут..."
|
||||
docker compose build --no-cache --pull
|
||||
|
||||
echo ""
|
||||
echo "Шаг 4: Запуск контейнеров..."
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "Шаг 5: Ожидание запуска БД..."
|
||||
sleep 10
|
||||
|
||||
echo ""
|
||||
echo "Шаг 6: Применение миграций..."
|
||||
docker exec platform_prod_web python manage.py migrate
|
||||
|
||||
echo ""
|
||||
echo "Шаг 7: Проверка статуса контейнеров..."
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✓ Пересборка завершена!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Проверьте логи:"
|
||||
echo " docker compose logs -f"
|
||||
echo ""
|
||||
echo "Если нужно создать суперпользователя:"
|
||||
echo " docker exec -it platform_prod_web python manage.py createsuperuser"
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Скрипт для удаления автоматического бэкапа из cron
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="/var/www/platform/prod"
|
||||
BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh"
|
||||
CRON_USER="root"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Удаление автоматического бэкапа из cron"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Проверить, есть ли запись в crontab
|
||||
if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then
|
||||
echo "Найдена запись в crontab:"
|
||||
crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT"
|
||||
echo ""
|
||||
read -p "Удалить? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" -
|
||||
echo "✓ Запись удалена из crontab"
|
||||
else
|
||||
echo "Отменено."
|
||||
fi
|
||||
else
|
||||
echo "Запись в crontab не найдена."
|
||||
fi
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для удаления автоматического бэкапа из cron
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="/var/www/platform/prod"
|
||||
BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh"
|
||||
CRON_USER="root"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Удаление автоматического бэкапа из cron"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Проверить, есть ли запись в crontab
|
||||
if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then
|
||||
echo "Найдена запись в crontab:"
|
||||
crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT"
|
||||
echo ""
|
||||
read -p "Удалить? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" -
|
||||
echo "✓ Запись удалена из crontab"
|
||||
else
|
||||
echo "Отменено."
|
||||
fi
|
||||
else
|
||||
echo "Запись в crontab не найдена."
|
||||
fi
|
||||
|
|
|
|||
52
safe-down.sh
52
safe-down.sh
|
|
@ -1,26 +1,26 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Безопасная остановка PROD окружения
|
||||
# Этот скрипт останавливает контейнеры БЕЗ удаления volumes (данных БД)
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Безопасная остановка PROD окружения"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Это остановит контейнеры, но СОХРАНИТ данные БД и Redis"
|
||||
echo ""
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Остановить контейнеры без удаления volumes
|
||||
docker compose down
|
||||
|
||||
echo ""
|
||||
echo "✓ Контейнеры остановлены"
|
||||
echo "✓ Volumes сохранены (данные БД не потеряны)"
|
||||
echo ""
|
||||
echo "Для запуска: docker compose up -d"
|
||||
echo "Для полной очистки (с удалением данных): docker compose down --volumes"
|
||||
echo " (ВНИМАНИЕ: это удалит все данные БД!)"
|
||||
#!/bin/bash
|
||||
|
||||
# Безопасная остановка PROD окружения
|
||||
# Этот скрипт останавливает контейнеры БЕЗ удаления volumes (данных БД)
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Безопасная остановка PROD окружения"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Это остановит контейнеры, но СОХРАНИТ данные БД и Redis"
|
||||
echo ""
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Остановить контейнеры без удаления volumes
|
||||
docker compose down
|
||||
|
||||
echo ""
|
||||
echo "✓ Контейнеры остановлены"
|
||||
echo "✓ Volumes сохранены (данные БД не потеряны)"
|
||||
echo ""
|
||||
echo "Для запуска: docker compose up -d"
|
||||
echo "Для полной очистки (с удалением данных): docker compose down --volumes"
|
||||
echo " (ВНИМАНИЕ: это удалит все данные БД!)"
|
||||
|
|
|
|||
|
|
@ -1,79 +1,79 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Скрипт для настройки автоматического бэкапа БД через cron
|
||||
# Запускается дважды в день: в 00:00 и 12:00
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="/var/www/platform/prod"
|
||||
BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh"
|
||||
CRON_USER="root"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Настройка автоматического бэкапа БД"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Проверить, что скрипт существует
|
||||
if [ ! -f "$BACKUP_SCRIPT" ]; then
|
||||
echo "Ошибка: Скрипт $BACKUP_SCRIPT не найден!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Сделать скрипт исполняемым
|
||||
chmod +x "$BACKUP_SCRIPT"
|
||||
echo "✓ Скрипт сделан исполняемым"
|
||||
|
||||
# Создать директорию для бэкапов
|
||||
mkdir -p "$SCRIPT_DIR/backups"
|
||||
echo "✓ Директория для бэкапов создана"
|
||||
|
||||
# Найти путь к docker (для cron)
|
||||
DOCKER_PATH=$(which docker 2>/dev/null || echo "/usr/bin/docker")
|
||||
|
||||
# Проверить, есть ли уже запись в crontab
|
||||
# Используем PATH с docker и bash для надежности
|
||||
CRON_CMD="0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin $BACKUP_SCRIPT >> $SCRIPT_DIR/backups/cron.log 2>&1"
|
||||
|
||||
if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then
|
||||
echo "⚠️ Запись в crontab уже существует"
|
||||
echo ""
|
||||
echo "Текущий crontab:"
|
||||
crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT"
|
||||
echo ""
|
||||
read -p "Заменить существующую запись? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# Удалить старую запись
|
||||
crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" -
|
||||
# Добавить новую
|
||||
(crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" -
|
||||
echo "✓ Запись в crontab обновлена"
|
||||
else
|
||||
echo "Отменено. Существующая запись сохранена."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
# Добавить новую запись
|
||||
(crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" -
|
||||
echo "✓ Запись в crontab добавлена"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Настройка завершена!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Расписание бэкапов:"
|
||||
echo " - Каждый день в 00:00 (полночь)"
|
||||
echo " - Каждый день в 12:00 (полдень)"
|
||||
echo ""
|
||||
echo "Проверить crontab:"
|
||||
echo " crontab -u $CRON_USER -l"
|
||||
echo ""
|
||||
echo "Просмотр логов бэкапов:"
|
||||
echo " tail -f $SCRIPT_DIR/backups/backup.log"
|
||||
echo " tail -f $SCRIPT_DIR/backups/cron.log"
|
||||
echo ""
|
||||
echo "Удалить автоматический бэкап:"
|
||||
echo " crontab -u $CRON_USER -l | grep -v '$BACKUP_SCRIPT' | crontab -u $CRON_USER -"
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для настройки автоматического бэкапа БД через cron
|
||||
# Запускается дважды в день: в 00:00 и 12:00
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="/var/www/platform/prod"
|
||||
BACKUP_SCRIPT="$SCRIPT_DIR/backup-db-auto.sh"
|
||||
CRON_USER="root"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Настройка автоматического бэкапа БД"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Проверить, что скрипт существует
|
||||
if [ ! -f "$BACKUP_SCRIPT" ]; then
|
||||
echo "Ошибка: Скрипт $BACKUP_SCRIPT не найден!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Сделать скрипт исполняемым
|
||||
chmod +x "$BACKUP_SCRIPT"
|
||||
echo "✓ Скрипт сделан исполняемым"
|
||||
|
||||
# Создать директорию для бэкапов
|
||||
mkdir -p "$SCRIPT_DIR/backups"
|
||||
echo "✓ Директория для бэкапов создана"
|
||||
|
||||
# Найти путь к docker (для cron)
|
||||
DOCKER_PATH=$(which docker 2>/dev/null || echo "/usr/bin/docker")
|
||||
|
||||
# Проверить, есть ли уже запись в crontab
|
||||
# Используем PATH с docker и bash для надежности
|
||||
CRON_CMD="0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin $BACKUP_SCRIPT >> $SCRIPT_DIR/backups/cron.log 2>&1"
|
||||
|
||||
if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "$BACKUP_SCRIPT"; then
|
||||
echo "⚠️ Запись в crontab уже существует"
|
||||
echo ""
|
||||
echo "Текущий crontab:"
|
||||
crontab -u "$CRON_USER" -l | grep "$BACKUP_SCRIPT"
|
||||
echo ""
|
||||
read -p "Заменить существующую запись? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# Удалить старую запись
|
||||
crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "$BACKUP_SCRIPT" | crontab -u "$CRON_USER" -
|
||||
# Добавить новую
|
||||
(crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" -
|
||||
echo "✓ Запись в crontab обновлена"
|
||||
else
|
||||
echo "Отменено. Существующая запись сохранена."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
# Добавить новую запись
|
||||
(crontab -u "$CRON_USER" -l 2>/dev/null; echo "$CRON_CMD") | crontab -u "$CRON_USER" -
|
||||
echo "✓ Запись в crontab добавлена"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Настройка завершена!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Расписание бэкапов:"
|
||||
echo " - Каждый день в 00:00 (полночь)"
|
||||
echo " - Каждый день в 12:00 (полдень)"
|
||||
echo ""
|
||||
echo "Проверить crontab:"
|
||||
echo " crontab -u $CRON_USER -l"
|
||||
echo ""
|
||||
echo "Просмотр логов бэкапов:"
|
||||
echo " tail -f $SCRIPT_DIR/backups/backup.log"
|
||||
echo " tail -f $SCRIPT_DIR/backups/cron.log"
|
||||
echo ""
|
||||
echo "Удалить автоматический бэкап:"
|
||||
echo " crontab -u $CRON_USER -l | grep -v '$BACKUP_SCRIPT' | crontab -u $CRON_USER -"
|
||||
|
|
|
|||
Loading…
Reference in New Issue