Daphne (ASGI) для WebSocket, подписки, email, регистрация, mentor gate, telegram-bot
Deploy to Dev / deploy-dev (push) Failing after 3s Details
Deploy to Production / deploy-production (push) Successful in 26s Details

This commit is contained in:
root 2026-02-13 02:58:25 +03:00
parent e3517b39ff
commit 6420a9b182
28 changed files with 2984 additions and 1237 deletions

104
backend/BACKEND.md Normal file
View File

@ -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 часто не нужен при обычном перезапуске.

View File

@ -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 и пароль приложения '
'(Настройки → Безопасность → Пароли для внешних приложений).'
)

View File

@ -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)

View File

@ -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):
""" """

View File

@ -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 = []
# Префиксы типов населенных пунктов, которые нужно убирать при поиске # Префиксы типов населенных пунктов, которые нужно убирать при поиске

File diff suppressed because it is too large Load Diff

View File

@ -494,12 +494,17 @@ else:
# SMTP настройки (используются только если EMAIL_BACKEND=smtp) # SMTP настройки (используются только если EMAIL_BACKEND=smtp)
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.gmail.com') EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.gmail.com')
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587')) EMAIL_PORT = int(os.getenv('EMAIL_PORT', '2525'))
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'True') == 'True' EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'True') == 'True'
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'False') == 'True' # Для порта 465 EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'False') == 'True' # Для порта 465
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'noreply@platform.com') _default_from = os.getenv('DEFAULT_FROM_EMAIL', '').strip()
# Для Mail.ru и др.: From должен совпадать с ящиком SMTP, иначе письма не доходят или уходят в спам
if EMAIL_HOST_USER and (not _default_from or _default_from == 'noreply@platform.com'):
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
else:
DEFAULT_FROM_EMAIL = _default_from or 'noreply@platform.com'
# Таймауты для отправки email # Таймауты для отправки email
EMAIL_TIMEOUT = int(os.getenv('EMAIL_TIMEOUT', '10')) EMAIL_TIMEOUT = int(os.getenv('EMAIL_TIMEOUT', '10'))

View File

