Daphne (ASGI) для WebSocket, подписки, email, регистрация, mentor gate, telegram-bot
This commit is contained in:
parent
e3517b39ff
commit
6420a9b182
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Backend (Django): миграции, суперпользователь, команды
|
||||||
|
|
||||||
|
Краткая инструкция по типичным операциям с бэкендом платформы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Через Docker (рекомендуется на сервере)
|
||||||
|
|
||||||
|
Все команды выполняйте из каталога проекта (`/var/www/platform/prod`). Сервис Django в compose называется **web**.
|
||||||
|
|
||||||
|
### Миграции
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Применить все миграции
|
||||||
|
docker compose -p platform exec web python manage.py migrate
|
||||||
|
|
||||||
|
# Создать миграции по изменениям в моделях (после правок в models.py)
|
||||||
|
docker compose -p platform exec web python manage.py makemigrations
|
||||||
|
|
||||||
|
# Создать миграции для конкретного приложения
|
||||||
|
docker compose -p platform exec web python manage.py makemigrations app_name
|
||||||
|
|
||||||
|
# Показать список миграций и их статус
|
||||||
|
docker compose -p platform exec web python manage.py showmigrations
|
||||||
|
```
|
||||||
|
|
||||||
|
После изменения моделей: сначала `makemigrations`, затем `migrate`.
|
||||||
|
|
||||||
|
### Суперпользователь (админ)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать суперпользователя (интерактивно запросит email и пароль)
|
||||||
|
docker compose -p platform exec web python manage.py createsuperuser
|
||||||
|
|
||||||
|
# Сменить пароль существующего пользователя
|
||||||
|
docker compose -p platform exec web python manage.py changepassword <email_или_username>
|
||||||
|
```
|
||||||
|
|
||||||
|
Доступ в админку: **https://api.uchill.online/admin/** (логин/пароль суперпользователя).
|
||||||
|
|
||||||
|
### Сбор статики
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Собрать статические файлы в STATIC_ROOT (для production)
|
||||||
|
docker compose -p platform exec web python manage.py collectstatic --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
### Django shell
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Обычный shell
|
||||||
|
docker compose -p platform exec web python manage.py shell
|
||||||
|
|
||||||
|
# Shell с автоматическим импортом моделей (если установлен django-extensions)
|
||||||
|
docker compose -p platform exec web python manage.py shell_plus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Другие полезные команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка конфигурации
|
||||||
|
docker compose -p platform exec web python manage.py check
|
||||||
|
|
||||||
|
# Список всех команд
|
||||||
|
docker compose -p platform exec web python manage.py help
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Локально (без Docker)
|
||||||
|
|
||||||
|
Из каталога **backend** (`/var/www/platform/prod/backend`). Нужны: Python 3, установленные зависимости, переменные окружения (или `.env` с `DATABASE_URL` и т.д.).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/platform/prod/backend
|
||||||
|
|
||||||
|
# Виртуальное окружение (если используется)
|
||||||
|
# source .venv/bin/activate # Linux/macOS
|
||||||
|
# .venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Миграции
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
|
||||||
|
# Суперпользователь
|
||||||
|
python manage.py createsuperuser
|
||||||
|
|
||||||
|
# Статика
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Запуск сервера разработки
|
||||||
|
python manage.py runserver 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок при первом развёртывании
|
||||||
|
|
||||||
|
1. Запустить контейнеры: `docker compose -p platform up -d`
|
||||||
|
2. Применить миграции: `docker compose -p platform exec web python manage.py migrate`
|
||||||
|
3. Создать суперпользователя: `docker compose -p platform exec web python manage.py createsuperuser`
|
||||||
|
4. При необходимости: `docker compose -p platform exec web python manage.py collectstatic --noinput`
|
||||||
|
|
||||||
|
В текущем `docker-compose` при старте сервиса **web** уже выполняется `migrate` в command, поэтому шаг 2 часто не нужен при обычном перезапуске.
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""
|
||||||
|
Отправка тестового письма для проверки SMTP.
|
||||||
|
Запуск: python manage.py send_test_email recipient@example.com
|
||||||
|
Из Docker: docker compose exec web python manage.py send_test_email recipient@example.com
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Отправляет тестовое письмо на указанный адрес для проверки SMTP'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'email',
|
||||||
|
type=str,
|
||||||
|
help='Email получателя',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
to_email = options['email'].strip()
|
||||||
|
self.stdout.write('Текущие настройки почты:')
|
||||||
|
self.stdout.write(f' EMAIL_BACKEND = {getattr(settings, "EMAIL_BACKEND", "?")}')
|
||||||
|
self.stdout.write(f' EMAIL_HOST = {getattr(settings, "EMAIL_HOST", "?")}')
|
||||||
|
self.stdout.write(f' EMAIL_PORT = {getattr(settings, "EMAIL_PORT", "?")}')
|
||||||
|
self.stdout.write(f' EMAIL_USE_TLS = {getattr(settings, "EMAIL_USE_TLS", "?")}')
|
||||||
|
self.stdout.write(f' EMAIL_USE_SSL = {getattr(settings, "EMAIL_USE_SSL", "?")}')
|
||||||
|
self.stdout.write(f' EMAIL_HOST_USER = {getattr(settings, "EMAIL_HOST_USER", "?")}')
|
||||||
|
pw = getattr(settings, 'EMAIL_HOST_PASSWORD', '') or ''
|
||||||
|
self.stdout.write(f' EMAIL_HOST_PASSWORD = {"***" if pw else "(пусто)"}')
|
||||||
|
self.stdout.write(f' DEFAULT_FROM_EMAIL = {getattr(settings, "DEFAULT_FROM_EMAIL", "?")}')
|
||||||
|
self.stdout.write('')
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject='Тест отправки с платформы',
|
||||||
|
message='Если вы получили это письмо, SMTP настроен верно.',
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[to_email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Письмо отправлено на {to_email}'))
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.ERROR(f'Ошибка: {e}'))
|
||||||
|
import traceback
|
||||||
|
self.stdout.write(traceback.format_exc())
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write(
|
||||||
|
'Подсказка: для Mail.ru используйте порт 465 с SSL и пароль приложения '
|
||||||
|
'(Настройки → Безопасность → Пароли для внешних приложений).'
|
||||||
|
)
|
||||||
|
|
@ -151,11 +151,11 @@ class NotificationPreferenceViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if not settings.TELEGRAM_BOT_TOKEN:
|
if not getattr(settings, 'TELEGRAM_BOT_TOKEN', None) or not settings.TELEGRAM_BOT_TOKEN.strip():
|
||||||
return Response({
|
return Response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'TELEGRAM_BOT_TOKEN не настроен'
|
'error': 'TELEGRAM_BOT_TOKEN не настроен'
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
async def get_bot_info():
|
async def get_bot_info():
|
||||||
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
|
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def active(self, request):
|
def active(self, request):
|
||||||
"""
|
"""
|
||||||
Активная подписка.
|
Активная подписка. При отсутствии — 200 и null (без 404), чтобы фронт не получал ошибку.
|
||||||
|
|
||||||
GET /api/subscriptions/subscriptions/active/
|
GET /api/subscriptions/subscriptions/active/
|
||||||
"""
|
"""
|
||||||
|
|
@ -242,11 +242,8 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
||||||
status__in=['trial', 'active']
|
status__in=['trial', 'active']
|
||||||
).select_related('plan', 'user').order_by('-end_date').first()
|
).select_related('plan', 'user').order_by('-end_date').first()
|
||||||
|
|
||||||
if not subscription:
|
if not subscription or not subscription.is_active():
|
||||||
return Response(
|
return Response(None)
|
||||||
{'error': 'Активная подписка не найдена'},
|
|
||||||
status=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
@ -291,6 +288,78 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
||||||
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def activate_free(self, request):
|
||||||
|
"""
|
||||||
|
Активировать бесплатный тариф (цена плана = 0). Платёжный запрос не создаётся.
|
||||||
|
|
||||||
|
POST /api/subscriptions/subscriptions/activate_free/
|
||||||
|
Body: {
|
||||||
|
"plan_id": 1,
|
||||||
|
"duration_days": 30, // опционально
|
||||||
|
"student_count": 1 // опционально, для тарифа "за ученика"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
plan_id = request.data.get('plan_id')
|
||||||
|
if not plan_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Требуется plan_id'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
plan = SubscriptionPlan.objects.get(id=plan_id, is_active=True)
|
||||||
|
except SubscriptionPlan.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Тариф не найден'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
# Только для тарифов с нулевой ценой (monthly: price=0, per_student: price_per_student=0)
|
||||||
|
base_price = Decimal(str(plan.price if plan.price is not None else 0))
|
||||||
|
price_per_student = Decimal(str(plan.price_per_student if getattr(plan, 'price_per_student', None) is not None else 0))
|
||||||
|
st = getattr(plan, 'subscription_type', None) or 'monthly'
|
||||||
|
is_free = (st == 'per_student' and price_per_student == Decimal('0')) or (st != 'per_student' and base_price == Decimal('0'))
|
||||||
|
if not is_free:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
duration_days = int(request.data.get('duration_days', 30))
|
||||||
|
student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0
|
||||||
|
if st == 'per_student' and student_count <= 0:
|
||||||
|
student_count = 1
|
||||||
|
available = plan.get_available_durations() if hasattr(plan, 'get_available_durations') else [30]
|
||||||
|
if not available:
|
||||||
|
available = [30]
|
||||||
|
if duration_days not in available:
|
||||||
|
duration_days = available[0]
|
||||||
|
try:
|
||||||
|
subscription = SubscriptionService.create_subscription(
|
||||||
|
user=request.user,
|
||||||
|
plan=plan,
|
||||||
|
student_count=student_count,
|
||||||
|
duration_days=duration_days,
|
||||||
|
start_date=timezone.now(),
|
||||||
|
promo_code=None
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception("activate_free create_subscription failed")
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
subscription.status = 'active'
|
||||||
|
subscription.save(update_fields=['status'])
|
||||||
|
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Подписка активирована',
|
||||||
|
'subscription': serializer.data,
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
@action(detail=False, methods=['post'])
|
||||||
def check_feature(self, request):
|
def check_feature(self, request):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -605,71 +605,49 @@ class ProfileViewSet(viewsets.ViewSet):
|
||||||
# Нормализуем запрос: приводим к lowercase после проверки на пустоту
|
# Нормализуем запрос: приводим к lowercase после проверки на пустоту
|
||||||
query_lower = query.lower().strip()
|
query_lower = query.lower().strip()
|
||||||
|
|
||||||
# Используем кеш для хранения данных CSV
|
import os
|
||||||
cache_key = 'cities_csv_data'
|
possible_paths = [
|
||||||
cities_data = cache.get(cache_key)
|
os.path.join(settings.BASE_DIR, '..', '..', 'city.csv'),
|
||||||
|
os.path.join(settings.BASE_DIR, '..', 'city.csv'),
|
||||||
|
os.path.join(settings.BASE_DIR, 'city.csv'),
|
||||||
|
'/app/city.csv',
|
||||||
|
'/code/city.csv',
|
||||||
|
]
|
||||||
|
csv_path = next((p for p in possible_paths if os.path.exists(p)), None)
|
||||||
|
# Ключ кеша по mtime — после загрузки нового city.csv подхватятся свежие данные
|
||||||
|
if csv_path:
|
||||||
|
cache_key = 'cities_csv_data_%s' % int(os.path.getmtime(csv_path))
|
||||||
|
cities_data = cache.get(cache_key)
|
||||||
|
else:
|
||||||
|
cache_key = None
|
||||||
|
cities_data = None
|
||||||
|
|
||||||
if cities_data is None:
|
if cities_data is None and csv_path:
|
||||||
try:
|
try:
|
||||||
# Путь к локальному файлу city.csv
|
|
||||||
import os
|
|
||||||
# Пробуем разные пути
|
|
||||||
possible_paths = [
|
|
||||||
os.path.join(settings.BASE_DIR, '..', '..', 'city.csv'), # Из backend/config/ -> platform/
|
|
||||||
os.path.join(settings.BASE_DIR, '..', 'city.csv'), # Из backend/ -> platform/
|
|
||||||
os.path.join(settings.BASE_DIR, 'city.csv'), # В backend/config/
|
|
||||||
'/app/city.csv', # В Docker контейнере (если монтирован в /app)
|
|
||||||
'/code/city.csv', # Альтернативный путь в Docker
|
|
||||||
]
|
|
||||||
|
|
||||||
csv_path = None
|
|
||||||
for path in possible_paths:
|
|
||||||
if os.path.exists(path):
|
|
||||||
csv_path = path
|
|
||||||
break
|
|
||||||
|
|
||||||
if not csv_path:
|
|
||||||
raise FileNotFoundError("city.csv не найден ни в одном из возможных мест")
|
|
||||||
|
|
||||||
# Читаем CSV файл
|
|
||||||
cities_data = []
|
cities_data = []
|
||||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
|
|
||||||
for row in reader:
|
for row in reader:
|
||||||
city_name = row.get('city', '').strip()
|
city_name = row.get('city', '').strip()
|
||||||
city_type = row.get('city_type', '').strip()
|
city_type = row.get('city_type', '').strip()
|
||||||
timezone = row.get('timezone', '').strip()
|
timezone = row.get('timezone', '').strip()
|
||||||
region = row.get('region', '').strip()
|
region = row.get('region', '').strip()
|
||||||
|
|
||||||
# Если city пустое, но есть region (как в случае с Москвой),
|
|
||||||
# используем region как название города
|
|
||||||
if not city_name and region:
|
if not city_name and region:
|
||||||
city_name = region
|
city_name = region
|
||||||
|
|
||||||
if city_name and timezone:
|
if city_name and timezone:
|
||||||
# Формируем полное название с типом (для поиска)
|
|
||||||
full_city_name = f"{city_type} {city_name}".strip() if city_type else city_name
|
full_city_name = f"{city_type} {city_name}".strip() if city_type else city_name
|
||||||
|
|
||||||
cities_data.append({
|
cities_data.append({
|
||||||
'name': city_name, # Оригинальное имя без типа для отображения
|
'name': city_name,
|
||||||
'full_search_name': full_city_name.lower(), # Полное имя с типом для поиска
|
'full_search_name': full_city_name.lower(),
|
||||||
'timezone': timezone,
|
'timezone': timezone,
|
||||||
'region': region,
|
'region': region,
|
||||||
'city_type': city_type,
|
'city_type': city_type,
|
||||||
'full_name': f"{city_name}" + (f", {region}" if region and region != city_name else ""),
|
'full_name': f"{city_name}" + (f", {region}" if region and region != city_name else ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Сохраняем в кеш на 24 часа
|
|
||||||
cache.set(cache_key, cities_data, 24 * 60 * 60)
|
cache.set(cache_key, cities_data, 24 * 60 * 60)
|
||||||
|
|
||||||
# Отладочная информация
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(f"Loaded {len(cities_data)} cities from CSV")
|
logger.info(f"Loaded {len(cities_data)} cities from CSV")
|
||||||
# Проверяем наличие Москвы в данных
|
|
||||||
moscow_found = any(c.get('name', '').lower() == 'москва' for c in cities_data)
|
|
||||||
logger.info(f"Moscow found in data: {moscow_found}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# В случае ошибки возвращаем пустой список
|
# В случае ошибки возвращаем пустой список
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -679,14 +657,24 @@ class ProfileViewSet(viewsets.ViewSet):
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return Response({'error': str(e)}, status=500)
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
# Проверяем, что данные загружены
|
# Если CSV пустой или не найден — используем cities_ru.json (geo_utils)
|
||||||
if not cities_data:
|
if not cities_data:
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.warning("cities_data is empty, cache might be expired or file not found")
|
logger.info("city.csv empty or missing, using cities_ru.json fallback")
|
||||||
return Response([])
|
from .geo_utils import search_cities as search_cities_json
|
||||||
|
json_cities = search_cities_json(country="RU", query=query_lower)
|
||||||
# Ищем города по запросу
|
results = []
|
||||||
|
for c in json_cities[:limit]:
|
||||||
|
results.append({
|
||||||
|
'name': c.get('city', ''),
|
||||||
|
'timezone': c.get('timezone', ''),
|
||||||
|
'region': c.get('country_name', ''),
|
||||||
|
'full_name': f"{c.get('city', '')}, {c.get('country_name', '')}".strip(', '),
|
||||||
|
})
|
||||||
|
return Response(results)
|
||||||
|
|
||||||
|
# Ищем города по запросу в данных из CSV
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Префиксы типов населенных пунктов, которые нужно убирать при поиске
|
# Префиксы типов населенных пунктов, которые нужно убирать при поиске
|
||||||
|
|
|
||||||
1118
backend/city.csv
1118
backend/city.csv
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,118 +1,274 @@
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# Docker Compose для DEV окружения
|
# Docker Compose PROD (порты не пересекаются с dev на одном хосте)
|
||||||
# ==============================================
|
# ==============================================
|
||||||
|
# Порты на хосте (prod): db 5434, redis 6381, web 8123, nginx 8084,
|
||||||
services:
|
# front_material 3010, yjs 1236, excalidraw 3004, whiteboard 8083,
|
||||||
db:
|
# livekit 7880/7881, celery/beat — без портов (внутренние)
|
||||||
image: postgres:16-alpine
|
# Dev использует: 5433, 6380, 8124, 8081, 3002, 1235, 3003, 8082, livekit 7890/7891
|
||||||
container_name: platform_dev_db
|
|
||||||
restart: unless-stopped
|
services:
|
||||||
environment:
|
db:
|
||||||
POSTGRES_DB: platform_dev_db
|
image: postgres:16-alpine
|
||||||
POSTGRES_USER: platform_dev_user
|
container_name: platform_prod_db
|
||||||
POSTGRES_PASSWORD: platform_dev_password
|
restart: unless-stopped
|
||||||
ports:
|
environment:
|
||||||
- "5433:5432"
|
POSTGRES_DB: platform_prod_db
|
||||||
volumes:
|
POSTGRES_USER: platform_prod_user
|
||||||
- dev_postgres_data:/var/lib/postgresql/data
|
POSTGRES_PASSWORD: platform_prod_password
|
||||||
networks:
|
ports:
|
||||||
- dev_network
|
- "5434:5432"
|
||||||
|
volumes:
|
||||||
redis:
|
- prod_postgres_data:/var/lib/postgresql/data
|
||||||
image: redis:7-alpine
|
networks:
|
||||||
container_name: platform_dev_redis
|
- dev_network
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
redis:
|
||||||
- "6380:6379"
|
image: redis:7-alpine
|
||||||
volumes:
|
container_name: platform_prod_redis
|
||||||
- dev_redis_data:/data
|
restart: unless-stopped
|
||||||
networks:
|
ports:
|
||||||
- dev_network
|
- "6381:6379"
|
||||||
|
volumes:
|
||||||
web:
|
- prod_redis_data:/data
|
||||||
build:
|
networks:
|
||||||
context: ./backend
|
- dev_network
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: platform_dev_web
|
web:
|
||||||
restart: unless-stopped
|
build:
|
||||||
command: sh -c "python manage.py migrate && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 2"
|
context: ./backend
|
||||||
environment:
|
dockerfile: Dockerfile
|
||||||
- DEBUG=True
|
container_name: platform_prod_web
|
||||||
- SECRET_KEY=dev_secret_key
|
restart: unless-stopped
|
||||||
- DATABASE_URL=postgresql://platform_dev_user:platform_dev_password@db:5432/platform_dev_db
|
user: "0:0"
|
||||||
- REDIS_URL=redis://redis:6379/0
|
env_file: .env
|
||||||
ports:
|
# Daphne (ASGI): HTTP + WebSocket (/ws/notifications/, /ws/chat/, /ws/board/ и т.д.)
|
||||||
- "8124:8000"
|
command: sh -c "python manage.py migrate && daphne -b 0.0.0.0 -p 8000 config.asgi:application"
|
||||||
volumes:
|
environment:
|
||||||
- ./backend:/app
|
- DEBUG=${DEBUG:-True}
|
||||||
depends_on:
|
- SECRET_KEY=dev_secret_key
|
||||||
- db
|
- ALLOWED_HOSTS=api.uchill.online,app.uchill.online,uchill.online,www.uchill.online,localhost,127.0.0.1,85.192.56.185
|
||||||
- redis
|
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||||
networks:
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- dev_network
|
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||||
nginx:
|
# Явно передаём переменные почты из .env (иначе контейнер может не видеть их)
|
||||||
image: nginx:alpine
|
- EMAIL_BACKEND=${EMAIL_BACKEND:-smtp}
|
||||||
container_name: platform_dev_nginx
|
- EMAIL_HOST=${EMAIL_HOST}
|
||||||
restart: unless-stopped
|
- EMAIL_PORT=${EMAIL_PORT:-2525}
|
||||||
ports:
|
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-True}
|
||||||
- "8081:80"
|
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-False}
|
||||||
volumes:
|
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
||||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
|
||||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||||
depends_on:
|
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
|
||||||
- web
|
# Ссылки в письмах (сброс пароля, подтверждение, приглашения) — без localhost
|
||||||
networks:
|
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
|
||||||
- dev_network
|
# Telegram бот (профиль: bot-info, привязка аккаунта)
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
front_material:
|
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||||
build:
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||||
context: ./front_material
|
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||||
dockerfile: Dockerfile
|
ports:
|
||||||
container_name: platform_dev_front_material
|
- "8123:8000"
|
||||||
restart: unless-stopped
|
volumes:
|
||||||
ports:
|
- ./backend:/app
|
||||||
- "3002:3000"
|
depends_on:
|
||||||
networks:
|
- db
|
||||||
- dev_network
|
- redis
|
||||||
|
networks:
|
||||||
yjs-whiteboard:
|
- dev_network
|
||||||
build:
|
|
||||||
context: ./yjs-whiteboard-server
|
celery:
|
||||||
dockerfile: Dockerfile
|
build:
|
||||||
container_name: platform_dev_yjs_whiteboard
|
context: ./backend
|
||||||
restart: unless-stopped
|
dockerfile: Dockerfile
|
||||||
ports:
|
container_name: platform_prod_celery
|
||||||
- "1235:1234"
|
restart: unless-stopped
|
||||||
networks:
|
user: "0:0"
|
||||||
- dev_network
|
env_file: .env
|
||||||
|
command: celery -A config worker -l info
|
||||||
excalidraw:
|
environment:
|
||||||
build:
|
- DEBUG=${DEBUG:-True}
|
||||||
context: ./excalidraw-server
|
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||||
dockerfile: Dockerfile
|
- REDIS_URL=redis://redis:6379/0
|
||||||
container_name: platform_dev_excalidraw
|
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
restart: unless-stopped
|
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||||
ports:
|
- EMAIL_BACKEND=${EMAIL_BACKEND:-smtp}
|
||||||
- "3003:3001"
|
- EMAIL_HOST=${EMAIL_HOST}
|
||||||
networks:
|
- EMAIL_PORT=${EMAIL_PORT:-2525}
|
||||||
- dev_network
|
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-True}
|
||||||
|
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-False}
|
||||||
whiteboard:
|
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
||||||
build:
|
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
|
||||||
context: ./whiteboard-server
|
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||||
dockerfile: Dockerfile
|
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
|
||||||
container_name: platform_dev_whiteboard
|
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
|
||||||
restart: unless-stopped
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
ports:
|
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||||
- "8082:8080"
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||||
networks:
|
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||||
- dev_network
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
volumes:
|
depends_on:
|
||||||
dev_postgres_data:
|
- db
|
||||||
dev_redis_data:
|
- redis
|
||||||
|
- web
|
||||||
networks:
|
networks:
|
||||||
dev_network:
|
- dev_network
|
||||||
driver: bridge
|
|
||||||
|
celery-beat:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: platform_prod_celery_beat
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "0:0"
|
||||||
|
env_file: .env
|
||||||
|
command: celery -A config beat -l info
|
||||||
|
environment:
|
||||||
|
- DEBUG=${DEBUG:-True}
|
||||||
|
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||||
|
- EMAIL_BACKEND=${EMAIL_BACKEND:-smtp}
|
||||||
|
- EMAIL_HOST=${EMAIL_HOST}
|
||||||
|
- EMAIL_PORT=${EMAIL_PORT:-2525}
|
||||||
|
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-True}
|
||||||
|
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-False}
|
||||||
|
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
||||||
|
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
|
||||||
|
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||||
|
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
|
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||||
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||||
|
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
- web
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
# Telegram бот (polling): получает /start, /link <код> и т.д. Если используете webhook — не поднимайте этот сервис.
|
||||||
|
telegram-bot:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: platform_prod_telegram_bot
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "0:0"
|
||||||
|
env_file: .env
|
||||||
|
command: python manage.py runtelegrambot
|
||||||
|
environment:
|
||||||
|
- DEBUG=${DEBUG:-True}
|
||||||
|
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
|
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||||
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||||
|
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
- web
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
livekit:
|
||||||
|
image: livekit/livekit-server:latest
|
||||||
|
container_name: platform_prod_livekit
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- LIVEKIT_KEYS=${LIVEKIT_API_KEY:-APIKeyPlatform2024Secret}:${LIVEKIT_API_SECRET:-ThisIsAVerySecureSecretKeyForPlatform2024VideoConf}
|
||||||
|
ports:
|
||||||
|
- "7880:7880"
|
||||||
|
- "7881:7881"
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: platform_prod_nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8084:80"
|
||||||
|
volumes:
|
||||||
|
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
front_material:
|
||||||
|
build:
|
||||||
|
context: ./front_material
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: development
|
||||||
|
container_name: platform_prod_front_material
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- WATCHPACK_POLLING=true
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
volumes:
|
||||||
|
- ./front_material:/app
|
||||||
|
- front_material_node_modules:/app/node_modules
|
||||||
|
- front_material_next:/app/.next
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
yjs-whiteboard:
|
||||||
|
build:
|
||||||
|
context: ./yjs-whiteboard-server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: platform_prod_yjs_whiteboard
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1236:1234"
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
excalidraw:
|
||||||
|
build:
|
||||||
|
context: ./excalidraw-server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: platform_prod_excalidraw
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3004:3001"
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
whiteboard:
|
||||||
|
build:
|
||||||
|
context: ./whiteboard-server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: platform_prod_whiteboard
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8083:8080"
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prod_postgres_data:
|
||||||
|
prod_redis_data:
|
||||||
|
front_material_node_modules:
|
||||||
|
front_material_next:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dev_network:
|
||||||
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["server_key"] = "5c6ba6f0e2c106400cff5d5ede6b92d54cb4c542";
|
||||||
|
["salt"] = "8dfb94a5-1800-4a6b-9759-d471a4195051";
|
||||||
|
["iteration_count"] = 10000;
|
||||||
|
["stored_key"] = "ead64b3f6ed500ec594070710ec1164acf72de28";
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["stored_key"] = "bc4b65242478d384b81701604c4ca2a9caf4f49f";
|
||||||
|
["server_key"] = "3dab6265e494481c98ed07016c3362383f5e18af";
|
||||||
|
["salt"] = "612f6256-9917-4ff6-b0ae-f166417342fc";
|
||||||
|
["iteration_count"] = 10000;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
return {
|
||||||
|
[false] = {
|
||||||
|
["pending"] = {};
|
||||||
|
["version"] = 2;
|
||||||
|
};
|
||||||
|
["focus.meet.jitsi"] = {
|
||||||
|
["groups"] = {};
|
||||||
|
["subscription"] = "from";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["IOklipPaqd0e"] = {
|
||||||
|
["h"] = 20;
|
||||||
|
["t"] = 1765397837;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["86Nsc6lW_ExG"] = {
|
||||||
|
["h"] = 22;
|
||||||
|
["t"] = 1765397837;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["2MUuXJqW06wx"] = {
|
||||||
|
["h"] = 76;
|
||||||
|
["t"] = 1765397765;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["Egmzt3mnNFZm"] = {
|
||||||
|
["t"] = 1765396098;
|
||||||
|
["h"] = 391;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["HxFnTU-GZE_C"] = {
|
||||||
|
["t"] = 1765399806;
|
||||||
|
["h"] = 24;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["Zdp0KD3khbCQ"] = {
|
||||||
|
["h"] = 113;
|
||||||
|
["t"] = 1765397776;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["EBWgvXb8H8bD"] = {
|
||||||
|
["t"] = 1765396098;
|
||||||
|
["h"] = 385;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["5EclNF5SoDsw"] = {
|
||||||
|
["t"] = 1765407413;
|
||||||
|
["h"] = 2711;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
return {
|
||||||
|
["akU3gJyLh9SF"] = {
|
||||||
|
["h"] = 39;
|
||||||
|
["t"] = 1765397776;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# Nginx конфигурация для разработки
|
# Nginx в контейнере (PROD: порт 8084 на хосте).
|
||||||
|
# Upstream — по имени сервиса: web:8000, front_material:3000 (внутри сети).
|
||||||
# ==============================================
|
# ==============================================
|
||||||
|
|
||||||
# API Backend (Django) — default_server: сюда попадают запросы на localhost и api.localhost
|
# API Backend (Django) — default_server: сюда попадают запросы на localhost и api.localhost
|
||||||
|
|
@ -122,10 +123,10 @@ server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend (Next.js)
|
# Frontend (Next.js) — app.localhost и app.uchill.online
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name app.localhost;
|
server_name app.localhost app.uchill.online;
|
||||||
|
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,107 @@
|
||||||
# Multi-stage build для оптимизации размера образа
|
# Multi-stage build для оптимизации размера образа
|
||||||
|
|
||||||
# Development stage
|
# Development stage
|
||||||
FROM node:20-alpine AS development
|
FROM node:20-alpine AS development
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Аргументы сборки для Next.js (должны быть доступны во время сборки)
|
# Аргументы сборки для Next.js (должны быть доступны во время сборки)
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ARG NEXT_PUBLIC_WS_URL
|
ARG NEXT_PUBLIC_WS_URL
|
||||||
ARG NEXT_PUBLIC_LIVEKIT_URL
|
ARG NEXT_PUBLIC_LIVEKIT_URL
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||||
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
||||||
|
ENV NODE_ENV=development
|
||||||
# Копируем package files
|
ENV HOSTNAME=0.0.0.0
|
||||||
COPY package*.json ./
|
ENV WATCHPACK_POLLING=true
|
||||||
|
ENV CHOKIDAR_USEPOLLING=true
|
||||||
# Устанавливаем зависимости
|
|
||||||
RUN npm install
|
# Копируем package files
|
||||||
|
COPY package*.json ./
|
||||||
# Копируем остальные файлы
|
|
||||||
COPY . .
|
# Устанавливаем зависимости
|
||||||
|
RUN npm install
|
||||||
# Открываем порт
|
|
||||||
EXPOSE 3000
|
# Копируем остальные файлы (при запуске в compose поверх монтируется volume)
|
||||||
|
COPY . .
|
||||||
# Запускаем dev server с Turbopack
|
|
||||||
CMD ["npm", "run", "dev"]
|
# Entrypoint: при volume-монтировании /app/node_modules при первом запуске пуст — ставим зависимости
|
||||||
|
RUN echo '#!/bin/sh' > /entrypoint.sh && \
|
||||||
# Production dependencies stage
|
echo 'set -e' >> /entrypoint.sh && \
|
||||||
FROM node:20-alpine AS production-deps
|
echo 'if [ ! -d node_modules/next ] 2>/dev/null || [ ! -f node_modules/.package-lock.json ] 2>/dev/null; then npm install; fi' >> /entrypoint.sh && \
|
||||||
|
echo 'exec npx next dev --webpack --hostname 0.0.0.0' >> /entrypoint.sh && \
|
||||||
WORKDIR /app
|
chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Копируем package files
|
# Открываем порт
|
||||||
COPY package*.json ./
|
EXPOSE 3000
|
||||||
|
|
||||||
# Устанавливаем только production зависимости
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
|
||||||
|
# Production dependencies stage
|
||||||
# Production build stage
|
FROM node:20-alpine AS production-deps
|
||||||
FROM node:20-alpine AS production-build
|
|
||||||
|
WORKDIR /app
|
||||||
WORKDIR /app
|
|
||||||
|
# Копируем package files
|
||||||
# Аргументы сборки для Next.js
|
COPY package*.json ./
|
||||||
ARG NEXT_PUBLIC_API_URL
|
|
||||||
ARG NEXT_PUBLIC_WS_URL
|
# Устанавливаем только production зависимости
|
||||||
ARG NEXT_PUBLIC_LIVEKIT_URL
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
# Production build stage
|
||||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
FROM node:20-alpine AS production-build
|
||||||
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
|
||||||
|
WORKDIR /app
|
||||||
# Копируем package files
|
|
||||||
COPY package*.json ./
|
# Аргументы сборки для Next.js
|
||||||
|
ARG NEXT_PUBLIC_API_URL
|
||||||
# Устанавливаем все зависимости для сборки
|
ARG NEXT_PUBLIC_WS_URL
|
||||||
RUN npm ci
|
ARG NEXT_PUBLIC_LIVEKIT_URL
|
||||||
|
|
||||||
# Копируем исходный код
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
COPY . .
|
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||||
|
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
||||||
# Папка public может отсутствовать — Next.js работает и без неё
|
|
||||||
RUN mkdir -p public
|
# Копируем package files
|
||||||
|
COPY package*.json ./
|
||||||
# Собираем приложение с оптимизацией
|
|
||||||
ENV NODE_ENV=production
|
# Устанавливаем все зависимости для сборки
|
||||||
RUN npm run build
|
RUN npm ci
|
||||||
|
|
||||||
# Production stage
|
# Копируем исходный код
|
||||||
FROM node:20-alpine AS production
|
COPY . .
|
||||||
|
|
||||||
WORKDIR /app
|
# Папка public может отсутствовать — Next.js работает и без неё
|
||||||
|
RUN mkdir -p public
|
||||||
# Копируем собранное приложение (standalone mode)
|
|
||||||
COPY --from=production-build /app/.next/standalone ./
|
# Собираем приложение с оптимизацией
|
||||||
COPY --from=production-build /app/.next/static ./.next/static
|
ENV NODE_ENV=production
|
||||||
COPY --from=production-build /app/public ./public
|
RUN npm run build
|
||||||
|
|
||||||
# Создаем непривилегированного пользователя
|
# Production stage
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
FROM node:20-alpine AS production
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
RUN chown -R nextjs:nodejs /app
|
WORKDIR /app
|
||||||
USER nextjs
|
|
||||||
|
# Копируем собранное приложение (standalone mode)
|
||||||
# Открываем порт
|
COPY --from=production-build /app/.next/standalone ./
|
||||||
EXPOSE 3000
|
COPY --from=production-build /app/.next/static ./.next/static
|
||||||
|
COPY --from=production-build /app/public ./public
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
# Создаем непривилегированного пользователя
|
||||||
ENV HOSTNAME="0.0.0.0"
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
# Запускаем production server
|
RUN chown -R nextjs:nodejs /app
|
||||||
CMD ["node", "server.js"]
|
USER nextjs
|
||||||
|
|
||||||
|
# Открываем порт
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Запускаем production server
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,19 @@ export async function getActiveSubscription(): Promise<Subscription | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivateFreeParams {
|
||||||
|
plan_id: number;
|
||||||
|
duration_days?: number;
|
||||||
|
student_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Активировать бесплатный тариф (цена 0) без создания платежа */
|
||||||
|
export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> {
|
||||||
|
const url = '/subscriptions/subscriptions/activate_free/';
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
console.log('[API] POST', url, params);
|
||||||
|
}
|
||||||
|
const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,164 +1,176 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { login } from '@/api/auth';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { login } from '@/api/auth';
|
||||||
import { getErrorMessage } from '@/lib/error-utils';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getErrorMessage } from '@/lib/error-utils';
|
||||||
const loadMaterialComponents = async () => {
|
|
||||||
await Promise.all([
|
const loadMaterialComponents = async () => {
|
||||||
import('@material/web/textfield/filled-text-field.js'),
|
await Promise.all([
|
||||||
import('@material/web/button/filled-button.js'),
|
import('@material/web/textfield/filled-text-field.js'),
|
||||||
import('@material/web/button/text-button.js'),
|
import('@material/web/button/filled-button.js'),
|
||||||
]);
|
import('@material/web/button/text-button.js'),
|
||||||
};
|
]);
|
||||||
|
};
|
||||||
export default function LoginPage() {
|
|
||||||
const router = useRouter();
|
export default function LoginPage() {
|
||||||
const { login: authLogin } = useAuth();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState('');
|
const { login: authLogin } = useAuth();
|
||||||
const [password, setPassword] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [loading, setLoading] = useState(false);
|
||||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
const [error, setError] = useState('');
|
||||||
|
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
loadMaterialComponents()
|
useEffect(() => {
|
||||||
.then(() => setComponentsLoaded(true))
|
loadMaterialComponents()
|
||||||
.catch((err) => {
|
.then(() => setComponentsLoaded(true))
|
||||||
console.error('Error loading Material components:', err);
|
.catch((err) => {
|
||||||
setComponentsLoaded(true);
|
console.error('Error loading Material components:', err);
|
||||||
});
|
setComponentsLoaded(true);
|
||||||
}, []);
|
});
|
||||||
|
}, []);
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
setLoading(true);
|
e.preventDefault();
|
||||||
setError('');
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
|
||||||
const response = await login({ email, password });
|
try {
|
||||||
if (response.access) {
|
const response = await login({ email, password });
|
||||||
localStorage.setItem('access_token', response.access);
|
if (response.access) {
|
||||||
if (response.refresh) {
|
localStorage.setItem('access_token', response.access);
|
||||||
localStorage.setItem('refresh_token', response.refresh);
|
if (response.refresh) {
|
||||||
}
|
localStorage.setItem('refresh_token', response.refresh);
|
||||||
if (response.user) {
|
}
|
||||||
authLogin(response.access, response.user).catch(console.error);
|
if (response.user) {
|
||||||
} else {
|
authLogin(response.access, response.user).catch(console.error);
|
||||||
authLogin(response.access).catch(console.error);
|
} else {
|
||||||
}
|
authLogin(response.access).catch(console.error);
|
||||||
window.location.href = '/dashboard';
|
}
|
||||||
return;
|
window.location.href = '/dashboard';
|
||||||
} else {
|
return;
|
||||||
setError('Ошибка: токен не получен');
|
} else {
|
||||||
setLoading(false);
|
setError('Ошибка: токен не получен');
|
||||||
return;
|
setLoading(false);
|
||||||
}
|
return;
|
||||||
} catch (err: any) {
|
}
|
||||||
setError(getErrorMessage(err, 'Ошибка входа. Проверьте данные.'));
|
} catch (err: any) {
|
||||||
setLoading(false);
|
setError(getErrorMessage(err, 'Ошибка входа. Проверьте данные.'));
|
||||||
}
|
setLoading(false);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
if (!componentsLoaded) {
|
|
||||||
return (
|
if (!componentsLoaded) {
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
|
return (
|
||||||
<div
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
|
||||||
style={{
|
<div
|
||||||
width: '40px',
|
style={{
|
||||||
height: '40px',
|
width: '40px',
|
||||||
border: '3px solid #e0e0e0',
|
height: '40px',
|
||||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
border: '3px solid #e0e0e0',
|
||||||
borderRadius: '50%',
|
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||||
animation: 'spin 0.8s linear infinite',
|
borderRadius: '50%',
|
||||||
}}
|
animation: 'spin 0.8s linear infinite',
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
return (
|
|
||||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
return (
|
||||||
|
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
|
||||||
Добро пожаловать! Войдите в аккаунт.
|
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||||
</p>
|
Добро пожаловать! Войдите в аккаунт.
|
||||||
|
</p>
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<form onSubmit={handleSubmit}>
|
||||||
<md-filled-text-field
|
<div style={{ marginBottom: '20px' }}>
|
||||||
label="Email"
|
<md-filled-text-field
|
||||||
type="email"
|
label="Email"
|
||||||
value={email}
|
type="email"
|
||||||
onInput={(e: any) => setEmail(e.target.value || '')}
|
value={email}
|
||||||
required
|
onInput={(e: any) => setEmail(e.target.value || '')}
|
||||||
style={{ width: '100%' }}
|
required
|
||||||
/>
|
style={{ width: '100%' }}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<md-filled-text-field
|
<div style={{ marginBottom: '20px' }}>
|
||||||
label="Пароль"
|
<md-filled-text-field
|
||||||
type="password"
|
label="Пароль"
|
||||||
value={password}
|
type="password"
|
||||||
onInput={(e: any) => setPassword(e.target.value || '')}
|
value={password}
|
||||||
required
|
onInput={(e: any) => setPassword(e.target.value || '')}
|
||||||
style={{ width: '100%' }}
|
required
|
||||||
/>
|
style={{ width: '100%' }}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
{error && (
|
|
||||||
<div
|
{error && (
|
||||||
style={{
|
<div
|
||||||
padding: '12px 16px',
|
style={{
|
||||||
marginBottom: '20px',
|
padding: '12px 16px',
|
||||||
background: '#ffebee',
|
marginBottom: '20px',
|
||||||
color: '#c62828',
|
background: '#ffebee',
|
||||||
borderRadius: '12px',
|
color: '#c62828',
|
||||||
fontSize: '14px',
|
borderRadius: '12px',
|
||||||
lineHeight: '1.5',
|
fontSize: '14px',
|
||||||
}}
|
lineHeight: '1.5',
|
||||||
>
|
}}
|
||||||
{error}
|
>
|
||||||
</div>
|
{error}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<md-filled-button
|
|
||||||
type="submit"
|
<md-filled-button
|
||||||
disabled={loading}
|
type="submit"
|
||||||
style={{
|
disabled={loading}
|
||||||
width: '100%',
|
style={{
|
||||||
height: '48px',
|
width: '100%',
|
||||||
marginBottom: '16px',
|
height: '48px',
|
||||||
fontSize: '16px',
|
marginBottom: '16px',
|
||||||
fontWeight: '500',
|
fontSize: '16px',
|
||||||
}}
|
fontWeight: '500',
|
||||||
>
|
}}
|
||||||
{loading ? 'Вход...' : 'Войти'}
|
>
|
||||||
</md-filled-button>
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</md-filled-button>
|
||||||
<div
|
</form>
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
<div
|
||||||
marginTop: '20px',
|
style={{
|
||||||
display: 'flex',
|
textAlign: 'center',
|
||||||
flexDirection: 'column',
|
marginTop: '20px',
|
||||||
gap: '8px',
|
display: 'flex',
|
||||||
}}
|
flexDirection: 'column',
|
||||||
>
|
gap: '8px',
|
||||||
<md-text-button
|
}}
|
||||||
onClick={() => router.push('/forgot-password')}
|
>
|
||||||
style={{ fontSize: '14px' }}
|
<Link
|
||||||
>
|
href="/forgot-password"
|
||||||
Забыли пароль?
|
style={{
|
||||||
</md-text-button>
|
fontSize: '14px',
|
||||||
<md-text-button onClick={() => router.push('/register')} style={{ fontSize: '14px' }}>
|
color: 'var(--md-sys-color-primary, #6750a4)',
|
||||||
Нет аккаунта? Зарегистрироваться
|
textDecoration: 'none',
|
||||||
</md-text-button>
|
}}
|
||||||
</div>
|
>
|
||||||
</form>
|
Забыли пароль?
|
||||||
</div>
|
</Link>
|
||||||
);
|
<Link
|
||||||
}
|
href="/register"
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--md-sys-color-primary, #6750a4)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Нет аккаунта? Зарегистрироваться
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ export default function RegisterPage() {
|
||||||
const [consent, setConsent] = useState(false);
|
const [consent, setConsent] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||||
|
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||||
const roleSelectRef = useRef<HTMLElement & { value: string } | null>(null);
|
const roleSelectRef = useRef<HTMLElement & { value: string } | null>(null);
|
||||||
|
|
||||||
|
|
@ -128,7 +130,7 @@ export default function RegisterPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await register({
|
await register({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
password_confirm: confirmPassword,
|
password_confirm: confirmPassword,
|
||||||
|
|
@ -139,21 +141,10 @@ export default function RegisterPage() {
|
||||||
timezone: getTimezoneForSubmit(),
|
timezone: getTimezoneForSubmit(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.access) {
|
// Не авторизуем сразу — требуется подтверждение email
|
||||||
localStorage.setItem('access_token', response.access);
|
setRegisteredEmail(email);
|
||||||
if (response.refresh) {
|
setRegistrationSuccess(true);
|
||||||
localStorage.setItem('refresh_token', response.refresh);
|
return;
|
||||||
}
|
|
||||||
if (referralCode.trim()) {
|
|
||||||
try {
|
|
||||||
await setReferrer(referralCode.trim());
|
|
||||||
} catch (_) {
|
|
||||||
// не блокируем вход при ошибке реферального кода
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(
|
setError(
|
||||||
err.response?.data?.detail ||
|
err.response?.data?.detail ||
|
||||||
|
|
@ -185,6 +176,57 @@ export default function RegisterPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (registrationSuccess) {
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||||
|
Регистрация
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px',
|
||||||
|
background: 'var(--md-sys-color-surface-container, #f5f5f5)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: 'var(--md-sys-color-on-surface, #1a1a1a)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
На адрес <strong>{registeredEmail}</strong> отправлено письмо с ссылкой для подтверждения.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant, #666)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
marginBottom: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Перейдите по ссылке из письма, затем войдите в аккаунт.
|
||||||
|
</p>
|
||||||
|
<md-filled-button
|
||||||
|
onClick={() => router.push('/login')}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '48px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вернуться ко входу
|
||||||
|
</md-filled-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
|
import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
|
||||||
import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
|
import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
|
||||||
import { getNavBadges } from '@/api/navBadges';
|
import { getNavBadges } from '@/api/navBadges';
|
||||||
|
import { getActiveSubscription } from '@/api/subscriptions';
|
||||||
import type { NavBadges } from '@/api/navBadges';
|
import type { NavBadges } from '@/api/navBadges';
|
||||||
|
|
||||||
export default function ProtectedLayout({
|
export default function ProtectedLayout({
|
||||||
|
|
@ -21,6 +22,7 @@ export default function ProtectedLayout({
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const [navBadges, setNavBadges] = useState<NavBadges | null>(null);
|
const [navBadges, setNavBadges] = useState<NavBadges | null>(null);
|
||||||
|
const [subscriptionChecked, setSubscriptionChecked] = useState(false);
|
||||||
|
|
||||||
const refreshNavBadges = useCallback(async () => {
|
const refreshNavBadges = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -36,6 +38,26 @@ export default function ProtectedLayout({
|
||||||
refreshNavBadges();
|
refreshNavBadges();
|
||||||
}, [user, refreshNavBadges]);
|
}, [user, refreshNavBadges]);
|
||||||
|
|
||||||
|
// Для ментора: редирект на /payment, если нет активной подписки (кроме самой страницы /payment)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || user.role !== 'mentor' || pathname === '/payment') {
|
||||||
|
if (user?.role === 'mentor' && pathname === '/payment') setSubscriptionChecked(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setSubscriptionChecked(false);
|
||||||
|
getActiveSubscription()
|
||||||
|
.then((sub) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSubscriptionChecked(true);
|
||||||
|
if (!sub) router.replace('/payment');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSubscriptionChecked(true);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [user, pathname, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Проверяем токен в localStorage напрямую, чтобы избежать race condition
|
// Проверяем токен в localStorage напрямую, чтобы избежать race condition
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||||
|
|
@ -68,6 +90,17 @@ export default function ProtectedLayout({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
||||||
|
const isMentorCheckingSubscription =
|
||||||
|
user.role === 'mentor' && pathname !== '/payment' && !subscriptionChecked;
|
||||||
|
if (isMentorCheckingSubscription) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Не показываем навигацию на страницах авторизации
|
// Не показываем навигацию на страницах авторизации
|
||||||
if (pathname?.startsWith('/login') || pathname?.startsWith('/register')) {
|
if (pathname?.startsWith('/login') || pathname?.startsWith('/register')) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import apiClient from '@/lib/api-client';
|
import apiClient from '@/lib/api-client';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { activateFreeSubscription, getActiveSubscription } from '@/api/subscriptions';
|
||||||
|
|
||||||
/** Подписи преимуществ из plan.features (API) */
|
/** Подписи преимуществ из plan.features (API) */
|
||||||
const FEATURE_LABELS: Record<string, string> = {
|
const FEATURE_LABELS: Record<string, string> = {
|
||||||
|
|
@ -50,6 +51,15 @@ function getPlanDescription(plan: any): string {
|
||||||
return 'Ежемесячная подписка без ограничений по количеству учеников. Все функции доступны.';
|
return 'Ежемесячная подписка без ограничений по количеству учеников. Все функции доступны.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFreePlan(plan: any): boolean {
|
||||||
|
const price = Number(plan.price) || 0;
|
||||||
|
const pricePerStudent = Number(plan.price_per_student) ?? 0;
|
||||||
|
if (plan.subscription_type === 'per_student') {
|
||||||
|
return pricePerStudent === 0;
|
||||||
|
}
|
||||||
|
return price === 0;
|
||||||
|
}
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||||
<path d="M16.7071 5.29289C17.0976 5.68342 17.0976 6.31658 16.7071 6.70711L8.70711 14.7071C8.31658 15.0976 7.68342 15.0976 7.29289 14.7071L3.29289 10.7071C2.90237 10.3166 2.90237 9.68342 3.29289 9.29289C3.68342 8.90237 4.31658 8.90237 4.70711 9.29289L8 12.5858L15.2929 5.29289C15.6834 4.90237 16.3166 4.90237 16.7071 5.29289Z" fill="currentColor" />
|
<path d="M16.7071 5.29289C17.0976 5.68342 17.0976 6.31658 16.7071 6.70711L8.70711 14.7071C8.31658 15.0976 7.68342 15.0976 7.29289 14.7071L3.29289 10.7071C2.90237 10.3166 2.90237 9.68342 3.29289 9.29289C3.68342 8.90237 4.31658 8.90237 4.70711 9.29289L8 12.5858L15.2929 5.29289C15.6834 4.90237 16.3166 4.90237 16.7071 5.29289Z" fill="currentColor" />
|
||||||
|
|
@ -60,24 +70,60 @@ export function ProfilePaymentTab() {
|
||||||
const [plans, setPlans] = useState<any[]>([]);
|
const [plans, setPlans] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [subscription, setSubscription] = useState<any>(null);
|
const [subscription, setSubscription] = useState<any>(null);
|
||||||
|
const [activatingPlanId, setActivatingPlanId] = useState<number | null>(null);
|
||||||
|
const [activateError, setActivateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [plansRes, subRes] = await Promise.all([
|
||||||
|
apiClient.get<any>('/subscriptions/plans/').then((r) => r.data?.results || r.data || []),
|
||||||
|
getActiveSubscription(),
|
||||||
|
]);
|
||||||
|
setPlans(Array.isArray(plansRes) ? plansRes : []);
|
||||||
|
setSubscription(subRes);
|
||||||
|
} catch {
|
||||||
|
setPlans([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
loadData();
|
||||||
try {
|
}, [loadData]);
|
||||||
const [plansRes, subRes] = await Promise.all([
|
|
||||||
apiClient.get<any>('/subscriptions/plans/').then((r) => r.data?.results || r.data || []),
|
const handleActivateFree = async (plan: any) => {
|
||||||
apiClient.get<any>('/subscriptions/subscriptions/active/').then((r) => r.data).catch(() => null),
|
setActivateError(null);
|
||||||
]);
|
setActivatingPlanId(plan.id);
|
||||||
setPlans(Array.isArray(plansRes) ? plansRes : []);
|
const body = {
|
||||||
setSubscription(subRes);
|
plan_id: plan.id,
|
||||||
} catch {
|
duration_days: 30,
|
||||||
setPlans([]);
|
student_count: plan.subscription_type === 'per_student' ? 1 : undefined,
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
load();
|
if (typeof window !== 'undefined') {
|
||||||
}, []);
|
console.log('[Subscription] Отправка запроса activate_free:', body);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await activateFreeSubscription(body);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
console.log('[Subscription] activate_free успешно');
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch (err: any) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
console.log('[Subscription] activate_free ошибка:', err.response?.status, err.response?.data);
|
||||||
|
}
|
||||||
|
const data = err.response?.data;
|
||||||
|
const message =
|
||||||
|
(typeof data?.error === 'string' && data.error) ||
|
||||||
|
(typeof data?.detail === 'string' && data.detail) ||
|
||||||
|
(Array.isArray(data?.detail) ? data.detail[0] : null) ||
|
||||||
|
'Не удалось активировать подписку';
|
||||||
|
setActivateError(message);
|
||||||
|
} finally {
|
||||||
|
setActivatingPlanId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingSpinner size="medium" />;
|
return <LoadingSpinner size="medium" />;
|
||||||
|
|
@ -104,11 +150,17 @@ export function ProfilePaymentTab() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activateError && (
|
||||||
|
<p style={{ color: 'var(--md-sys-color-error)', fontSize: 14, marginBottom: 12 }}>
|
||||||
|
{activateError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<span className="ios26-payment-tab__label">ТАРИФНЫЕ ПЛАНЫ</span>
|
<span className="ios26-payment-tab__label">ТАРИФНЫЕ ПЛАНЫ</span>
|
||||||
<div className="ios26-plan-card-grid">
|
<div className="ios26-plan-card-grid">
|
||||||
{plans.slice(0, 5).map((plan: any) => {
|
{plans.slice(0, 5).map((plan: any) => {
|
||||||
const benefits = getBenefitList(plan);
|
const benefits = getBenefitList(plan);
|
||||||
const description = getPlanDescription(plan);
|
const description = getPlanDescription(plan);
|
||||||
|
const free = isFreePlan(plan);
|
||||||
const priceText = plan.price_per_student
|
const priceText = plan.price_per_student
|
||||||
? `${Math.round(plan.price_per_student || 0).toLocaleString('ru-RU')} ₽/уч.`
|
? `${Math.round(plan.price_per_student || 0).toLocaleString('ru-RU')} ₽/уч.`
|
||||||
: `${Math.round(plan.price || 0).toLocaleString('ru-RU')} ₽/мес`;
|
: `${Math.round(plan.price || 0).toLocaleString('ru-RU')} ₽/мес`;
|
||||||
|
|
@ -135,9 +187,21 @@ export function ProfilePaymentTab() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="ios26-plan-card__actions">
|
<div className="ios26-plan-card__actions">
|
||||||
<Link href="/payment" className="ios26-plan-card__action">
|
{free ? (
|
||||||
Подробнее и оплатить
|
<button
|
||||||
</Link>
|
type="button"
|
||||||
|
className="ios26-plan-card__action"
|
||||||
|
onClick={() => handleActivateFree(plan)}
|
||||||
|
disabled={!!activatingPlanId}
|
||||||
|
style={{ cursor: activatingPlanId ? 'wait' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{activatingPlanId === plan.id ? 'Активация...' : 'Активировать'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link href="/payment" className="ios26-plan-card__action">
|
||||||
|
Подробнее и оплатить
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,13 @@ const nextConfig = {
|
||||||
|
|
||||||
// Оптимизация webpack
|
// Оптимизация webpack
|
||||||
webpack: (config, { dev, isServer }) => {
|
webpack: (config, { dev, isServer }) => {
|
||||||
// Hot reload для development
|
// Hot reload для development (в т.ч. в Docker с volume)
|
||||||
if (dev) {
|
if (dev) {
|
||||||
config.watchOptions = {
|
config.watchOptions = {
|
||||||
...config.watchOptions,
|
...config.watchOptions,
|
||||||
poll: 1000,
|
poll: process.env.WATCHPACK_POLLING ? 500 : 1000,
|
||||||
aggregateTimeout: 300,
|
aggregateTimeout: 300,
|
||||||
|
ignored: ['**/node_modules', '**/.git'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue