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

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

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

@ -0,0 +1,62 @@
# Конфигурация Docker на сервере
## Рекомендации для серверов с ограниченной RAM (8 GB)
### 1. Ограничение BuildKit cache
Чтобы BuildKit cache не раздувался до 80+ GB:
```bash
# Создать или отредактировать
sudo nano /etc/docker/daemon.json
```
Содержимое:
```json
{
"builder": {
"gc": {
"defaultKeepStorage": "10GB",
"enabled": true
}
},
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
```
Затем перезапуск:
```bash
sudo systemctl restart docker
```
### 2. Очистка build cache
Если нужно освободить место вручную:
```bash
# Удалить неиспользуемый build cache (осторожно: следующая сборка будет дольше)
docker builder prune -af
# Или с ограничением по возрасту (старше 7 дней)
docker builder prune -af --filter "until=168h"
```
### 3. Frontend Dockerfile — лимит памяти Node.js
В `front_material/Dockerfile` уже задано:
```dockerfile
ENV NODE_OPTIONS="--max-old-space-size=2048"
```
Это ограничивает heap Node.js до 2 GB при сборке и снижает риск OOM и тяжёлого swapping на машинах с 8 GB RAM.
### 4. Swap
Рекомендуется swap 48 GB на серверах с 8 GB RAM — для стабильности при пиковых нагрузках.

View File

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

View File

