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