@ -1,31 +1,35 @@
# ============================================== # ==============================================
# Docker Compose для DEV окружения # Docker Compose PROD (порты не пересекаются с dev на одном хосте)
# ============================================== # ==============================================
# Порты на хосте (prod): db 5434, redis 6381, web 8123, nginx 8084,
# front_material 3010, yjs 1236, excalidraw 3004, whiteboard 8083,
# livekit 7880/7881, celery/beat — без портов (внутренние)
# Dev использует: 5433, 6380, 8124, 8081, 3002, 1235, 3003, 8082, livekit 7890/7891
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: platform_dev_db container_name: platform_prod_db
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: platform_dev_db POSTGRES_DB: platform_prod_db
POSTGRES_USER: platform_dev_user POSTGRES_USER: platform_prod_user
POSTGRES_PASSWORD: platform_dev_password POSTGRES_PASSWORD: platform_prod_password
ports: ports:
- "5433:5432" - "5434:5432"
volumes: volumes:
- dev_postgres_data:/var/lib/postgresql/data - prod_postgres_data:/var/lib/postgresql/data
networks: networks:
- dev_network - dev_network
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: platform_dev_redis container_name: platform_prod_redis
restart: unless-stopped restart: unless-stopped
ports: ports:
- "6380:6379" - "6381:6379"
volumes: volumes:
- dev_redis_data:/data - prod_redis_data:/data
networks: networks:
- dev_network - dev_network
@ -33,16 +37,39 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: platform_dev_web container_name: platform_prod_web
restart: unless-stopped restart: unless-stopped
command: sh -c "python manage.py migrate && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 2" user: "0:0"
env_file: .env
# Daphne (ASGI): HTTP + WebSocket (/ws/notifications/, /ws/chat/, /ws/board/ и т.д.)
command: sh -c "python manage.py migrate && daphne -b 0.0.0.0 -p 8000 config.asgi:application"
environment: environment:
- DEBUG=True - DEBUG=${DEBUG:-True}
- SECRET_KEY=dev_secret_key - SECRET_KEY=dev_secret_key
- DATABASE_URL=postgresql://platform_dev_user:platform_dev_password@db:5432/platform_dev_db - ALLOWED_HOSTS=api.uchill.online,app.uchill.online,uchill.online,www.uchill.online,localhost,127.0.0.1,85.192.56.185
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/1
- CELERY_RESULT_BACKEND=redis://redis:6379/2
# Явно передаём переменные почты из .env (иначе контейнер может не видеть их)
- 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}
# Ссылки в письмах (сброс пароля, подтверждение, приглашения) — без localhost
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
# Telegram бот (профиль: bot-info, привязка аккаунта)
- 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:-}
ports: ports:
- "8124:8000" - "8123:8000"
volumes: volumes:
- ./backend:/app - ./backend:/app
depends_on: depends_on:
@ -51,12 +78,128 @@ services:
networks: networks:
- dev_network - dev_network
celery:
build:
context: ./backend
dockerfile: Dockerfile
container_name: platform_prod_celery
restart: unless-stopped
user: "0:0"
env_file: .env
command: celery -A config worker -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
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: nginx:
image: nginx:alpine image: nginx:alpine
container_name: platform_dev_nginx container_name: platform_prod_nginx
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8081:80" - "8084:80"
volumes: volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro - ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
@ -69,10 +212,21 @@ services:
build: build:
context: ./front_material context: ./front_material
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: platform_dev_front_material target: development
container_name: platform_prod_front_material
restart: unless-stopped restart: unless-stopped
env_file: .env
environment:
- NODE_ENV=development
- WATCHPACK_POLLING=true
- HOSTNAME=0.0.0.0
- CHOKIDAR_USEPOLLING=true
ports: ports:
- "3002:3000" - "3010:3000"
volumes:
- ./front_material:/app
- front_material_node_modules:/app/node_modules
- front_material_next:/app/.next
networks: networks:
- dev_network - dev_network
@ -80,10 +234,10 @@ services:
build: build:
context: ./yjs-whiteboard-server context: ./yjs-whiteboard-server
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: platform_dev_yjs_whiteboard container_name: platform_prod_yjs_whiteboard
restart: unless-stopped restart: unless-stopped
ports: ports:
- "1235:1234" - "1236:1234"
networks: networks:
- dev_network - dev_network
@ -91,10 +245,10 @@ services:
build: build:
context: ./excalidraw-server context: ./excalidraw-server
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: platform_dev_excalidraw container_name: platform_prod_excalidraw
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3003:3001" - "3004:3001"
networks: networks:
- dev_network - dev_network
@ -102,16 +256,18 @@ services:
build: build:
context: ./whiteboard-server context: ./whiteboard-server
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: platform_dev_whiteboard container_name: platform_prod_whiteboard
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8082:8080" - "8083:8080"
networks: networks:
- dev_network - dev_network
volumes: volumes:
dev_postgres_data: prod_postgres_data:
dev_redis_data: prod_redis_data:
front_material_node_modules:
front_material_next:
networks: networks:
dev_network: dev_network:

View File

@ -0,0 +1,6 @@
return {
["server_key"] = "5c6ba6f0e2c106400cff5d5ede6b92d54cb4c542";
["salt"] = "8dfb94a5-1800-4a6b-9759-d471a4195051";
["iteration_count"] = 10000;
["stored_key"] = "ead64b3f6ed500ec594070710ec1164acf72de28";
};

View File

@ -0,0 +1,6 @@
return {
["stored_key"] = "bc4b65242478d384b81701604c4ca2a9caf4f49f";
["server_key"] = "3dab6265e494481c98ed07016c3362383f5e18af";
["salt"] = "612f6256-9917-4ff6-b0ae-f166417342fc";
["iteration_count"] = 10000;
};

View File