@ -1,98 +1,98 @@
# Инструкция по пересборке PROD и созданию бэкапов
## 🎯 Что нужно сделать:
### 1. Создать бэкап PROD БД
```bash
# Сделать скрипты исполняемыми (первый раз)
chmod +x /var/www/service/backup/*.sh
chmod +x /var/www/service/platform/*.sh
# Создать бэкап PROD БД
/var/www/service/backup/backup-all-db.sh
```
Это создаст бэкап:
- `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
**Примечание:** DEV БД не бэкапится, так как это окружение разработки.
### 2. Пересобрать PROD окружение
```bash
# Автоматическая пересборка (с бэкапом)
/var/www/service/platform/rebuild-prod.sh
```
Или вручную:
```bash
cd /var/www/platform/prod
# Остановить контейнеры
docker compose down
# Пересобрать без кэша
docker compose build --no-cache --pull
# Запустить
docker compose up -d
# Подождать запуска БД
sleep 10
# Применить миграции
docker exec platform_prod_web python manage.py migrate
# Проверить статус
docker compose ps
```
### 3. Проверить, что всё работает
```bash
# Проверить логи
docker compose logs -f
# Проверить подключение к БД
docker exec platform_prod_web python manage.py shell -c "from django.db import connection; print('DB:', connection.settings_dict['NAME'])"
# Проверить количество пользователей (если таблица существует)
docker exec platform_prod_db psql -U platform_prod_user -d platform_prod_db -c "SELECT COUNT(*) FROM users_user;" 2>/dev/null || echo "Таблица не создана, нужно применить миграции"
```
### 4. Если пользователей нет - создать суперпользователя
```bash
docker exec -it platform_prod_web python manage.py createsuperuser
```
## 📋 Что было исправлено:
**Отдельная сеть для prod** - `prod_network` (изолирована от dev)
**Именованные volumes** - все volumes имеют префикс `platform_prod_`
**Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis`
**Защита от случайного удаления данных** - volumes не удаляются при `docker compose down`
## ⚠️ Важно:
- **НЕ используйте** `docker compose down --volumes` без бэкапа!
- Всегда создавайте бэкапы перед пересборкой
- Используйте `./safe-down.sh` для безопасной остановки
## 🔄 Восстановление из бэкапа (если нужно):
```bash
# Восстановить PROD БД
gunzip < /var/www/platform/prod/backups/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres
```
## 📁 Расположение скриптов
Все служебные скрипты находятся в `/var/www/service/`:
- **Бэкапы**: `/var/www/service/backup/`
- **Управление платформой**: `/var/www/service/platform/`
Подробнее: `/var/www/service/README.md`
# Инструкция по пересборке PROD и созданию бэкапов
## 🎯 Что нужно сделать:
### 1. Создать бэкап PROD БД
```bash
# Сделать скрипты исполняемыми (первый раз)
chmod +x /var/www/service/backup/*.sh
chmod +x /var/www/service/platform/*.sh
# Создать бэкап PROD БД
/var/www/service/backup/backup-all-db.sh
```
Это создаст бэкап:
- `/var/www/platform/prod/backups/platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
**Примечание:** DEV БД не бэкапится, так как это окружение разработки.
### 2. Пересобрать PROD окружение
```bash
# Автоматическая пересборка (с бэкапом)
/var/www/service/platform/rebuild-prod.sh
```
Или вручную:
```bash
cd /var/www/platform/prod
# Остановить контейнеры
docker compose down
# Пересобрать без кэша
docker compose build --no-cache --pull
# Запустить
docker compose up -d
# Подождать запуска БД
sleep 10
# Применить миграции
docker exec platform_prod_web python manage.py migrate
# Проверить статус
docker compose ps
```
### 3. Проверить, что всё работает
```bash
# Проверить логи
docker compose logs -f
# Проверить подключение к БД
docker exec platform_prod_web python manage.py shell -c "from django.db import connection; print('DB:', connection.settings_dict['NAME'])"
# Проверить количество пользователей (если таблица существует)
docker exec platform_prod_db psql -U platform_prod_user -d platform_prod_db -c "SELECT COUNT(*) FROM users_user;" 2>/dev/null || echo "Таблица не создана, нужно применить миграции"
```
### 4. Если пользователей нет - создать суперпользователя
```bash
docker exec -it platform_prod_web python manage.py createsuperuser
```
## 📋 Что было исправлено:
**Отдельная сеть для prod** - `prod_network` (изолирована от dev)
**Именованные volumes** - все volumes имеют префикс `platform_prod_`
**Полные имена контейнеров** - используются `platform_prod_db` и `platform_prod_redis`
**Защита от случайного удаления данных** - volumes не удаляются при `docker compose down`
## ⚠️ Важно:
- **НЕ используйте** `docker compose down --volumes` без бэкапа!
- Всегда создавайте бэкапы перед пересборкой
- Используйте `./safe-down.sh` для безопасной остановки
## 🔄 Восстановление из бэкапа (если нужно):
```bash
# Восстановить PROD БД
gunzip < /var/www/platform/prod/backups/platform_prod_db_*.sql.gz | docker exec -i platform_prod_db psql -U platform_prod_user -d postgres
```
## 📁 Расположение скриптов
Все служебные скрипты находятся в `/var/www/service/`:
- **Бэкапы**: `/var/www/service/backup/`
- **Управление платформой**: `/var/www/service/platform/`
Подробнее: `/var/www/service/README.md`

View File

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

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.7
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0010_user_login_token"),
]
operations = [
migrations.AddField(
model_name="user",
name="onboarding_tours_seen",
field=models.JSONField(
blank=True,
default=dict,
help_text="Страницы, для которых уже показан приветственный тур",
verbose_name="Просмотренные туры онбординга",
),
),
]

View File

@ -241,6 +241,15 @@ class User(AbstractUser):
verbose_name='Последняя активность'
)
# Прогресс онбординга (какие страницы уже показывали подсказки)
# Формат: {"dashboard": true, "schedule": true, "students": false, ...}
onboarding_tours_seen = models.JSONField(
default=dict,
blank=True,
verbose_name='Просмотренные туры онбординга',
help_text='Страницы, для которых уже показан приветственный тур',
)
# Настройки уведомлений
notifications_enabled = models.BooleanField(
default=True,

View File

@ -568,6 +568,7 @@ class ProfileViewSet(viewsets.ViewSet):
'ai_trust_draft': getattr(user, 'ai_trust_draft', False),
'ai_trust_publish': getattr(user, 'ai_trust_publish', False),
}
settings['onboarding_tours_seen'] = getattr(user, 'onboarding_tours_seen', {}) or {}
return Response(settings)
@ -842,6 +843,14 @@ class ProfileViewSet(viewsets.ViewSet):
if 'ai_trust_publish' in mentor_ai:
user.ai_trust_publish = bool(mentor_ai['ai_trust_publish'])
# Онбординг: отметка просмотренных туров
if 'onboarding_tours_seen' in request.data:
tours = request.data['onboarding_tours_seen']
if isinstance(tours, dict):
current = getattr(user, 'onboarding_tours_seen', None) or {}
merged = {**current, **{k: bool(v) for k, v in tours.items()}}
user.onboarding_tours_seen = merged
user.save()
return Response({'message': 'Настройки успешно обновлены'})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -91,6 +91,7 @@ import { getOrCreateLessonChat } from '@/api/chat';
import type { Chat } from '@/api/chat';
import { ChatWindow } from '@/components/chat/ChatWindow';
import { useAuth } from '@/contexts/AuthContext';
import { useOnboarding } from '@/contexts/OnboardingContext';
import { getAvatarUrl } from '@/api/profile';
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
import { getNavBadges } from '@/api/navBadges';
@ -817,6 +818,7 @@ export default function LiveKitRoomContent() {
const accessToken = searchParams.get('token');
const lessonIdParam = searchParams.get('lesson_id');
const { user } = useAuth();
const onboarding = useOnboarding();
const [serverUrl, setServerUrl] = useState<string>('');
const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
@ -923,6 +925,15 @@ export default function LiveKitRoomContent() {
}
}, []);
// Подсказка по видеозвонку для студента (один раз, после входа в комнату)
useEffect(() => {
if (user?.role !== 'client' || !onboarding || showPreJoin) return;
const t = setTimeout(() => {
onboarding.runTourManually('livekit');
}, 3500);
return () => clearTimeout(t);
}, [user?.role, onboarding, showPreJoin]);
useEffect(() => {
const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
if (id && !isNaN(id)) {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,189 @@
'use client';
import { createContext, useContext, useEffect, useRef, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { driver, type DriveStep, type Driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import '@/styles/driver-onboarding.css';
import {
MENTOR_ONBOARDING,
CLIENT_ONBOARDING,
PARENT_ONBOARDING,
getOnboardingKey,
getOnboardingProgress,
} from '@/lib/onboarding-steps';
import { getProfileSettings, updateProfileSettings } from '@/api/profile';
import { useAuth } from '@/contexts/AuthContext';
type Role = 'mentor' | 'client' | 'parent';
interface OnboardingContextType {
markTourSeen: (pageId: string) => Promise<void>;
runTourManually: (pageKey: string, options?: { force?: boolean }) => void;
getProgress: () => { seen: number; total: number };
refreshProgress: () => Promise<void>;
}
const OnboardingContext = createContext<OnboardingContextType | null>(null);
export function useOnboarding() {
return useContext(OnboardingContext);
}
export function OnboardingProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user } = useAuth();
const toursSeenRef = useRef<Record<string, boolean>>({});
const driverRef = useRef<Driver | null>(null);
const markTourSeen = useCallback(async (pageId: string) => {
if (!pageId) return;
toursSeenRef.current[pageId] = true;
try {
await updateProfileSettings({
onboarding_tours_seen: { [pageId]: true },
});
} catch {
// ignore
}
}, []);
const runTour = useCallback(
(
config: { pageId: string; steps: { element?: string; popover: { title: string; description: string; side?: string; align?: string } }[] },
skipSeenCheck?: boolean
) => {
if (!skipSeenCheck && toursSeenRef.current[config.pageId]) return;
if (!config.steps.length) return;
// Преобразуем шаги в формат driver.js
const steps: DriveStep[] = config.steps
.map((s) => {
// driver.js: если элемент не найден, step показывается как overlay по центру
const element = s.element && document.querySelector(s.element) ? s.element : undefined;
return {
element: element || undefined,
popover: {
title: s.popover.title,
description: s.popover.description,
side: (s.popover.side as 'top' | 'right' | 'bottom' | 'left') || 'bottom',
align: (s.popover.align as 'start' | 'center' | 'end') || 'center',
},
};
})
.filter((s) => s.element || s.popover);
if (steps.length === 0) return;
if (driverRef.current) {
driverRef.current.destroy();
driverRef.current = null;
}
const driverObj = driver({
showProgress: true,
steps,
nextBtnText: 'Далее',
prevBtnText: 'Назад',
doneBtnText: 'Понятно',
progressText: '{{current}} из {{total}}',
popoverClass: 'driver-onboarding-friendly',
onDestroyStarted: () => {
markTourSeen(config.pageId);
driverObj.destroy();
driverRef.current = null;
},
});
driverRef.current = driverObj;
driverObj.drive();
},
[markTourSeen]
);
const runTourManually = useCallback(
(pageKey: string, options?: { force?: boolean }) => {
const role = user?.role as Role;
if (!role || !['mentor', 'client', 'parent'].includes(role)) return;
const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING;
const config = configs[pageKey];
if (config) runTour(config, options?.force);
},
[user?.role, runTour]
);
const getProgress = useCallback(() => {
const role = user?.role as Role;
if (!role || !['mentor', 'client', 'parent'].includes(role)) return { seen: 0, total: 0 };
return getOnboardingProgress(toursSeenRef.current, role);
}, [user?.role]);
const refreshProgress = useCallback(async () => {
try {
const settings = await getProfileSettings();
const seen = settings?.onboarding_tours_seen ?? {};
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
} catch {
// ignore
}
}, []);
const runTourRef = useRef(runTour);
runTourRef.current = runTour;
useEffect(() => {
if (!user) return;
getProfileSettings()
.then((s) => {
const seen = s?.onboarding_tours_seen ?? {};
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
})
.catch(() => {});
}, [user?.id]);
useEffect(() => {
if (!user || !pathname) return;
const role = user.role as Role;
if (!['mentor', 'client', 'parent'].includes(role)) return;
if (pathname.startsWith('/login') || pathname.startsWith('/register') || pathname.startsWith('/livekit')) return;
const key = getOnboardingKey(pathname, role);
if (!key) return;
const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING;
const config = configs[key];
if (!config) return;
let cancelled = false;
const loadAndRun = async () => {
try {
const settings = await getProfileSettings();
if (cancelled) return;
const seen = settings?.onboarding_tours_seen ?? {};
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
if (seen[config.pageId]) return;
setTimeout(() => {
if (!cancelled) runTourRef.current(config);
}, 600);
} catch {
// при ошибке не показываем тур
}
};
loadAndRun();
return () => { cancelled = true; };
}, [pathname, user]);
const value: OnboardingContextType = {
markTourSeen,
runTourManually,
getProgress,
refreshProgress,
};
return (
<OnboardingContext.Provider value={value}>
{children}
</OnboardingContext.Provider>
);
}

View File

@ -0,0 +1,49 @@
# Онбординг-туры для платформы
Контекстные подсказки при первом посещении страниц для менторов, студентов и родителей. Используется библиотека **Driver.js**.
## Архитектура
### Backend
- **Поле User.onboarding_tours_seen** (JSONField): `{"mentor-dashboard": true, "mentor-schedule": false, ...}` — какие туры уже просмотрены.
- **API**:
- `GET /profile/settings/` — возвращает `onboarding_tours_seen` в ответе.
- `PATCH /profile/update_settings/` — принимает `onboarding_tours_seen` и сливает с текущим состоянием.
### Frontend
- **OnboardingProvider** (`contexts/OnboardingContext.tsx`): при смене страницы проверяет, нужен ли тур для текущей роли. Если тур ещё не просмотрен — запускает Driver.js.
- **Шаги** (`lib/onboarding-steps.ts`): определения шагов по страницам и ролям (MENTOR_ONBOARDING, CLIENT_ONBOARDING, PARENT_ONBOARDING).
- **data-tour** атрибуты: элементы с `data-tour="..."` используются как цели для подсветки (например, `mentor-income`, `schedule-calendar`, `client-lessons`).
## Страницы и шаги
### Ментор
- dashboard — Динамика доходов, Ближайшие занятия, Недавние сдачи, Навигация
- schedule — Календарь, форма создания занятия
- students, materials, homework, feedback, analytics, payment, profile
### Студент (client)
- dashboard — Ближайшие занятия, Навигация
- schedule, materials, homework, my-progress, request-mentor, profile
### Родитель
- dashboard — Выбор ребёнка, Занятия ребёнка, Навигация
- homework, my-progress, profile
## Добавление новых шагов
1. Добавить шаги в `lib/onboarding-steps.ts` в нужный объект (MENTOR_ONBOARDING, CLIENT_ONBOARDING, PARENT_ONBOARDING).
2. Добавить `data-tour="уникальный-id"` на целевой элемент в компоненте.
3. Шаги без `element` или с несуществующим селектором отображаются как overlay по центру.
## Ручной запуск тура
```ts
const { runTourManually } = useOnboarding();
runTourManually('dashboard'); // для текущей роли
```

View File

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

View File

@ -0,0 +1,85 @@
/**
* Симпатичная стилизация онбординг-туров (Driver.js).
* Мягкие тени, скругления, дружелюбная палитра.
*/
.driver-popover.driver-onboarding-friendly {
background: var(--md-sys-color-surface-container-high, #fff);
border-radius: 20px;
box-shadow: 0 12px 40px rgba(103, 80, 164, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(103, 80, 164, 0.12);
padding: 20px 24px;
max-width: 360px;
}
.driver-popover.driver-onboarding-friendly .driver-popover-title {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-surface, #1c1b1f);
margin-bottom: 10px;
line-height: 1.35;
letter-spacing: -0.01em;
}
.driver-popover.driver-onboarding-friendly .driver-popover-description {
font-size: 15px;
line-height: 1.5;
color: var(--md-sys-color-on-surface-variant, #49454f);
margin-bottom: 20px;
}
.driver-popover.driver-onboarding-friendly .driver-popover-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 4px;
}
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn,
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn {
padding: 10px 20px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn:hover,
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn:hover {
transform: translateY(-1px);
}
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn {
background: var(--md-sys-color-surface-variant, #e7e0ec);
color: var(--md-sys-color-on-surface-variant, #49454f);
}
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn {
background: var(--md-sys-color-primary, #6750a4);
color: var(--md-sys-color-on-primary, #fff);
}
.driver-popover.driver-onboarding-friendly .driver-popover-progress-text {
font-size: 13px;
color: var(--md-sys-color-outline, #79747e);
font-weight: 500;
}
.driver-popover.driver-onboarding-friendly .driver-popover-arrow {
border-color: var(--md-sys-color-surface-container-high, #fff);
}
.driver-overlay {
background: rgba(0, 0, 0, 0) !important;
}
.driver-active-element {
border-radius: 16px !important;
box-shadow:
0 0 0 4px rgba(103, 80, 164, 0.9),
0 0 0 8px rgba(103, 80, 164, 0.25),
0 0 32px rgba(103, 80, 164, 0.35) !important;
}

View File

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

View File

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

View File

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

View File

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