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
|
|
@ -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'))
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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