@ -0,0 +1,10 @@
return {
[false] = {
["pending"] = {};
["version"] = 2;
};
["focus.meet.jitsi"] = {
["groups"] = {};
["subscription"] = "from";
};
};

View File

@ -0,0 +1,6 @@
return {
["IOklipPaqd0e"] = {
["h"] = 20;
["t"] = 1765397837;
};
};

View File

@ -0,0 +1,6 @@
return {
["86Nsc6lW_ExG"] = {
["h"] = 22;
["t"] = 1765397837;
};
};

View File

@ -0,0 +1,6 @@
return {
["2MUuXJqW06wx"] = {
["h"] = 76;
["t"] = 1765397765;
};
};

View File

@ -0,0 +1,6 @@
return {
["Egmzt3mnNFZm"] = {
["t"] = 1765396098;
["h"] = 391;
};
};

View File

@ -0,0 +1,6 @@
return {
["HxFnTU-GZE_C"] = {
["t"] = 1765399806;
["h"] = 24;
};
};

View File

@ -0,0 +1,6 @@
return {
["Zdp0KD3khbCQ"] = {
["h"] = 113;
["t"] = 1765397776;
};
};

View File

@ -0,0 +1,6 @@
return {
["EBWgvXb8H8bD"] = {
["t"] = 1765396098;
["h"] = 385;
};
};

View File

@ -0,0 +1,6 @@
return {
["5EclNF5SoDsw"] = {
["t"] = 1765407413;
["h"] = 2711;
};
};

View File

@ -0,0 +1,6 @@
return {
["akU3gJyLh9SF"] = {
["h"] = 39;
["t"] = 1765397776;
};
};

View File

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

View File

@ -13,6 +13,10 @@ 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
ENV HOSTNAME=0.0.0.0
ENV WATCHPACK_POLLING=true
ENV CHOKIDAR_USEPOLLING=true
# Копируем package files # Копируем package files
COPY package*.json ./ COPY package*.json ./
@ -20,14 +24,20 @@ COPY package*.json ./
# Устанавливаем зависимости # Устанавливаем зависимости
RUN npm install RUN npm install
# Копируем остальные файлы # Копируем остальные файлы (при запуске в compose поверх монтируется volume)
COPY . . COPY . .
# Entrypoint: при volume-монтировании /app/node_modules при первом запуске пуст — ставим зависимости
RUN echo '#!/bin/sh' > /entrypoint.sh && \
echo 'set -e' >> /entrypoint.sh && \
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 && \
chmod +x /entrypoint.sh
# Открываем порт # Открываем порт
EXPOSE 3000 EXPOSE 3000
# Запускаем dev server с Turbopack ENTRYPOINT ["/entrypoint.sh"]
CMD ["npm", "run", "dev"]
# Production dependencies stage # Production dependencies stage
FROM node:20-alpine AS production-deps FROM node:20-alpine AS production-deps

View File

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

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { login } from '@/api/auth'; import { login } from '@/api/auth';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getErrorMessage } from '@/lib/error-utils'; import { getErrorMessage } from '@/lib/error-utils';
@ -138,27 +139,38 @@ export default function LoginPage() {
> >
{loading ? 'Вход...' : 'Войти'} {loading ? 'Вход...' : 'Войти'}
</md-filled-button> </md-filled-button>
</form>
<div <div
style={{
textAlign: 'center',
marginTop: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<Link
href="/forgot-password"
style={{ style={{
textAlign: 'center', fontSize: '14px',
marginTop: '20px', color: 'var(--md-sys-color-primary, #6750a4)',
display: 'flex', textDecoration: 'none',
flexDirection: 'column',
gap: '8px',
}} }}
> >
<md-text-button Забыли пароль?
onClick={() => router.push('/forgot-password')} </Link>
style={{ fontSize: '14px' }} <Link
> href="/register"
Забыли пароль? 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> Нет аккаунта? Зарегистрироваться
</Link>
</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -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'],
}; };
} }