Compare commits

..

No commits in common. "e49fa9e746c5d68409031bdb82908db7f58fbeed" and "17bed2b321a26e427e32925ee4bd2c5c5c2a83a8" have entirely different histories.

379 changed files with 2599 additions and 23177 deletions

View File

@ -37,19 +37,14 @@ class ChatService:
with transaction.atomic(): with transaction.atomic():
# Ищем существующий чат между пользователями # Ищем существующий чат между пользователями
# select_for_update() несовместим с distinct() в PostgreSQL, # Используем select_for_update для блокировки найденных записей
# поэтому сначала ищем без блокировки, затем лочим найденный объект existing_chat = Chat.objects.select_for_update().filter(
existing_chat = Chat.objects.filter(
chat_type='direct', chat_type='direct',
participants__user=users[0] participants__user=users[0]
).filter( ).filter(
participants__user=users[1] participants__user=users[1]
).distinct().first() ).distinct().first()
if existing_chat:
# Лочим конкретную запись для безопасного возврата
existing_chat = Chat.objects.select_for_update().get(pk=existing_chat.pk)
if existing_chat: if existing_chat:
return existing_chat, False return existing_chat, False

View File

@ -1,38 +0,0 @@
from celery import shared_task
from django.utils import timezone
from django.db import transaction
@shared_task
def process_pending_referral_bonuses():
"""Ежедневная обработка отложенных реферальных бонусов."""
from .models import PendingReferralBonus, UserActivityDay, UserReferralProfile
now = timezone.now()
paid_count = 0
for pending in PendingReferralBonus.objects.filter(
status=PendingReferralBonus.STATUS_PENDING
).select_related('referrer', 'referred_user'):
referred_at = pending.referred_at
active_days = UserActivityDay.objects.filter(
user=pending.referred_user,
date__gte=referred_at.date(),
).count()
days_since = (now - referred_at).days
if (days_since >= 30 and active_days >= 20) or active_days >= 21:
try:
with transaction.atomic():
profile = pending.referrer.referral_profile
profile.add_points(
pending.points,
reason=pending.reason or f'Реферал {pending.referred_user.email} выполнил условия'
)
pending.status = PendingReferralBonus.STATUS_PAID
pending.paid_at = now
pending.save(update_fields=['status', 'paid_at'])
paid_count += 1
except Exception:
pass
return f'Начислено бонусов: {paid_count}'

View File

@ -131,6 +131,14 @@ class LessonSerializer(serializers.ModelSerializer):
start_time = attrs.get('start_time') start_time = attrs.get('start_time')
duration = attrs.get('duration', 60) duration = attrs.get('duration', 60)
# Проверка: допускаем создание занятий до 30 минут в прошлом
now = timezone.now()
tolerance = timedelta(minutes=30)
if start_time and start_time < now - tolerance:
raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
})
# Проверка конфликтов (только при создании или изменении времени) # Проверка конфликтов (только при создании или изменении времени)
if self.instance is None or 'start_time' in attrs: if self.instance is None or 'start_time' in attrs:
mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None
@ -204,10 +212,6 @@ class LessonCreateSerializer(serializers.ModelSerializer):
'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price', 'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price',
'is_recurring' 'is_recurring'
] ]
extra_kwargs = {
'client': {'required': False, 'allow_null': True},
'group': {'required': False, 'allow_null': True},
}
def to_internal_value(self, data): def to_internal_value(self, data):
""" """
@ -303,12 +307,6 @@ class LessonCreateSerializer(serializers.ModelSerializer):
duration = attrs.get('duration', 60) duration = attrs.get('duration', 60)
mentor = attrs.get('mentor') mentor = attrs.get('mentor')
client = attrs.get('client') client = attrs.get('client')
group = attrs.get('group')
if not client and not group:
raise serializers.ValidationError({
'client': 'Необходимо указать ученика или группу.'
})
# Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name # Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name
# subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject' # subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject'
@ -382,15 +380,16 @@ class LessonCreateSerializer(serializers.ModelSerializer):
attrs['mentor_subject'] = mentor_subject attrs['mentor_subject'] = mentor_subject
attrs['subject_name'] = mentor_subject.name attrs['subject_name'] = mentor_subject.name
# Нормализуем start_time к UTC # Проверка: допускаем создание занятий до 30 минут в прошлом
if start_time: if start_time:
if not django_timezone.is_aware(start_time): if not django_timezone.is_aware(start_time):
start_time = pytz.UTC.localize(start_time) start_time = pytz.UTC.localize(start_time)
elif start_time.tzinfo != pytz.UTC: elif start_time.tzinfo != pytz.UTC:
start_time = start_time.astimezone(pytz.UTC) start_time = start_time.astimezone(pytz.UTC)
# Проверяем что занятие не начинается более 30 минут назад now = django_timezone.now()
if start_time < django_timezone.now() - timedelta(minutes=30): tolerance = timedelta(minutes=30)
if start_time < now - tolerance:
raise serializers.ValidationError({ raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад' 'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
}) })
@ -649,7 +648,6 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря.""" """Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True) client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True) mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
group_name = serializers.CharField(source='group.name', read_only=True, default=None)
subject = serializers.SerializerMethodField() subject = serializers.SerializerMethodField()
def get_subject(self, obj): def get_subject(self, obj):
@ -673,7 +671,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Lesson model = Lesson
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'group', 'group_name', 'subject', 'subject_name'] fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name']
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer): class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):

View File

@ -101,13 +101,10 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
'fields': ('name', 'slug', 'description') 'fields': ('name', 'slug', 'description')
}), }),
('Стоимость', { ('Стоимость', {
'fields': ('price', 'price_per_student', 'currency', 'duration_days', 'subscription_type', 'trial_days'), 'fields': ('price', 'price_per_student', 'currency', 'billing_period', 'subscription_type', 'trial_days'),
'description': 'Укажите "Период оплаты (дней)" — именно столько дней будет действовать подписка (например: 30, 60, 90, 180, 365).' 'description': 'Для типа "За ученика" укажите price_per_student. Для ежемесячной подписки - price. '
}), 'Прогрессирующие скидки настраиваются ниже в разделе "Прогрессирующие скидки". '
('Устаревшие настройки', { 'Доступные периоды оплаты определяются через скидки за длительность (см. раздел ниже).'
'fields': ('billing_period',),
'classes': ('collapse',),
'description': 'Устаревшее поле. Используйте "Период оплаты (дней)" выше.'
}), }),
('Целевая аудитория', { ('Целевая аудитория', {
'fields': ('target_role',), 'fields': ('target_role',),
@ -156,11 +153,17 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
price_display.short_description = 'Цена' price_display.short_description = 'Цена'
def billing_period_display(self, obj): def billing_period_display(self, obj):
"""Отображение периода в днях.""" """Отображение периода."""
days = obj.get_duration_days() colors = {
'monthly': '#17a2b8',
'quarterly': '#28a745',
'yearly': '#ffc107',
'lifetime': '#6610f2'
}
return format_html( return format_html(
'<span style="background-color: #17a2b8; color: white; padding: 3px 10px; border-radius: 3px;">{} дн.</span>', '<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
days colors.get(obj.billing_period, '#000'),
obj.get_billing_period_display()
) )
billing_period_display.short_description = 'Период' billing_period_display.short_description = 'Период'

View File

@ -1,24 +0,0 @@
# Generated by Django 4.2.7 on 2026-03-12 20:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("subscriptions", "0011_add_target_role_to_subscription_plan"),
]
operations = [
migrations.AddField(
model_name="subscriptionplan",
name="duration_days",
field=models.IntegerField(
blank=True,
help_text='Количество дней действия подписки. Если указано, имеет приоритет над "Периодом оплаты".',
null=True,
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="Длительность (дней)",
),
),
]

View File

@ -234,8 +234,7 @@ class SubscriptionPlan(models.Model):
max_length=20, max_length=20,
choices=BILLING_PERIOD_CHOICES, choices=BILLING_PERIOD_CHOICES,
default='monthly', default='monthly',
verbose_name='Период оплаты (устарело)', verbose_name='Период оплаты'
help_text='Устаревшее поле. Используйте "Период оплаты (дней)" ниже.'
) )
subscription_type = models.CharField( subscription_type = models.CharField(
@ -255,14 +254,6 @@ class SubscriptionPlan(models.Model):
help_text='Используется для типа "За ученика"' help_text='Используется для типа "За ученика"'
) )
duration_days = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1)],
verbose_name='Период оплаты (дней)',
help_text='Количество дней действия подписки, например: 30, 60, 90, 180, 365.'
)
trial_days = models.IntegerField( trial_days = models.IntegerField(
default=0, default=0,
validators=[MinValueValidator(0)], validators=[MinValueValidator(0)],
@ -435,12 +426,16 @@ class SubscriptionPlan(models.Model):
def get_duration_days(self, custom_days=None): def get_duration_days(self, custom_days=None):
""" """
Получить длительность подписки в днях. Получить длительность подписки в днях.
Приоритет: custom_days duration_days (поле модели) billing_period.
Args:
custom_days: кастомная длительность в днях (30, 90, 180, 365)
Returns:
int: количество дней
""" """
if custom_days: if custom_days:
return custom_days return custom_days
if self.duration_days:
return self.duration_days
if self.billing_period == 'monthly': if self.billing_period == 'monthly':
return 30 return 30
elif self.billing_period == 'quarterly': elif self.billing_period == 'quarterly':
@ -448,7 +443,7 @@ class SubscriptionPlan(models.Model):
elif self.billing_period == 'yearly': elif self.billing_period == 'yearly':
return 365 return 365
elif self.billing_period == 'lifetime': elif self.billing_period == 'lifetime':
return 36500 return 36500 # 100 лет
return 30 return 30
def get_available_durations(self): def get_available_durations(self):

View File

@ -202,7 +202,7 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
plan_id = serializers.IntegerField() plan_id = serializers.IntegerField()
student_count = serializers.IntegerField(default=0, min_value=0) student_count = serializers.IntegerField(default=0, min_value=0)
duration_days = serializers.IntegerField(required=False, allow_null=True, min_value=1) duration_days = serializers.IntegerField(default=30, min_value=1)
promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True) promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True)
start_date = serializers.DateTimeField(required=False, allow_null=True) start_date = serializers.DateTimeField(required=False, allow_null=True)
@ -219,9 +219,26 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
return value return value
def validate_duration_days(self, value): def validate_duration_days(self, value):
"""Валидация длительности — любое положительное число дней.""" """Валидация длительности."""
if value is not None and value < 1: allowed_durations = [30, 90, 180, 365]
raise serializers.ValidationError('Длительность должна быть не менее 1 дня') if value not in allowed_durations:
raise serializers.ValidationError(
f'Длительность должна быть одним из значений: {", ".join(map(str, allowed_durations))}'
)
# Проверяем доступность периода для плана
plan_id = self.initial_data.get('plan_id')
if plan_id:
try:
plan = SubscriptionPlan.objects.get(id=plan_id)
if not plan.is_duration_available(value):
available = plan.get_available_durations()
raise serializers.ValidationError(
f'Период {value} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}'
)
except SubscriptionPlan.DoesNotExist:
pass
return value return value
def validate_student_count(self, value): def validate_student_count(self, value):
@ -240,10 +257,6 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
except SubscriptionPlan.DoesNotExist: except SubscriptionPlan.DoesNotExist:
raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'}) raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'})
# Если duration_days не передан — берём из плана
if not attrs.get('duration_days'):
attrs['duration_days'] = plan.get_duration_days()
# Проверяем доступность тарифа (акция) # Проверяем доступность тарифа (акция)
user = self.context.get('request').user if self.context.get('request') else None user = self.context.get('request').user if self.context.get('request') else None
can_use, error_message = plan.can_be_used(user) can_use, error_message = plan.can_be_used(user)

View File

@ -326,11 +326,15 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'}, {'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
requested_days = request.data.get('duration_days') duration_days = int(request.data.get('duration_days', 30))
duration_days = int(requested_days) if requested_days else plan.get_duration_days()
student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0 student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0
if st == 'per_student' and student_count <= 0: if st == 'per_student' and student_count <= 0:
student_count = 1 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: try:
subscription = SubscriptionService.create_subscription( subscription = SubscriptionService.create_subscription(
user=request.user, user=request.user,
@ -784,8 +788,9 @@ class PaymentViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
else: else:
# По умолчанию используем duration_days из тарифного плана # По умолчанию используем первый доступный период или 30 дней
duration_days = plan.get_duration_days() available_durations = plan.get_available_durations()
duration_days = available_durations[0] if available_durations else 30
# Определяем количество учеников для тарифов "за ученика" # Определяем количество учеников для тарифов "за ученика"
if plan.subscription_type == 'per_student': if plan.subscription_type == 'per_student':

View File

@ -73,7 +73,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
# Занятия - оптимизация: используем aggregate для всех подсчетов # Занятия - оптимизация: используем aggregate для всех подсчетов
from django.db.models import Count, Sum, Q from django.db.models import Count, Sum, Q
lessons = Lesson.objects.filter(mentor=user.id).select_related( lessons = Lesson.objects.filter(mentor=user.id).select_related(
'mentor', 'client', 'client__user', 'subject', 'mentor_subject', 'group' 'mentor', 'client', 'client__user', 'subject', 'mentor_subject'
) )
# Один запрос для всех подсчетов занятий # Один запрос для всех подсчетов занятий
@ -89,9 +89,9 @@ class MentorDashboardViewSet(viewsets.ViewSet):
lessons_this_month = lessons_stats['this_month'] lessons_this_month = lessons_stats['this_month']
completed_lessons = lessons_stats['completed'] completed_lessons = lessons_stats['completed']
# Ближайшие занятия (включая начавшиеся в последние 90 мин, чтобы отображать кнопку «Подключиться») # Ближайшие занятия
upcoming_lessons = lessons.filter( upcoming_lessons = lessons.filter(
start_time__gte=now - timedelta(minutes=90), start_time__gte=now,
status__in=['scheduled', 'in_progress'] status__in=['scheduled', 'in_progress']
).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5] ).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5]
@ -163,12 +163,6 @@ class MentorDashboardViewSet(viewsets.ViewSet):
'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None, 'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None,
'first_name': lesson.client.user.first_name if lesson.client.user else '', 'first_name': lesson.client.user.first_name if lesson.client.user else '',
'last_name': lesson.client.user.last_name if lesson.client.user else '' 'last_name': lesson.client.user.last_name if lesson.client.user else ''
} if lesson.client_id else {
'id': None,
'name': lesson.group.name if lesson.group_id else 'Группа',
'avatar': None,
'first_name': '',
'last_name': ''
}, },
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None, 'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None 'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
@ -595,7 +589,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
'target_name': ( 'target_name': (
item['group__name'] item['group__name']
if item['group__name'] if item['group__name']
else f"{item['client__user__last_name']} {item['client__user__first_name']}".strip() or 'Ученик' else f"{item['client__user__first_name']} {item['client__user__last_name']}".strip() or 'Ученик'
), ),
'lessons_count': item['lessons_count'], 'lessons_count': item['lessons_count'],
'total_income': float(item['total_income']), 'total_income': float(item['total_income']),
@ -655,9 +649,9 @@ class ClientDashboardViewSet(viewsets.ViewSet):
completed_lessons = lessons_stats['completed'] completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week'] lessons_this_week = lessons_stats['this_week']
# Ближайшие занятия с оптимизацией (включая начавшиеся в последние 90 мин) # Ближайшие занятия с оптимизацией
upcoming_lessons = lessons.filter( upcoming_lessons = lessons.filter(
start_time__gte=now - timedelta(minutes=90), start_time__gte=now,
status__in=['scheduled', 'in_progress'] status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5] ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
@ -713,11 +707,8 @@ class ClientDashboardViewSet(viewsets.ViewSet):
'title': lesson.title, 'title': lesson.title,
'mentor': { 'mentor': {
'id': lesson.mentor.id, 'id': lesson.mentor.id,
'first_name': lesson.mentor.first_name, 'name': lesson.mentor.get_full_name()
'last_name': lesson.mentor.last_name, },
'name': lesson.mentor.get_full_name(),
'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
} if lesson.mentor_id else None,
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None, 'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None 'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
} }
@ -725,6 +716,8 @@ class ClientDashboardViewSet(viewsets.ViewSet):
] ]
} }
# Сохраняем в кеш на 2 минуты (120 секунд)
# Кеш на 30 секунд для актуальности уведомлений
cache.set(cache_key, response_data, 30) cache.set(cache_key, response_data, 30)
return Response(response_data) return Response(response_data)
@ -1188,9 +1181,9 @@ class ParentDashboardViewSet(viewsets.ViewSet):
completed_lessons = lessons_stats['completed'] completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week'] lessons_this_week = lessons_stats['this_week']
# Ближайшие занятия (включая начавшиеся в последние 90 мин) # Ближайшие занятия
upcoming_lessons = lessons.filter( upcoming_lessons = lessons.filter(
start_time__gte=now - timedelta(minutes=90), start_time__gte=now,
status__in=['scheduled', 'in_progress'] status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5] ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
@ -1246,11 +1239,8 @@ class ParentDashboardViewSet(viewsets.ViewSet):
'title': lesson.title, 'title': lesson.title,
'mentor': { 'mentor': {
'id': lesson.mentor.id, 'id': lesson.mentor.id,
'first_name': lesson.mentor.first_name, 'name': lesson.mentor.get_full_name()
'last_name': lesson.mentor.last_name, },
'name': lesson.mentor.get_full_name(),
'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
} if lesson.mentor_id else None,
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None, 'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None 'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
} }
@ -1258,6 +1248,7 @@ class ParentDashboardViewSet(viewsets.ViewSet):
] ]
} }
# Сохраняем в кеш на 2 минуты (120 секунд)
cache.set(cache_key, response_data, 30) cache.set(cache_key, response_data, 30)
return Response(response_data) return Response(response_data)

View File

@ -15,7 +15,6 @@ from apps.board.models import Board
def _apply_connection(conn): def _apply_connection(conn):
"""После принятия связи: добавить ментора к студенту, создать доску.""" """После принятия связи: добавить ментора к студенту, создать доску."""
from django.core.cache import cache
student_user = conn.student student_user = conn.student
mentor = conn.mentor mentor = conn.mentor
try: try:
@ -39,10 +38,6 @@ def _apply_connection(conn):
if conn.status != MentorStudentConnection.STATUS_ACCEPTED: if conn.status != MentorStudentConnection.STATUS_ACCEPTED:
conn.status = MentorStudentConnection.STATUS_ACCEPTED conn.status = MentorStudentConnection.STATUS_ACCEPTED
conn.save(update_fields=['status', 'updated_at']) conn.save(update_fields=['status', 'updated_at'])
# Инвалидируем кэш списка студентов ментора
for page in range(1, 6):
for page_size in [10, 20, 50]:
cache.delete(f'manage_clients_{mentor.id}_{page}_{page_size}')
class MentorshipRequestViewSet(viewsets.ViewSet): class MentorshipRequestViewSet(viewsets.ViewSet):

View File

@ -1,24 +0,0 @@
# Generated by Django 4.2.7 on 2026-03-11 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0011_add_onboarding_tours_seen"),
]
operations = [
migrations.AlterField(
model_name="user",
name="universal_code",
field=models.CharField(
blank=True,
help_text="8-символьный код (цифры и латинские буквы) для добавления ученика ментором",
max_length=8,
null=True,
unique=True,
verbose_name="Универсальный код",
),
),
]

View File

@ -1,68 +0,0 @@
# Generated by Django 4.2.7 on 2026-03-12 14:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("users", "0012_add_group_to_board"),
]
operations = [
migrations.CreateModel(
name="InvitationLink",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"token",
models.CharField(
db_index=True, max_length=64, unique=True, verbose_name="Токен"
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
),
(
"is_banned",
models.BooleanField(default=False, verbose_name="Забанена"),
),
(
"mentor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitation_links",
to=settings.AUTH_USER_MODEL,
verbose_name="Ментор",
),
),
(
"used_by",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="registered_via_link",
to=settings.AUTH_USER_MODEL,
verbose_name="Использована пользователем",
),
),
],
options={
"verbose_name": "Ссылка-приглашение",
"verbose_name_plural": "Ссылки-приглашения",
"db_table": "invitation_links",
"ordering": ["-created_at"],
},
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 4.2.7 on 2026-03-12 21:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0013_invitation_link_model"),
]
operations = [
migrations.AlterField(
model_name="mentorstudentconnection",
name="status",
field=models.CharField(
choices=[
("pending_mentor", "Ожидает ответа ментора"),
("pending_student", "Ожидает подтверждения студента"),
("pending_parent", "Ожидает подтверждения родителя"),
("accepted", "Принято"),
("rejected", "Отклонено"),
("removed", "Удалено"),
],
db_index=True,
max_length=20,
verbose_name="Статус",
),
),
]

View File

@ -567,7 +567,6 @@ class MentorStudentConnection(models.Model):
STATUS_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя STATUS_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя
STATUS_ACCEPTED = 'accepted' STATUS_ACCEPTED = 'accepted'
STATUS_REJECTED = 'rejected' STATUS_REJECTED = 'rejected'
STATUS_REMOVED = 'removed'
STATUS_CHOICES = [ STATUS_CHOICES = [
(STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'), (STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'),
@ -575,7 +574,6 @@ class MentorStudentConnection(models.Model):
(STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'), (STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'),
(STATUS_ACCEPTED, 'Принято'), (STATUS_ACCEPTED, 'Принято'),
(STATUS_REJECTED, 'Отклонено'), (STATUS_REJECTED, 'Отклонено'),
(STATUS_REMOVED, 'Удалено'),
] ]
INITIATOR_STUDENT = 'student' INITIATOR_STUDENT = 'student'
INITIATOR_MENTOR = 'mentor' INITIATOR_MENTOR = 'mentor'
@ -694,49 +692,3 @@ class Group(models.Model):
def __str__(self): def __str__(self):
return f"{self.name} (ментор: {self.mentor.get_full_name()})" return f"{self.name} (ментор: {self.mentor.get_full_name()})"
class InvitationLink(models.Model):
"""
Ссылка-приглашение от ментора для регистрации ученика.
- Каждая ссылка действует 12 часов с момента создания
- Одна ссылка один ученик (used_by)
- Несколько ссылок могут быть активны одновременно
- По истечении 12 часов ссылка помечается is_banned=True и не может быть использована
"""
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='Токен')
mentor = models.ForeignKey(
'User',
on_delete=models.CASCADE,
related_name='invitation_links',
verbose_name='Ментор',
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создана')
used_by = models.OneToOneField(
'User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='registered_via_link',
verbose_name='Использована пользователем',
)
is_banned = models.BooleanField(default=False, verbose_name='Забанена')
class Meta:
db_table = 'invitation_links'
verbose_name = 'Ссылка-приглашение'
verbose_name_plural = 'Ссылки-приглашения'
ordering = ['-created_at']
def __str__(self):
return f"InvitationLink({self.mentor.email}, {self.token[:8]}...)"
@property
def is_expired(self):
from django.utils import timezone as tz
from datetime import timedelta
return tz.now() > self.created_at + timedelta(hours=12)
@property
def is_valid(self):
return not self.is_banned and not self.is_expired and self.used_by_id is None

View File

@ -608,7 +608,7 @@ class ProfileViewSet(viewsets.ViewSet):
continue continue
return Response(timezones_data) return Response(timezones_data)
@action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny], authentication_classes=[]) @action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny])
def search_cities_from_csv(self, request): def search_cities_from_csv(self, request):
""" """
Поиск городов из city.csv по запросу. Поиск городов из city.csv по запросу.
@ -921,8 +921,16 @@ class ClientManagementViewSet(viewsets.ViewSet):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Кеширование: кеш на 5 минут для каждого пользователя и страницы
# Увеличено с 2 до 5 минут для ускорения повторных загрузок страницы "Студенты"
page = int(request.query_params.get('page', 1)) page = int(request.query_params.get('page', 1))
page_size = int(request.query_params.get('page_size', 20)) page_size = int(request.query_params.get('page_size', 20))
cache_key = f'manage_clients_{user.id}_{page}_{page_size}'
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
# ВАЖНО: оптимизация страницы "Студенты" # ВАЖНО: оптимизация страницы "Студенты"
# Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1). # Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1).
@ -1018,6 +1026,9 @@ class ClientManagementViewSet(viewsets.ViewSet):
for inv in pending for inv in pending
] ]
# Сохраняем в кеш на 5 минут (300 секунд) для ускорения повторных загрузок
cache.set(cache_key, response_data.data, 300)
return response_data return response_data
@action(detail=False, methods=['get'], url_path='check-user') @action(detail=False, methods=['get'], url_path='check-user')
@ -1138,7 +1149,7 @@ class ClientManagementViewSet(viewsets.ViewSet):
defaults={ defaults={
'status': MentorStudentConnection.STATUS_PENDING_STUDENT, 'status': MentorStudentConnection.STATUS_PENDING_STUDENT,
'initiator': MentorStudentConnection.INITIATOR_MENTOR, 'initiator': MentorStudentConnection.INITIATOR_MENTOR,
'confirm_token': secrets.token_urlsafe(32), 'confirm_token': secrets.token_urlsafe(32) if is_new_user or True else None,
} }
) )
if not created: if not created:
@ -1150,13 +1161,6 @@ class ClientManagementViewSet(viewsets.ViewSet):
'message': 'Приглашение уже отправлено, ожидайте подтверждения', 'message': 'Приглашение уже отправлено, ожидайте подтверждения',
'invitation_id': conn.id, 'invitation_id': conn.id,
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
# Связь была удалена — повторно отправляем приглашение
if conn.status == MentorStudentConnection.STATUS_REMOVED:
conn.status = MentorStudentConnection.STATUS_PENDING_STUDENT
conn.initiator = MentorStudentConnection.INITIATOR_MENTOR
conn.confirm_token = secrets.token_urlsafe(32)
conn.student_confirmed_at = None
conn.save(update_fields=['status', 'initiator', 'confirm_token', 'student_confirmed_at', 'updated_at'])
if not conn.confirm_token: if not conn.confirm_token:
conn.confirm_token = secrets.token_urlsafe(32) conn.confirm_token = secrets.token_urlsafe(32)
@ -1227,38 +1231,17 @@ class ClientManagementViewSet(viewsets.ViewSet):
future_lessons_count = future_lessons.count() future_lessons_count = future_lessons.count()
future_lessons.delete() future_lessons.delete()
# Убираем доступ к доскам (не удаляем доски, только убираем участника) # Удаляем ментора (это автоматически запретит доступ ко всем материалам)
from apps.board.models import Board
boards = Board.objects.filter(mentor=user, student=client.user)
for board in boards:
board.participants.remove(client.user)
# Закрываем активную связь
MentorStudentConnection.objects.filter(
mentor=user,
student=client.user,
status=MentorStudentConnection.STATUS_ACCEPTED
).update(status='removed')
# Удаляем ментора (убирает доступ к материалам)
client.mentors.remove(user) client.mentors.remove(user)
# Уведомление ученику # Инвалидируем кеш списка клиентов для этого ментора
NotificationService.create_notification_with_telegram( # Удаляем все варианты кеша для этого пользователя (разные страницы и размеры)
recipient=client.user,
notification_type='system',
title='Ментор завершил сотрудничество',
message=f'Ментор {user.get_full_name()} удалил вас из своего списка учеников. '
f'Будущие занятия отменены. Доступ к доскам приостановлен (при восстановлении связи — вернётся).',
data={'mentor_id': user.id}
)
for page in range(1, 10): for page in range(1, 10):
for size in [10, 20, 50, 100, 1000]: for size in [10, 20, 50, 100, 1000]:
cache.delete(f'manage_clients_{user.id}_{page}_{size}') cache.delete(f'manage_clients_{user.id}_{page}_{size}')
return Response({ return Response({
'message': 'Клиент успешно удалён', 'message': 'Клиент успешно удален',
'future_lessons_deleted': future_lessons_count 'future_lessons_deleted': future_lessons_count
}) })
@ -1270,114 +1253,23 @@ class ClientManagementViewSet(viewsets.ViewSet):
@action(detail=False, methods=['post'], url_path='generate-invitation-link') @action(detail=False, methods=['post'], url_path='generate-invitation-link')
def generate_invitation_link(self, request): def generate_invitation_link(self, request):
""" """
Создать новую ссылку-приглашение (12 часов, 1 использование). Сгенерировать или обновить токен ссылки-приглашения.
Старые ссылки остаются действительными. POST /api/users/manage/clients/generate-invitation-link/
POST /api/manage/clients/generate-invitation-link/
""" """
from django.utils import timezone as tz
from datetime import timedelta
from .models import InvitationLink
user = request.user user = request.user
if user.role != 'mentor': if user.role != 'mentor':
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN) return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
# Истекаем просроченные ссылки этого ментора user.invitation_link_token = secrets.token_urlsafe(32)
expire_before = tz.now() - timedelta(hours=12) user.save(update_fields=['invitation_link_token'])
InvitationLink.objects.filter(
mentor=user,
is_banned=False,
used_by__isnull=True,
created_at__lt=expire_before,
).update(is_banned=True)
token = secrets.token_urlsafe(32)
inv = InvitationLink.objects.create(mentor=user, token=token)
from django.conf import settings from django.conf import settings
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
link = f"{frontend_url}/invite/{token}" link = f"{frontend_url}/invite/{user.invitation_link_token}"
return Response({ return Response({
'invitation_link_token': token, 'invitation_link_token': user.invitation_link_token,
'invitation_link': link, 'invitation_link': link
'expires_at': (inv.created_at + timedelta(hours=12)).isoformat(),
})
class StudentMentorViewSet(viewsets.ViewSet):
"""
Действия студента в отношении своих менторов.
remove_mentor: студент удаляет ментора из своего списка.
"""
permission_classes = [IsAuthenticated]
@action(detail=True, methods=['delete'], url_path='remove')
def remove_mentor(self, request, pk=None):
"""
Студент удаляет ментора.
DELETE /api/student/mentors/{mentor_id}/remove/
"""
from django.utils import timezone
from apps.schedule.models import Lesson
from apps.board.models import Board
user = request.user
if user.role not in ('client', 'parent'):
return Response({'error': 'Только для учеников'}, status=status.HTTP_403_FORBIDDEN)
try:
mentor = User.objects.get(id=pk, role='mentor')
except User.DoesNotExist:
return Response({'error': 'Ментор не найден'}, status=status.HTTP_404_NOT_FOUND)
try:
client = user.client_profile
except Client.DoesNotExist:
return Response({'error': 'Профиль ученика не найден'}, status=status.HTTP_404_NOT_FOUND)
if mentor not in client.mentors.all():
return Response({'error': 'Ментор не связан с вами'}, status=status.HTTP_400_BAD_REQUEST)
# Удаляем будущие занятия
now = timezone.now()
future_lessons = Lesson.objects.filter(
mentor=mentor,
client=client,
start_time__gt=now,
status='scheduled'
)
future_lessons_count = future_lessons.count()
future_lessons.delete()
# Убираем доступ к доскам
boards = Board.objects.filter(mentor=mentor, student=user)
for board in boards:
board.participants.remove(user)
# Закрываем активную связь
MentorStudentConnection.objects.filter(
mentor=mentor,
student=user,
status=MentorStudentConnection.STATUS_ACCEPTED
).update(status='removed')
# Удаляем ментора из профиля
client.mentors.remove(mentor)
# Уведомление ментору
NotificationService.create_notification_with_telegram(
recipient=mentor,
notification_type='system',
title='Ученик завершил сотрудничество',
message=f'Ученик {user.get_full_name()} удалил вас из своего списка менторов. '
f'Будущие занятия отменены. Доступ к доскам приостановлен.',
data={'student_id': user.id}
)
return Response({
'message': 'Ментор успешно удалён',
'future_lessons_deleted': future_lessons_count
}) })
@ -1561,36 +1453,19 @@ class InvitationViewSet(viewsets.ViewSet):
Получить информацию о менторе по токену ссылки-приглашения. Получить информацию о менторе по токену ссылки-приглашения.
GET /api/invitation/info-by-token/?token=... GET /api/invitation/info-by-token/?token=...
""" """
from django.utils import timezone as tz
from datetime import timedelta
from .models import InvitationLink
token = request.query_params.get('token') token = request.query_params.get('token')
if not token: if not token:
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
try: try:
inv = InvitationLink.objects.select_related('mentor').get(token=token) mentor = User.objects.get(invitation_link_token=token, role='mentor')
except InvitationLink.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
if inv.is_banned:
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
if inv.is_expired:
inv.is_banned = True
inv.save(update_fields=['is_banned'])
return Response({'error': 'Ссылка истекла'}, status=status.HTTP_410_GONE)
if inv.used_by_id is not None:
return Response({'error': 'Ссылка уже использована'}, status=status.HTTP_410_GONE)
mentor = inv.mentor
expires_at = inv.created_at + timedelta(hours=12)
return Response({ return Response({
'mentor_name': mentor.get_full_name(), 'mentor_name': mentor.get_full_name(),
'mentor_id': mentor.id, 'mentor_id': mentor.id,
'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None, 'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
'expires_at': expires_at.isoformat(),
}) })
except User.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny]) @action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny])
def register_by_link(self, request): def register_by_link(self, request):
@ -1607,8 +1482,6 @@ class InvitationViewSet(viewsets.ViewSet):
"city": "..." "city": "..."
} }
""" """
from .models import InvitationLink
token = request.data.get('token') token = request.data.get('token')
first_name = request.data.get('first_name') first_name = request.data.get('first_name')
last_name = request.data.get('last_name') last_name = request.data.get('last_name')
@ -1621,21 +1494,10 @@ class InvitationViewSet(viewsets.ViewSet):
return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST)
try: try:
inv = InvitationLink.objects.select_related('mentor').get(token=token) mentor = User.objects.get(invitation_link_token=token, role='mentor')
except InvitationLink.DoesNotExist: except User.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
if inv.is_banned:
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
if inv.is_expired:
inv.is_banned = True
inv.save(update_fields=['is_banned'])
return Response({'error': 'Срок действия ссылки истёк'}, status=status.HTTP_410_GONE)
if inv.used_by_id is not None:
return Response({'error': 'Ссылка уже была использована'}, status=status.HTTP_410_GONE)
mentor = inv.mentor
# Если email указан, проверяем его уникальность # Если email указан, проверяем его уникальность
if email: if email:
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
@ -1661,10 +1523,6 @@ class InvitationViewSet(viewsets.ViewSet):
student_user.login_token = secrets.token_urlsafe(32) student_user.login_token = secrets.token_urlsafe(32)
student_user.save(update_fields=['login_token']) student_user.save(update_fields=['login_token'])
# Помечаем ссылку как использованную
inv.used_by = student_user
inv.save(update_fields=['used_by'])
# Создаем профиль клиента # Создаем профиль клиента
client = Client.objects.create(user=student_user) client = Client.objects.create(user=student_user)
@ -1743,55 +1601,34 @@ class ParentManagementViewSet(viewsets.ViewSet):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Поддержка: universal_code (8 симв.) / child_email / email # Поддержка старого формата (child_email) и нового (email + данные)
universal_code = (request.data.get('universal_code') or '').strip().upper()
child_email = request.data.get('child_email') or request.data.get('email') child_email = request.data.get('child_email') or request.data.get('email')
if not universal_code and not child_email: if not child_email:
return Response( return Response(
{'error': 'Необходимо указать 8-значный код ребенка или его email'}, {'error': 'Необходимо указать email ребенка'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Нормализуем email
child_email = child_email.lower().strip()
# Получаем или создаем профиль родителя # Получаем или создаем профиль родителя
parent, _ = Parent.objects.get_or_create(user=user) parent, created = Parent.objects.get_or_create(user=user)
# Ищем пользователя
created = False created = False
if universal_code:
# --- Поиск по universal_code ---
allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
valid_8 = len(universal_code) == 8 and all(c in allowed for c in universal_code)
valid_6_legacy = len(universal_code) == 6 and universal_code.isdigit()
if not (valid_8 or valid_6_legacy):
return Response(
{'error': 'Код должен содержать 8 символов (буквы и цифры)'},
status=status.HTTP_400_BAD_REQUEST
)
try:
child_user = User.objects.get(universal_code=universal_code)
except User.DoesNotExist:
return Response(
{'error': 'Пользователь с таким кодом не найден'},
status=status.HTTP_404_NOT_FOUND
)
if child_user.role != 'client':
return Response(
{'error': 'Пользователь с этим кодом не является учеником (client)'},
status=status.HTTP_400_BAD_REQUEST
)
else:
# --- Поиск / создание по email ---
child_email = child_email.lower().strip()
try: try:
child_user = User.objects.get(email=child_email) child_user = User.objects.get(email=child_email)
# Если пользователь существует, проверяем что это клиент
if child_user.role != 'client': if child_user.role != 'client':
return Response( return Response(
{'error': 'Пользователь с таким email не является учеником (client)'}, {'error': 'Пользователь с таким email уже существует, но не является клиентом'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
except User.DoesNotExist: except User.DoesNotExist:
created = True created = True
# Создаем нового пользователя-клиента
first_name = request.data.get('first_name', '').strip() first_name = request.data.get('first_name', '').strip()
last_name = request.data.get('last_name', '').strip() last_name = request.data.get('last_name', '').strip()
phone = request.data.get('phone', '').strip() phone = request.data.get('phone', '').strip()
@ -1802,7 +1639,9 @@ class ParentManagementViewSet(viewsets.ViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Создаем пользователя с временным паролем
temp_password = secrets.token_urlsafe(12) temp_password = secrets.token_urlsafe(12)
child_user = User.objects.create_user( child_user = User.objects.create_user(
email=child_email, email=child_email,
password=temp_password, password=temp_password,
@ -1810,11 +1649,15 @@ class ParentManagementViewSet(viewsets.ViewSet):
last_name=last_name, last_name=last_name,
phone=normalize_phone(phone) if phone else '', phone=normalize_phone(phone) if phone else '',
role='client', role='client',
email_verified=True, email_verified=True, # Email автоматически подтвержден при добавлении родителем
) )
# Генерируем токен для установки пароля
reset_token = secrets.token_urlsafe(32) reset_token = secrets.token_urlsafe(32)
child_user.email_verification_token = reset_token child_user.email_verification_token = reset_token
child_user.save() child_user.save()
# Отправляем приветственное письмо со ссылкой на установку пароля
send_student_welcome_email_task.delay(child_user.id, reset_token) send_student_welcome_email_task.delay(child_user.id, reset_token)
# Получаем или создаем профиль клиента # Получаем или создаем профиль клиента

View File

@ -130,17 +130,6 @@ class RegisterSerializer(serializers.ModelSerializer):
"""Нормализация email в нижний регистр.""" """Нормализация email в нижний регистр."""
return value.lower().strip() if value else value return value.lower().strip() if value else value
def validate_timezone(self, value):
"""Проверяем что timezone — валидный IANA идентификатор."""
if not value:
return 'Europe/Moscow'
import zoneinfo
try:
zoneinfo.ZoneInfo(value)
return value
except Exception:
return 'Europe/Moscow'
def validate(self, attrs): def validate(self, attrs):
"""Проверка совпадения паролей.""" """Проверка совпадения паролей."""
if attrs.get('password') != attrs.get('password_confirm'): if attrs.get('password') != attrs.get('password_confirm'):

View File

@ -1,11 +1,11 @@
""" """
Сигналы для пользователей. Сигналы для пользователей.
""" """
from django.db.models.signals import post_save, post_delete, m2m_changed from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.core.cache import cache from django.core.cache import cache
from .models import MentorStudentConnection, Group from .models import MentorStudentConnection
def _invalidate_manage_clients_cache(mentor_id): def _invalidate_manage_clients_cache(mentor_id):
@ -28,24 +28,3 @@ def mentor_student_connection_changed(sender, instance, created, **kwargs):
if instance.mentor_id: if instance.mentor_id:
_invalidate_manage_clients_cache(instance.mentor_id) _invalidate_manage_clients_cache(instance.mentor_id)
@receiver(post_save, sender=Group)
def group_saved(sender, instance, created, **kwargs):
"""При создании/обновлении группы — синхронизировать групповой чат."""
try:
from apps.chat.services import ChatService
ChatService.get_or_create_group_chat(instance)
except Exception:
pass
@receiver(m2m_changed, sender=Group.students.through)
def group_students_changed(sender, instance, action, **kwargs):
"""При изменении участников группы — синхронизировать участников чата."""
if action in ('post_add', 'post_remove', 'post_clear'):
try:
from apps.chat.services import ChatService
ChatService.get_or_create_group_chat(instance)
except Exception:
pass

View File

@ -25,7 +25,6 @@ def send_welcome_email_task(user_id):
context = { context = {
'user_full_name': user.get_full_name() or user.email, 'user_full_name': user.get_full_name() or user.email,
'user_email': user.email, 'user_email': user.email,
'login_url': f"{settings.FRONTEND_URL}/auth/jwt/sign-in",
} }
# Загружаем HTML и текстовые шаблоны # Загружаем HTML и текстовые шаблоны
@ -61,7 +60,7 @@ def send_verification_email_task(user_id, verification_token):
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
# URL для подтверждения # URL для подтверждения
verification_url = f"{settings.FRONTEND_URL}/auth/jwt/verify-email?token={verification_token}" verification_url = f"{settings.FRONTEND_URL}/verify-email?token={verification_token}"
subject = 'Подтвердите ваш email' subject = 'Подтвердите ваш email'
@ -103,7 +102,7 @@ def send_password_reset_email_task(user_id, reset_token):
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
# URL для сброса пароля # URL для сброса пароля
reset_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}" reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
subject = 'Восстановление пароля' subject = 'Восстановление пароля'
@ -145,7 +144,7 @@ def send_student_welcome_email_task(user_id, reset_token):
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
# URL для установки пароля # URL для установки пароля
set_password_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}" set_password_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
subject = 'Добро пожаловать на платформу!' subject = 'Добро пожаловать на платформу!'

View File

@ -4,42 +4,61 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{% block title %}Училл{% endblock %}</title> <title>{% block title %}Uchill{% endblock %}</title>
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]--> <!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"> <body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;"> <!-- Wrapper table -->
<tr><td align="center" style="padding:40px 16px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<!-- Card -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;"> <td align="center" style="padding: 40px 20px;">
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span> <!-- Main content table -->
<br> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span> <!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Body --> <!-- Content block -->
<tr> <tr>
<td style="background:#ffffff;padding:40px;"> <td style="padding: 0 40px 40px 40px;">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;"> <td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p> <tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© {% now "Y" %} Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td></tr> </td>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,111 +4,167 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Приглашение от ментора — Училл</title> <title>Приглашение от ментора - Uchill</title>
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]--> <!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"> <body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;"> <!-- Wrapper table -->
<tr><td align="center" style="padding:40px 16px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;"> <td align="center" style="padding: 40px 20px;">
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br> <!-- Main content table -->
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Body --> <!-- Content -->
<tr> <tr>
<td style="background:#ffffff;padding:48px 40px 40px;"> <td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Приглашение от ментора</h1>
</td>
</tr>
<!-- Icon --> <!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;"> <tr>
<tr><td style="background:#EEE8FF;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;"> <td style="padding-bottom: 24px;">
<span style="font-size:36px;line-height:72px;">🎓</span> <p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
</td></tr>
</table>
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Вас приглашают учиться!</h1>
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Личное приглашение от ментора</p>
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
Здравствуйте! Здравствуйте!
</p> </p>
</td>
</tr>
<!-- Mentor highlight --> <!-- Main message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:linear-gradient(135deg,#F5F0FF,#EEE8FF);border:1px solid #DDD6FE;border-radius:12px;margin-bottom:24px;"> <tr>
<tr><td style="padding:20px 24px;"> <td style="padding-bottom: 24px;">
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ментор</p> <p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
<p style="margin:0;font-size:20px;font-weight:700;color:#111827;">{{ mentor_name }}</p> <strong style="color: #7444FD;">{{ mentor_name }}</strong> приглашает вас в качестве ученика на платформу Uchill.
<p style="margin:4px 0 0 0;font-size:14px;color:#6B7280;">приглашает вас на платформу <strong style="color:#7444FD;">Училл</strong></p> </p>
</td></tr> </td>
</table> </tr>
{% if set_password_url %} {% if set_password_url %}
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;"> <!-- New user flow -->
Для начала занятий установите пароль и подтвердите приглашение — это займёт меньше минуты. <tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Для начала работы установите пароль и подтвердите приглашение.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;"> <td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<a href="{{ set_password_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
Принять приглашение <tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Установить пароль и подтвердить
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;"> <tr>
<tr><td style="padding:16px;"> <td style="padding-bottom: 24px;">
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ set_password_url }}</p> <tr>
</td></tr> <td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ set_password_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
{% elif confirm_url %} {% elif confirm_url %}
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;"> <!-- Existing user flow -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Подтвердите приглашение, чтобы начать занятия с ментором. Подтвердите приглашение, чтобы начать занятия с ментором.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;"> <td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<a href="{{ confirm_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ confirm_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Подтвердить приглашение Подтвердить приглашение
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;"> <tr>
<tr><td style="padding:16px;"> <td style="padding-bottom: 24px;">
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ confirm_url }}</p> <tr>
</td></tr> <td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ confirm_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
{% endif %} {% endif %}
</table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;"> <td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p> <tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td></tr> </td>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,93 +4,143 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Восстановление пароля — Училл</title> <title>Восстановление пароля - Uchill</title>
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]--> <!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"> <body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;"> <!-- Wrapper table -->
<tr><td align="center" style="padding:40px 16px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;"> <td align="center" style="padding: 40px 20px;">
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br> <!-- Main content table -->
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Body --> <!-- Content -->
<tr> <tr>
<td style="background:#ffffff;padding:48px 40px 40px;"> <td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Восстановление пароля</h1>
</td>
</tr>
<!-- Icon --> <!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;"> <tr>
<tr><td style="background:#FEF3C7;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;"> <td style="padding-bottom: 24px;">
<span style="font-size:36px;line-height:72px;">🔐</span> <p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
</td></tr> Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
</table>
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Восстановление пароля</h1>
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Мы получили запрос на сброс пароля</p>
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
</p> </p>
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;"> </td>
Нажмите на кнопку ниже, чтобы установить новый пароль для вашего аккаунта. </tr>
<!-- Main message -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Вы запросили восстановление пароля для вашего аккаунта. Нажмите на кнопку ниже, чтобы установить новый пароль.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;"> <td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<a href="{{ reset_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
Установить новый пароль <tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ reset_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Восстановить пароль
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:24px;"> <tr>
<tr><td style="padding:16px;"> <td style="padding-bottom: 24px;">
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p> <p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;">
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ reset_url }}</p> Или скопируйте и вставьте эту ссылку в браузер:
</td></tr>
</table>
<!-- Warning -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#FFFBEB;border-left:4px solid #F59E0B;border-radius:8px;margin-bottom:24px;">
<tr><td style="padding:14px 16px;">
<p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
<strong>Важно:</strong> ссылка действительна в течение 24 часов.
</p> </p>
</td></tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<tr>
<td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ reset_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Warning box -->
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FFF3E0; border-left: 4px solid #FF9800; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0; font-size: 14px; color: #E65100;">
<strong>⚠️ Важно:</strong> Ссылка действительна в течение 24 часов.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Security notice --> <!-- Security notice -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;"> <tr>
<tr><td style="padding:14px 16px;"> <td style="padding-top: 24px; border-top: 1px solid #E0E0E0;">
<p style="margin:0;font-size:13px;color:#6B7280;line-height:1.6;"> <p style="margin: 0; font-size: 12px; color: #9E9E9E; line-height: 1.6;">
Если вы не запрашивали восстановление пароля — просто проигнорируйте это письмо. Ваш пароль останется без изменений. Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений.
</p> </p>
</td></tr> </td>
</tr>
</table> </table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;"> <td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p> <tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td></tr> </td>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,92 +4,152 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Добро пожаловать на Училл</title> <title>Добро пожаловать на Uchill</title>
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]--> <!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"> <body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;"> <!-- Wrapper table -->
<tr><td align="center" style="padding:40px 16px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;"> <td align="center" style="padding: 40px 20px;">
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br> <!-- Main content table -->
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Body --> <!-- Content -->
<tr> <tr>
<td style="background:#ffffff;padding:48px 40px 40px;"> <td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Добро пожаловать!</h1>
</td>
</tr>
<!-- Icon --> <!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;"> <tr>
<tr><td style="background:#ECFDF5;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;"> <td style="padding-bottom: 24px;">
<span style="font-size:36px;line-height:72px;">🎉</span> <p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
</td></tr> Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
</table>
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Добро пожаловать!</h1>
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Ваш аккаунт на Училл создан</p>
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
</p>
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
Ваш ментор добавил вас на платформу <strong style="color:#7444FD;">Училл</strong>. Для начала работы установите пароль для вашего аккаунта.
</p> </p>
</td>
</tr>
<!-- Email info --> <!-- Main message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F5F0FF;border-left:4px solid #7444FD;border-radius:8px;margin-bottom:32px;"> <tr>
<tr><td style="padding:16px 20px;"> <td style="padding-bottom: 24px;">
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ваш email для входа</p> <p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
<p style="margin:0;font-size:15px;font-weight:600;color:#111827;">{{ user_email }}</p> Вас добавили на Uchill. Для начала работы необходимо установить пароль для вашего аккаунта.
</td></tr> </p>
</td>
</tr>
<!-- Info box -->
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
Ваш email для входа:
</p>
<p style="margin: 0; font-size: 14px; color: #757575;">
{{ user_email }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;"> <td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<a href="{{ set_password_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Установить пароль Установить пароль
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:24px;"> <tr>
<tr><td style="padding:16px;"> <td style="padding-bottom: 24px;">
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p> <p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;">
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ set_password_url }}</p> Или скопируйте и вставьте эту ссылку в браузер:
</td></tr>
</table>
<!-- Warning -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#FFFBEB;border-left:4px solid #F59E0B;border-radius:8px;">
<tr><td style="padding:14px 16px;">
<p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
<strong>Важно:</strong> ссылка действительна в течение 7 дней.
</p> </p>
</td></tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<tr>
<td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ set_password_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Warning box -->
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FFF3E0; border-left: 4px solid #FF9800; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0; font-size: 14px; color: #E65100;">
<strong>⚠️ Важно:</strong> Ссылка действительна в течение 7 дней.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;"> <td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p> <tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td></tr> </td>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,84 +4,128 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Подтверждение email — Училл</title> <title>Подтверждение email - Uchill</title>
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]--> <!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"> <body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;"> <!-- Wrapper table -->
<tr><td align="center" style="padding:40px 16px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;"> <td align="center" style="padding: 40px 20px;">
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br> <!-- Main content table -->
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header with logo -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Body --> <!-- Content -->
<tr> <tr>
<td style="background:#ffffff;padding:48px 40px 40px;"> <td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Подтверждение email</h1>
</td>
</tr>
<!-- Icon --> <!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;"> <tr>
<tr><td style="background:#EEE8FF;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;"> <td style="padding-bottom: 24px;">
<span style="font-size:36px;line-height:72px;">✉️</span> <p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
</td></tr> Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
</table>
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Подтвердите ваш email</h1>
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Осталось всего один шаг!</p>
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
</p> </p>
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;"> </td>
Спасибо за регистрацию на <strong style="color:#7444FD;">Училл</strong>. Нажмите на кнопку ниже, чтобы подтвердить ваш адрес электронной почты и активировать аккаунт. </tr>
<!-- Main message -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Спасибо за регистрацию на Uchill. Для завершения регистрации необходимо подтвердить ваш email адрес.
</p> </p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;"> <td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<a href="{{ verification_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{{ verification_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Подтвердить email Подтвердить email
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
<!-- Link fallback --> <!-- Link fallback -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:32px;"> <tr>
<tr><td style="padding:16px;"> <td style="padding-bottom: 24px;">
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p> <p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;">
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ verification_url }}</p> Или скопируйте и вставьте эту ссылку в браузер:
</td></tr>
</table>
<!-- Notice -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F0FDF4;border-left:4px solid #22C55E;border-radius:8px;">
<tr><td style="padding:14px 16px;">
<p style="margin:0;font-size:13px;color:#166534;line-height:1.6;">
Если вы не регистрировались на Училл — просто проигнорируйте это письмо.
</p> </p>
</td></tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<tr>
<td style="padding: 12px;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
{{ verification_url }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- Security notice -->
<tr>
<td style="padding-top: 24px; border-top: 1px solid #E0E0E0;">
<p style="margin: 0; font-size: 12px; color: #9E9E9E; line-height: 1.6;">
Если вы не регистрировались на нашей платформе, просто проигнорируйте это письмо.
</p>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;"> <td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p> <tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td></tr> </td>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,106 +4,128 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Добро пожаловать на Училл</title> <title>Добро пожаловать на Uchill</title>
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]--> <!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head> </head>
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"> <body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;"> <!-- Wrapper table -->
<tr><td align="center" style="padding:40px 16px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;"> <td align="center" style="padding: 40px 20px;">
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br> <!-- Main content table -->
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
</td> <!-- Header with logo -->
</tr>
<!-- Body -->
<tr> <tr>
<td style="background:#ffffff;padding:48px 40px 40px;"> <td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill -->
<!-- Icon --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
<tr><td style="background:#ECFDF5;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
<span style="font-size:36px;line-height:72px;">🚀</span>
</td></tr>
</table>
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Добро пожаловать на Училл!</h1>
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Ваш аккаунт успешно создан</p>
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
</p>
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
Вы успешно зарегистрировались на платформе <strong style="color:#7444FD;">Училл</strong>. Теперь у вас есть доступ ко всем возможностям для обучения.
</p>
<!-- Email info -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F5F0FF;border-left:4px solid #7444FD;border-radius:8px;margin-bottom:32px;">
<tr><td style="padding:16px 20px;">
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ваш email для входа</p>
<p style="margin:0;font-size:15px;font-weight:600;color:#111827;">{{ user_email }}</p>
</td></tr>
</table>
<!-- Features -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom:32px;">
<tr>
<td style="padding:0 0 12px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
<tr>
<td style="padding:14px 16px;font-size:14px;color:#374151;">📅 &nbsp;Онлайн-расписание занятий</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 0 12px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
<tr>
<td style="padding:14px 16px;font-size:14px;color:#374151;">📹 &nbsp;Видеозвонки с интерактивной доской</td>
</tr>
</table>
</td>
</tr>
<tr> <tr>
<td> <td>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;"> <span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<tr> <span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
<td style="padding:14px 16px;font-size:14px;color:#374151;">📚 &nbsp;Домашние задания и материалы</td> </span>
</td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
<!-- Content -->
<tr>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title -->
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Добро пожаловать!</h1>
</td>
</tr>
<!-- Greeting -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
</p>
</td>
</tr>
<!-- Main message -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Добро пожаловать на Uchill! Ваш аккаунт успешно создан.
</p>
</td>
</tr>
<!-- Info box -->
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
Ваш email для входа:
</p>
<p style="margin: 0; font-size: 14px; color: #757575;">
{{ user_email }}
</p>
</td>
</tr>
</table> </table>
</td>
</tr>
<!-- CTA -->
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
Теперь вы можете войти в систему и начать пользоваться всеми возможностями платформы.
</p>
</td>
</tr>
<!-- Button --> <!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto;">
<tr> <tr>
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;"> <td style="padding-top: 8px; padding-bottom: 24px;">
<a href="{{ login_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0">
Войти в Училл <tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="https://app.uchill.online/login" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Войти в систему
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;"> <td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p> <tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td> </td>
</tr> </tr>
</table> </table>
</td></tr> </td>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -32,7 +32,6 @@ from .profile_views import (
ClientManagementViewSet, ClientManagementViewSet,
ParentManagementViewSet, ParentManagementViewSet,
InvitationViewSet, InvitationViewSet,
StudentMentorViewSet,
) )
from .mentorship_views import MentorshipRequestViewSet from .mentorship_views import MentorshipRequestViewSet
from .student_progress_views import StudentProgressViewSet from .student_progress_views import StudentProgressViewSet
@ -54,7 +53,6 @@ router.register(r'parent', ParentDashboardViewSet, basename='parent-dashboard')
router.register(r'profile', ProfileViewSet, basename='profile') router.register(r'profile', ProfileViewSet, basename='profile')
router.register(r'manage/clients', ClientManagementViewSet, basename='manage-clients') router.register(r'manage/clients', ClientManagementViewSet, basename='manage-clients')
router.register(r'invitation', InvitationViewSet, basename='invitation') router.register(r'invitation', InvitationViewSet, basename='invitation')
router.register(r'student/mentors', StudentMentorViewSet, basename='student-mentors')
router.register(r'mentorship-requests', MentorshipRequestViewSet, basename='mentorship-request') router.register(r'mentorship-requests', MentorshipRequestViewSet, basename='mentorship-request')
router.register(r'manage/parents', ParentManagementViewSet, basename='manage-parents') router.register(r'manage/parents', ParentManagementViewSet, basename='manage-parents')

View File

@ -46,7 +46,6 @@ class TelegramBotInfoView(generics.GenericAPIView):
GET /api/auth/telegram/bot-info/ GET /api/auth/telegram/bot-info/
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Получение имени бота для использования в Telegram Login Widget.""" """Получение имени бота для использования в Telegram Login Widget."""
@ -75,7 +74,6 @@ class TelegramAuthView(generics.GenericAPIView):
""" """
serializer_class = TelegramAuthSerializer serializer_class = TelegramAuthSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -157,7 +155,6 @@ class RegisterView(generics.CreateAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = RegisterSerializer serializer_class = RegisterSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
@ -202,7 +199,6 @@ class LoginView(generics.GenericAPIView):
""" """
serializer_class = LoginSerializer serializer_class = LoginSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -248,7 +244,6 @@ class LoginByTokenView(generics.GenericAPIView):
POST /api/auth/login-by-token/ POST /api/auth/login-by-token/
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -358,7 +353,6 @@ class PasswordResetRequestView(generics.GenericAPIView):
""" """
serializer_class = PasswordResetRequestSerializer serializer_class = PasswordResetRequestSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [BurstRateThrottle] throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -396,7 +390,6 @@ class PasswordResetConfirmView(generics.GenericAPIView):
""" """
serializer_class = PasswordResetConfirmSerializer serializer_class = PasswordResetConfirmSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Подтверждение восстановления пароля.""" """Подтверждение восстановления пароля."""
@ -433,7 +426,6 @@ class EmailVerificationView(generics.GenericAPIView):
""" """
serializer_class = EmailVerificationSerializer serializer_class = EmailVerificationSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Подтверждение email пользователя.""" """Подтверждение email пользователя."""
@ -472,7 +464,6 @@ class ResendVerificationEmailView(generics.GenericAPIView):
Можно использовать с авторизацией или без (передавая email) Можно использовать с авторизацией или без (передавая email)
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Повторная отправка письма подтверждения email.""" """Повторная отправка письма подтверждения email."""
@ -815,8 +806,8 @@ class GroupViewSet(viewsets.ModelViewSet):
distinct=True distinct=True
) )
).only( ).only(
'id', 'name', 'description', 'mentor_id', 'id', 'name', 'description', 'mentor_id', 'max_students',
'created_at' 'is_active', 'created_at', 'updated_at'
) )
return queryset return queryset

View File

@ -159,16 +159,6 @@ app.conf.beat_schedule = {
'task': 'apps.materials.tasks.cleanup_old_unused_materials', 'task': 'apps.materials.tasks.cleanup_old_unused_materials',
'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00 'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00
}, },
# ============================================
# РЕФЕРАЛЬНАЯ СИСТЕМА
# ============================================
# Обработка отложенных реферальных бонусов каждый день в 6:00
'process-pending-referral-bonuses': {
'task': 'apps.referrals.tasks.process_pending_referral_bonuses',
'schedule': crontab(hour=6, minute=0),
},
} }
@app.task(bind=True, ignore_result=True) @app.task(bind=True, ignore_result=True)

View File

@ -253,11 +253,8 @@ services:
context: ./front_minimal context: ./front_minimal
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- VITE_SERVER_URL=${NEXT_PUBLIC_API_URL} - NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_API_URL}
- VITE_API_URL=${NEXT_PUBLIC_API_URL} - NEXT_PUBLIC_ASSET_URL=${NEXT_PUBLIC_API_URL}
- VITE_WS_URL=${NEXT_PUBLIC_WS_URL}
- VITE_LIVEKIT_URL=${NEXT_PUBLIC_LIVEKIT_URL}
- VITE_EXCALIDRAW_PATH=${NEXT_PUBLIC_EXCALIDRAW_PATH}
container_name: ${COMPOSE_PROJECT_NAME}_front_minimal container_name: ${COMPOSE_PROJECT_NAME}_front_minimal
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@ -266,7 +263,7 @@ services:
env_file: .env env_file: .env
environment: environment:
- NODE_ENV=${NODE_ENV:-development} - NODE_ENV=${NODE_ENV:-development}
- HOST=0.0.0.0 - HOSTNAME=0.0.0.0
ports: ports:
- "${FRONTEND_MINIMAL_PORT:-3005}:3000" - "${FRONTEND_MINIMAL_PORT:-3005}:3000"
networks: networks:
@ -287,13 +284,11 @@ services:
build: build:
context: ./excalidraw-server context: ./excalidraw-server
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- NEXT_PUBLIC_BASE_PATH=/devboard
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
- NEXT_PUBLIC_BASE_PATH=/devboard - NEXT_PUBLIC_BASE_PATH=
ports: ports:
- "${EXCALIDRAW_PORT:-3001}:3001" - "${EXCALIDRAW_PORT:-3001}:3001"
networks: networks:

View File

@ -16,9 +16,7 @@ RUN npx patch-package
# Копируем исходный код # Копируем исходный код
COPY . . COPY . .
# Собираем приложение (NEXT_PUBLIC_* переменные нужны на этапе сборки) # Собираем приложение
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN npm run build

View File

@ -1,32 +1,34 @@
# Development stage для front_minimal (отдельный от front_material)
FROM node:20-alpine AS development FROM node:20-alpine AS development
WORKDIR /app WORKDIR /app
ARG VITE_SERVER_URL # front_minimal использует NEXT_PUBLIC_SERVER_URL, NEXT_PUBLIC_ASSET_URL
ARG VITE_API_URL ARG NEXT_PUBLIC_SERVER_URL
ARG VITE_WS_URL ARG NEXT_PUBLIC_ASSET_URL
ARG VITE_LIVEKIT_URL ARG NEXT_PUBLIC_BASE_PATH
ARG VITE_EXCALIDRAW_PATH ARG BUILD_STATIC_EXPORT=false
ENV VITE_SERVER_URL=${VITE_SERVER_URL} ENV NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_SERVER_URL}
ENV VITE_API_URL=${VITE_API_URL} ENV NEXT_PUBLIC_ASSET_URL=${NEXT_PUBLIC_ASSET_URL}
ENV VITE_WS_URL=${VITE_WS_URL} ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH:-}
ENV VITE_LIVEKIT_URL=${VITE_LIVEKIT_URL} ENV BUILD_STATIC_EXPORT=${BUILD_STATIC_EXPORT}
ENV VITE_EXCALIDRAW_PATH=${VITE_EXCALIDRAW_PATH}
ENV NODE_ENV=development ENV NODE_ENV=development
ENV HOST=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV WATCHPACK_POLLING=true
ENV CHOKIDAR_USEPOLLING=true ENV CHOKIDAR_USEPOLLING=true
# front_minimal: есть и package-lock.json и yarn.lock, используем npm
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
# Entrypoint: при volume-монтировании проверяем node_modules, затем запускаем vite # Entrypoint: при volume-монтировании проверяем node_modules
RUN echo '#!/bin/sh' > /entrypoint.sh && \ RUN echo '#!/bin/sh' > /entrypoint.sh && \
echo 'set -e' >> /entrypoint.sh && \ echo 'set -e' >> /entrypoint.sh && \
echo 'if [ ! -d node_modules/vite ] 2>/dev/null; then npm install; fi' >> /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 vite --port 3000 --host 0.0.0.0' >> /entrypoint.sh && \ echo 'exec npx next dev -p 3000 --hostname 0.0.0.0' >> /entrypoint.sh && \
chmod +x /entrypoint.sh chmod +x /entrypoint.sh
EXPOSE 3000 EXPOSE 3000

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Училл</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -29,9 +29,6 @@
"@fullcalendar/timeline": "^6.1.14", "@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0", "@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1", "@iconify/react": "^5.0.1",
"@livekit/components-core": "^0.12.13",
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@mui/lab": "^5.0.0-alpha.170", "@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20", "@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11", "@mui/material-nextjs": "^5.15.11",
@ -54,7 +51,6 @@
"autosuggest-highlight": "^3.3.4", "autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6", "aws-amplify": "^6.3.6",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"embla-carousel": "^8.1.5", "embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5", "embla-carousel-auto-height": "^8.1.5",
@ -66,7 +62,6 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"livekit-client": "^2.17.2",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2", "mui-one-time-password-input": "^2.0.2",
@ -3244,12 +3239,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@dnd-kit/accessibility": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
@ -4087,22 +4076,20 @@
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==" "integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.5", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.11" "@floating-ui/utils": "^0.2.1"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.7.4", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.3", "@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.10" "@floating-ui/utils": "^0.2.1"
} }
}, },
"node_modules/@floating-ui/react-dom": { "node_modules/@floating-ui/react-dom": {
@ -4118,10 +4105,9 @@
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.11", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
"license": "MIT"
}, },
"node_modules/@fontsource/barlow": { "node_modules/@fontsource/barlow": {
"version": "5.0.13", "version": "5.0.13",
@ -4396,76 +4382,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@livekit/components-core": {
"version": "0.12.13",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/dom": "1.7.4",
"loglevel": "1.9.1",
"rxjs": "7.8.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"livekit-client": "^2.17.2",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-react": {
"version": "2.9.20",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.20.tgz",
"integrity": "sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.12.13",
"clsx": "2.1.1",
"events": "^3.3.0",
"jose": "^6.0.12",
"usehooks-ts": "3.1.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
"livekit-client": "^2.17.2",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
},
"peerDependenciesMeta": {
"@livekit/krisp-noise-filter": {
"optional": true
}
}
},
"node_modules/@livekit/components-styles": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz",
"integrity": "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": { "node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
@ -6824,13 +6740,6 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.13", "version": "7946.0.13",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
@ -8085,16 +7994,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.11", "version": "1.11.11",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
@ -10711,15 +10610,6 @@
"restructure": "^3.0.0" "restructure": "^3.0.0"
} }
}, },
"node_modules/jose": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz",
"integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@ -10889,40 +10779,6 @@
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==" "integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
}, },
"node_modules/livekit-client": {
"version": "2.17.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz",
"integrity": "sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.44.0",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"ts-debounce": "^4.0.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/livekit-client/node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -10974,19 +10830,6 @@
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
}, },
"node_modules/loglevel": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/long": { "node_modules/long": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
@ -13394,10 +13237,9 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@ -13481,21 +13323,6 @@
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==" "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
}, },
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -14217,12 +14044,6 @@
"typescript": ">=4.2.0" "typescript": ">=4.2.0"
} }
}, },
"node_modules/ts-debounce": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
"license": "MIT"
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -14248,10 +14069,9 @@
} }
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
"license": "0BSD"
}, },
"node_modules/turndown": { "node_modules/turndown": {
"version": "7.2.0", "version": "7.2.0",
@ -14365,15 +14185,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.4.5", "version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
@ -14662,21 +14473,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -14784,19 +14580,6 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
}, },
"node_modules/webrtc-adapter": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/websocket-driver": { "node_modules/websocket-driver": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",

View File

@ -1,20 +1,22 @@
{ {
"name": "platform-frontend", "name": "@minimal-kit/next-js",
"author": "Platform", "author": "Minimals",
"version": "1.0.0", "version": "6.0.1",
"description": "Platform frontend — Vite + React", "description": "Next & JavaScript",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite --port 3032", "dev": "next dev -p 3032",
"build": "vite build", "start": "next start -p 3032",
"preview": "vite preview --port 3032", "build": "next build",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"", "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"",
"fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", "fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
"fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"rm:all": "rm -rf node_modules dist .vite", "rm:all": "rm -rf node_modules .next out dist build",
"re:start": "yarn rm:all && yarn install && yarn dev", "re:start": "yarn rm:all && yarn install && yarn dev",
"re:build": "yarn rm:all && yarn install && yarn build" "re:build": "yarn rm:all && yarn install && yarn build",
"re:build-npm": "npm run rm:all && npm install && npm run build",
"start:out": "npx serve@latest out -p 3032"
}, },
"dependencies": { "dependencies": {
"@auth0/auth0-react": "^2.2.4", "@auth0/auth0-react": "^2.2.4",
@ -38,11 +40,9 @@
"@fullcalendar/timeline": "^6.1.14", "@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0", "@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1", "@iconify/react": "^5.0.1",
"@livekit/components-core": "^0.12.13",
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@mui/lab": "^5.0.0-alpha.170", "@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20", "@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11",
"@mui/x-data-grid": "^7.7.0", "@mui/x-data-grid": "^7.7.0",
"@mui/x-date-pickers": "^7.7.0", "@mui/x-date-pickers": "^7.7.0",
"@mui/x-tree-view": "^7.7.0", "@mui/x-tree-view": "^7.7.0",
@ -62,7 +62,6 @@
"autosuggest-highlight": "^3.3.4", "autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6", "aws-amplify": "^6.3.6",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"embla-carousel": "^8.1.5", "embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5", "embla-carousel-auto-height": "^8.1.5",
@ -74,10 +73,10 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"livekit-client": "^2.17.2",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2", "mui-one-time-password-input": "^2.0.2",
"next": "^14.2.4",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-apexcharts": "^1.4.1", "react-apexcharts": "^1.4.1",
@ -104,9 +103,8 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@svgr/webpack": "^8.1.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
@ -119,9 +117,6 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"react-router-dom": "^6.30.3", "typescript": "^5.4.5"
"typescript": "^5.4.5",
"vite": "^5.4.21",
"vite-plugin-svgr": "^4.5.0"
} }
} }

View File

@ -1,86 +1,47 @@
import { useMemo, useState, useEffect } from 'react'; import { useMemo } from 'react';
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
import { getCalendarLessons, getMentorStudents, getMentorSubjects, createCalendarLesson } from 'src/utils/dashboard-api';
import {
getCalendarLessons,
getMentorStudents,
getMentorSubjects,
createCalendarLesson,
updateCalendarLesson,
deleteCalendarLesson,
} from 'src/utils/dashboard-api';
import { getGroups } from 'src/utils/groups-api';
import { useAuthContext } from 'src/auth/hooks';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const CALENDAR_ENDPOINT = '/schedule/lessons/calendar/';
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200'; const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
const SUBJECTS_ENDPOINT = '/schedule/subjects/'; const SUBJECTS_ENDPOINT = '/schedule/subjects/';
const GROUPS_ENDPOINT = '/groups/';
const swrOptions = { const swrOptions = {
revalidateIfStale: true, revalidateIfStale: true,
revalidateOnFocus: false, revalidateOnFocus: true,
revalidateOnReconnect: true, revalidateOnReconnect: true,
}; };
// Ключ кэша для календаря (по месяцу)
function calendarKey(date = new Date()) {
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
return ['calendar', start, end];
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function useGetEvents(currentDate) { export function useGetEvents() {
const date = currentDate || new Date(); const startDate = '2026-02-01';
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd'); const endDate = '2026-04-30';
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
const { user } = useAuthContext();
const getChildId = () => {
if (user?.role !== 'parent') return null;
try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || null) : null; } catch { return null; }
};
const [childId, setChildId] = useState(getChildId);
useEffect(() => {
if (user?.role !== 'parent') return undefined;
const handler = () => setChildId(getChildId());
window.addEventListener('child-changed', handler);
return () => window.removeEventListener('child-changed', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.role]);
const { data: response, isLoading, error, isValidating } = useSWR( const { data: response, isLoading, error, isValidating } = useSWR(
['calendar', start, end, childId], [CALENDAR_ENDPOINT, startDate, endDate],
([, s, e, cid]) => getCalendarLessons(s, e, cid ? { child_id: cid } : undefined), ([url, start, end]) => getCalendarLessons(start, end),
swrOptions swrOptions
); );
const memoizedValue = useMemo(() => { const memoizedValue = useMemo(() => {
const lessonsArray = response?.data?.lessons || response?.lessons || []; const lessonsArray = response?.data?.lessons || [];
const events = lessonsArray.map((lesson) => { const events = lessonsArray.map((lesson) => {
const start = lesson.start_time || lesson.start; const start = lesson.start_time || lesson.start;
const end = lesson.end_time || lesson.end || start; const end = lesson.end_time || lesson.end || start;
const startTimeStr = start const startTimeStr = start ? new Date(start).toLocaleTimeString('ru-RU', {
? new Date(start).toLocaleTimeString('ru-RU', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hourCycle: 'h23', hourCycle: 'h23'
}) }) : '';
: '';
const subject = lesson.subject_name || lesson.subject || 'Урок'; const subject = lesson.subject_name || lesson.subject || 'Урок';
const participant = lesson.group_name const student = lesson.client_name || '';
? `Группа: ${lesson.group_name}` const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`;
: (lesson.client_name || '');
const displayTitle = `${startTimeStr} ${subject}${participant ? ` - ${participant}` : ''}`;
const status = String(lesson.status || 'scheduled').toLowerCase(); const status = String(lesson.status || 'scheduled').toLowerCase();
let eventColor = '#7635dc'; let eventColor = '#7635dc';
@ -103,8 +64,6 @@ export function useGetEvents(currentDate) {
status, status,
student: lesson.client_name || '', student: lesson.client_name || '',
mentor: lesson.mentor_name || '', mentor: lesson.mentor_name || '',
group: lesson.group || null,
group_name: lesson.group_name || '',
}, },
}; };
}); });
@ -124,16 +83,14 @@ export function useGetEvents(currentDate) {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function useGetStudents() { export function useGetStudents() {
const { data: response, isLoading, error } = useSWR( const { data: response, isLoading, error } = useSWR(STUDENTS_ENDPOINT, getMentorStudents, swrOptions);
STUDENTS_ENDPOINT,
getMentorStudents,
swrOptions
);
return useMemo(() => { return useMemo(() => {
const rawData = response?.data?.results || response?.results || response || []; const rawData = response?.data?.results || response?.results || response || [];
const studentsArray = Array.isArray(rawData) ? rawData : [];
return { return {
students: Array.isArray(rawData) ? rawData : [], students: studentsArray,
studentsLoading: isLoading, studentsLoading: isLoading,
studentsError: error, studentsError: error,
}; };
@ -141,72 +98,41 @@ export function useGetStudents() {
} }
export function useGetSubjects() { export function useGetSubjects() {
const { data: response, isLoading, error } = useSWR( const { data: response, isLoading, error } = useSWR(SUBJECTS_ENDPOINT, getMentorSubjects, swrOptions);
SUBJECTS_ENDPOINT,
getMentorSubjects,
swrOptions
);
return useMemo(() => { return useMemo(() => {
const rawData = response?.data || response?.results || response || []; const rawData = response?.data || response?.results || response || [];
const subjectsArray = Array.isArray(rawData) ? rawData : [];
return { return {
subjects: Array.isArray(rawData) ? rawData : [], subjects: subjectsArray,
subjectsLoading: isLoading, subjectsLoading: isLoading,
subjectsError: error, subjectsError: error,
}; };
}, [response, isLoading, error]); }, [response, isLoading, error]);
} }
export function useGetGroups() {
const { data, isLoading, error } = useSWR(GROUPS_ENDPOINT, getGroups, swrOptions);
return useMemo(() => ({
groups: Array.isArray(data) ? data : [],
groupsLoading: isLoading,
groupsError: error,
}), [data, isLoading, error]);
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function revalidateCalendar() {
mutate((key) => Array.isArray(key) && key[0] === 'calendar', undefined, { revalidate: true });
}
export async function createEvent(eventData) { export async function createEvent(eventData) {
const isGroup = !!eventData.group;
const payload = { const payload = {
title: eventData.title || 'Занятие', client: String(eventData.client),
description: eventData.description || '', title: eventData.title.replace(' - ', ' — '),
description: eventData.description,
start_time: eventData.start_time, start_time: eventData.start_time,
duration: eventData.duration || 60, duration: eventData.duration,
price: eventData.price, price: eventData.price,
is_recurring: eventData.is_recurring || false, is_recurring: eventData.is_recurring,
...(eventData.subject && { subject_id: Number(eventData.subject) }), subject_id: Number(eventData.subject),
...(isGroup ? { group: eventData.group } : { client: String(eventData.client) }),
}; };
const res = await createCalendarLesson(payload); const response = await createCalendarLesson(payload);
revalidateCalendar();
return res; // Обновляем кэш, чтобы занятия появлялись сразу
mutate([CALENDAR_ENDPOINT, '2026-02-01', '2026-04-30']);
return response;
} }
export async function updateEvent(eventData, currentDate) { export async function updateEvent(eventData) { console.log('Update Event:', eventData); }
const { id, ...data } = eventData; export async function deleteEvent(eventId) { console.log('Delete Event:', eventId); }
const updatePayload = {};
if (data.start_time) updatePayload.start_time = data.start_time;
if (data.duration) updatePayload.duration = data.duration;
if (data.price != null) updatePayload.price = data.price;
if (data.description != null) updatePayload.description = data.description;
if (data.status) updatePayload.status = data.status;
const res = await updateCalendarLesson(String(id), updatePayload);
revalidateCalendar();
return res;
}
export async function deleteEvent(eventId, deleteAllFuture = false) {
await deleteCalendarLesson(String(eventId), deleteAllFuture);
revalidateCalendar();
}

View File

@ -1,39 +0,0 @@
import { BrowserRouter } from 'react-router-dom';
import { LocalizationProvider } from 'src/locales';
import { I18nProvider } from 'src/locales/i18n-provider';
import { ThemeProvider } from 'src/theme/theme-provider';
import { Snackbar } from 'src/components/snackbar';
import { ProgressBar } from 'src/components/progress-bar';
import { MotionLazy } from 'src/components/animate/motion-lazy';
import { SettingsDrawer, defaultSettings, SettingsProvider } from 'src/components/settings';
import { AuthProvider } from 'src/auth/context/jwt';
import { Router } from 'src/routes/sections';
// ----------------------------------------------------------------------
export default function App() {
return (
<BrowserRouter>
<I18nProvider>
<LocalizationProvider>
<AuthProvider>
<SettingsProvider settings={defaultSettings} caches="localStorage">
<ThemeProvider>
<MotionLazy>
<Snackbar />
<ProgressBar />
<SettingsDrawer />
<Router />
</MotionLazy>
</ThemeProvider>
</SettingsProvider>
</AuthProvider>
</LocalizationProvider>
</I18nProvider>
</BrowserRouter>
);
}

View File

@ -1,13 +0,0 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
import { GuestGuard } from 'src/auth/guard';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return (
<GuestGuard>
<AuthSplitLayout>{children}</AuthSplitLayout>
</GuestGuard>
);
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { JwtForgotPasswordView } from 'src/sections/auth/jwt';
// ----------------------------------------------------------------------
export const metadata = { title: `Forgot password | Jwt - ${CONFIG.site.name}` };
export default function Page() {
return <JwtForgotPasswordView />;
}

View File

@ -1,13 +0,0 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
import { GuestGuard } from 'src/auth/guard';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return (
<GuestGuard>
<AuthSplitLayout>{children}</AuthSplitLayout>
</GuestGuard>
);
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { JwtResetPasswordView } from 'src/sections/auth/jwt';
// ----------------------------------------------------------------------
export const metadata = { title: `Reset password | Jwt - ${CONFIG.site.name}` };
export default function Page() {
return <JwtResetPasswordView />;
}

View File

@ -7,14 +7,7 @@ import { GuestGuard } from 'src/auth/guard';
export default function Layout({ children }) { export default function Layout({ children }) {
return ( return (
<GuestGuard> <GuestGuard>
<AuthSplitLayout <AuthSplitLayout section={{ title: 'Hi, Welcome back' }}>{children}</AuthSplitLayout>
section={{
title: 'Добро пожаловать',
subtitle: 'Платформа для онлайн-обучения и работы с репетиторами.',
}}
>
{children}
</AuthSplitLayout>
</GuestGuard> </GuestGuard>
); );
} }

View File

@ -1,7 +0,0 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return <AuthSplitLayout>{children}</AuthSplitLayout>;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { JwtVerifyEmailView } from 'src/sections/auth/jwt';
// ----------------------------------------------------------------------
export const metadata = { title: `Verify email | Jwt - ${CONFIG.site.name}` };
export default function Page() {
return <JwtVerifyEmailView />;
}

View File

@ -1,11 +1,11 @@
import { CONFIG } from 'src/config-global'; import { CONFIG } from 'src/config-global';
import { AnalyticsView } from 'src/sections/analytics/view'; import { OverviewAnalyticsView } from 'src/sections/overview/analytics/view';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const metadata = { title: `Аналитика | ${CONFIG.site.name}` }; export const metadata = { title: `Analytics | Dashboard - ${CONFIG.site.name}` };
export default function Page() { export default function Page() {
return <AnalyticsView />; return <OverviewAnalyticsView />;
} }

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { BoardView } from 'src/sections/board/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Доска | ${CONFIG.site.name}` };
export default function Page() {
return <BoardView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { ChatPlatformView } from 'src/sections/chat/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Чат | ${CONFIG.site.name}` };
export default function Page() {
return <ChatPlatformView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { ChildrenProgressView } from 'src/sections/children/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Прогресс ребёнка | ${CONFIG.site.name}` };
export default function Page() {
return <ChildrenProgressView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { ChildrenView } from 'src/sections/children/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Мои дети | ${CONFIG.site.name}` };
export default function Page() {
return <ChildrenView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { FeedbackView } from 'src/sections/feedback/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Обратная связь | ${CONFIG.site.name}` };
export default function Page() {
return <FeedbackView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { HomeworkView } from 'src/sections/homework/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Домашние задания | ${CONFIG.site.name}` };
export default function Page() {
return <HomeworkView />;
}

View File

@ -1,21 +0,0 @@
import { CONFIG } from 'src/config-global';
import { LessonDetailView } from 'src/sections/lesson-detail/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Занятие | ${CONFIG.site.name}` };
export default function Page({ params }) {
return <LessonDetailView id={params.id} />;
}
// ----------------------------------------------------------------------
const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic';
export { dynamic };
export async function generateStaticParams() {
return [];
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { MaterialsView } from 'src/sections/materials/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Материалы | ${CONFIG.site.name}` };
export default function Page() {
return <MaterialsView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { MyProgressView } from 'src/sections/my-progress/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Мой прогресс | ${CONFIG.site.name}` };
export default function Page() {
return <MyProgressView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { NotificationsView } from 'src/sections/notifications/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Уведомления | ${CONFIG.site.name}` };
export default function Page() {
return <NotificationsView />;
}

View File

@ -1,130 +1,38 @@
import { useState, useEffect } from 'react'; 'use client';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { CONFIG } from 'src/config-global';
import { useAuthContext } from 'src/auth/hooks'; import { useAuthContext } from 'src/auth/hooks';
import axios from 'src/utils/axios';
// Временно импортируем только ментора (позже добавим клиента и родителя)
import { OverviewCourseView } from 'src/sections/overview/course/view'; import { OverviewCourseView } from 'src/sections/overview/course/view';
import { OverviewClientView } from 'src/sections/overview/client/view';
// ---------------------------------------------------------------------- export default function Page() {
async function loadChildren() {
try {
const res = await axios.get('/parent/dashboard/');
const raw = res.data?.children ?? [];
return raw.map((item) => {
const c = item.child ?? item;
return { id: c.id, name: c.name || c.email || '' };
});
} catch {
return [];
}
}
// ----------------------------------------------------------------------
export default function DashboardPage() {
const { user, loading } = useAuthContext(); const { user, loading } = useAuthContext();
const [selectedChild, setSelectedChild] = useState(null);
const [childrenLoading, setChildrenLoading] = useState(false);
const [noChildren, setNoChildren] = useState(false);
// Load children for parent role
useEffect(() => {
if (user?.role !== 'parent') return undefined;
setChildrenLoading(true);
loadChildren().then((list) => {
setChildrenLoading(false);
if (!list.length) {
setNoChildren(true);
return;
}
// Try to restore saved child
try {
const saved = localStorage.getItem('selected_child');
if (saved) {
const parsed = JSON.parse(saved);
const exists = list.find((c) => c.id === parsed.id);
if (exists) {
setSelectedChild(parsed);
return;
}
}
} catch { /* ignore */ }
// Auto-select first child
const first = list[0];
localStorage.setItem('selected_child', JSON.stringify(first));
window.dispatchEvent(new Event('child-changed'));
setSelectedChild(first);
});
// React to child switch from nav selector
const handler = () => {
try {
const saved = localStorage.getItem('selected_child');
if (saved) setSelectedChild(JSON.parse(saved));
} catch { /* ignore */ }
};
window.addEventListener('child-changed', handler);
return () => window.removeEventListener('child-changed', handler);
}, [user]);
// ----------------------------------------------------------------------
if (loading) { if (loading) {
return ( return <div>Загрузка...</div>;
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
if (!user) return null;
if (user.role === 'mentor') return <OverviewCourseView />;
if (user.role === 'client') return <OverviewClientView />;
if (user.role === 'parent') {
if (childrenLoading) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
if (noChildren) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 1 }}>
<Typography variant="h6" color="text.secondary">Нет привязанных детей</Typography>
<Typography variant="body2" color="text.disabled">
Обратитесь к администратору для привязки аккаунта ребёнка
</Typography>
</Box>
);
}
if (!selectedChild) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
return <OverviewClientView childId={selectedChild.id} childName={selectedChild.name} />;
} }
if (!user) {
return null; return null;
} }
// Роутинг по ролям
if (user.role === 'mentor') {
return <OverviewCourseView />;
}
if (user.role === 'client') {
return <div>Дашборд Клиента (в разработке)</div>;
}
if (user.role === 'parent') {
return <div>Дашборд Родителя (в разработке)</div>;
}
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p>Неизвестная роль пользователя: {user.role}</p>
</div>
);
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { PaymentPlatformView } from 'src/sections/payment/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Оплата | ${CONFIG.site.name}` };
export default function Page() {
return <PaymentPlatformView />;
}

View File

@ -1,3 +1,4 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';

View File

@ -1,3 +1,4 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';

View File

@ -1,3 +1,4 @@
'use client';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { AccountPlatformView } from 'src/sections/account-platform/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Профиль | ${CONFIG.site.name}` };
export default function Page() {
return <AccountPlatformView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { ReferralsView } from 'src/sections/referrals/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Рефералы | ${CONFIG.site.name}` };
export default function Page() {
return <ReferralsView />;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { StudentsView } from 'src/sections/students/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Ученики | ${CONFIG.site.name}` };
export default function Page() {
return <StudentsView />;
}

View File

@ -1,10 +0,0 @@
import { CONFIG } from 'src/config-global';
import { InviteRegisterView } from 'src/sections/invite/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Регистрация по приглашению | ${CONFIG.site.name}` };
export default function Page({ params }) {
return <InviteRegisterView token={params.token} />;
}

View File

@ -1,3 +1,4 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';

View File

@ -1,3 +1,4 @@
'use client';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';

View File

@ -1,3 +1,4 @@
'use client';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';

View File

@ -1,5 +0,0 @@
// Fullscreen layout no sidebar or header
export default function VideoCallLayout({ children }) {
return children;
}

View File

@ -1,11 +0,0 @@
import { CONFIG } from 'src/config-global';
import { VideoCallView } from 'src/sections/video-call/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Видеозвонок | ${CONFIG.site.name}` };
export default function Page() {
return <VideoCallView />;
}

View File

@ -1,3 +1,4 @@
'use client';
import { import {
signIn as _signIn, signIn as _signIn,

View File

@ -1,3 +1,4 @@
'use client';
import { Amplify } from 'aws-amplify'; import { Amplify } from 'aws-amplify';
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';

View File

@ -1,3 +1,4 @@
'use client';
import { createContext } from 'react'; import { createContext } from 'react';

View File

@ -1,3 +1,4 @@
'use client';
import { useAuth0, Auth0Provider } from '@auth0/auth0-react'; import { useAuth0, Auth0Provider } from '@auth0/auth0-react';
import { useMemo, useState, useEffect, useCallback } from 'react'; import { useMemo, useState, useEffect, useCallback } from 'react';

View File

@ -1,3 +1,4 @@
'use client';
import { doc, setDoc, collection } from 'firebase/firestore'; import { doc, setDoc, collection } from 'firebase/firestore';
import { import {

View File

@ -1,3 +1,4 @@
'use client';
import { doc, getDoc } from 'firebase/firestore'; import { doc, getDoc } from 'firebase/firestore';
import { onAuthStateChanged } from 'firebase/auth'; import { onAuthStateChanged } from 'firebase/auth';

View File

@ -1,25 +1,27 @@
'use client';
import axios, { endpoints } from 'src/utils/axios'; import axios, { endpoints } from 'src/utils/axios';
import { setSession } from './utils'; import { setSession } from './utils';
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant'; import { STORAGE_KEY } from './constant';
/** ************************************** /** **************************************
* Sign in * Sign in
*************************************** */ *************************************** */
export const signInWithPassword = async ({ email, password }) => { export const signInWithPassword = async ({ email, password }) => {
try { try {
const res = await axios.post(endpoints.auth.signIn, { email, password }); const params = { email, password };
const data = res.data?.data; const res = await axios.post(endpoints.auth.signIn, params);
const accessToken = data?.tokens?.access;
const refreshToken = data?.tokens?.refresh; // Адаптация под твой API: { data: { tokens: { access } } }
const accessToken = res.data?.data?.tokens?.access;
if (!accessToken) { if (!accessToken) {
throw new Error('Access token not found in response'); throw new Error('Access token not found in response');
} }
await setSession(accessToken, refreshToken); setSession(accessToken);
} catch (error) { } catch (error) {
console.error('Error during sign in:', error); console.error('Error during sign in:', error);
throw error; throw error;
@ -29,23 +31,24 @@ export const signInWithPassword = async ({ email, password }) => {
/** ************************************** /** **************************************
* Sign up * Sign up
*************************************** */ *************************************** */
export const signUp = async ({ email, password, passwordConfirm, firstName, lastName, role, city, timezone }) => { export const signUp = async ({ email, password, firstName, lastName }) => {
try {
const params = { const params = {
email, email,
password, password,
password_confirm: passwordConfirm, firstName,
first_name: firstName, lastName,
last_name: lastName,
role: role || 'client',
city: city || '',
timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
}; };
await axios.post(endpoints.auth.signUp, params); try {
const res = await axios.post(endpoints.auth.signUp, params);
// Всегда требуем подтверждение email перед входом const { accessToken } = res.data;
return { requiresVerification: true };
if (!accessToken) {
throw new Error('Access token not found in response');
}
sessionStorage.setItem(STORAGE_KEY, accessToken);
} catch (error) { } catch (error) {
console.error('Error during sign up:', error); console.error('Error during sign up:', error);
throw error; throw error;
@ -63,67 +66,3 @@ export const signOut = async () => {
throw error; throw error;
} }
}; };
/** **************************************
* Refresh token
*************************************** */
export const refreshAccessToken = async () => {
try {
const refreshToken = localStorage.getItem(REFRESH_STORAGE_KEY);
if (!refreshToken) throw new Error('No refresh token');
const res = await axios.post(endpoints.auth.refresh, { refresh: refreshToken }, {
headers: { Authorization: undefined },
});
const accessToken = res.data?.access;
if (!accessToken) throw new Error('No access token in refresh response');
await setSession(accessToken, refreshToken);
return accessToken;
} catch (error) {
console.error('Error during token refresh:', error);
throw error;
}
};
/** **************************************
* Request password reset
*************************************** */
export const requestPasswordReset = async ({ email }) => {
try {
await axios.post(endpoints.auth.passwordReset, { email });
} catch (error) {
console.error('Error during password reset request:', error);
throw error;
}
};
/** **************************************
* Confirm password reset
*************************************** */
export const confirmPasswordReset = async ({ token, newPassword, newPasswordConfirm }) => {
try {
await axios.post(endpoints.auth.passwordResetConfirm, {
token,
new_password: newPassword,
new_password_confirm: newPasswordConfirm,
});
} catch (error) {
console.error('Error during password reset confirm:', error);
throw error;
}
};
/** **************************************
* Verify email
*************************************** */
export const verifyEmail = async ({ token }) => {
try {
const res = await axios.post(endpoints.auth.verifyEmail, { token });
return res.data;
} catch (error) {
console.error('Error during email verification:', error);
throw error;
}
};

View File

@ -1,11 +1,11 @@
'use client';
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';
import { useSetState } from 'src/hooks/use-set-state'; import { useSetState } from 'src/hooks/use-set-state';
import axios, { endpoints } from 'src/utils/axios'; import axios, { endpoints } from 'src/utils/axios';
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant'; import { STORAGE_KEY } from './constant';
import { AuthContext } from '../auth-context'; import { AuthContext } from '../auth-context';
import { setSession, isValidToken } from './utils'; import { setSession, isValidToken } from './utils';
import { refreshAccessToken } from './action';
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const { state, setState } = useSetState({ const { state, setState } = useSetState({
@ -15,28 +15,25 @@ export function AuthProvider({ children }) {
const checkUserSession = useCallback(async () => { const checkUserSession = useCallback(async () => {
try { try {
let accessToken = localStorage.getItem(STORAGE_KEY); const accessToken = sessionStorage.getItem(STORAGE_KEY);
if (accessToken && isValidToken(accessToken)) { if (accessToken && isValidToken(accessToken)) {
setSession(accessToken); setSession(accessToken);
} else {
// Пробуем обновить через refresh token
try {
accessToken = await refreshAccessToken();
} catch {
setState({ user: null, loading: false });
return;
}
}
const res = await axios.get(endpoints.auth.me); const res = await axios.get(endpoints.auth.me);
// Гарантируем получение объекта пользователя из data
const userData = res.data?.data || res.data; const userData = res.data?.data || res.data;
// Если прилетел массив или невалидный объект - сбрасываем
if (!userData || typeof userData !== 'object' || Array.isArray(userData)) { if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
throw new Error('Invalid user data format'); throw new Error('Invalid user data format');
} }
setState({ user: { ...userData, accessToken }, loading: false }); setState({ user: { ...userData, accessToken }, loading: false });
} else {
setState({ user: null, loading: false });
}
} catch (error) { } catch (error) {
console.error('[Auth Debug]:', error); console.error('[Auth Debug]:', error);
setState({ user: null, loading: false }); setState({ user: null, loading: false });
@ -55,7 +52,7 @@ export function AuthProvider({ children }) {
user: state.user user: state.user
? { ? {
...state.user, ...state.user,
role: state.user?.role ?? 'client', role: state.user?.role ?? 'admin',
} }
: null, : null,
checkUserSession, checkUserSession,

View File

@ -1,2 +1 @@
export const STORAGE_KEY = 'jwt_access_token'; export const STORAGE_KEY = 'jwt_access_token';
export const REFRESH_STORAGE_KEY = 'jwt_refresh_token';

View File

@ -2,7 +2,7 @@ import { paths } from 'src/routes/paths';
import axios from 'src/utils/axios'; import axios from 'src/utils/axios';
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant'; import { STORAGE_KEY } from './constant';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -57,29 +57,26 @@ export function tokenExpired(exp) {
setTimeout(() => { setTimeout(() => {
try { try {
localStorage.removeItem(STORAGE_KEY); alert('Token expired!');
localStorage.removeItem(REFRESH_STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY);
window.location.href = paths.auth.jwt.signIn; window.location.href = paths.auth.jwt.signIn;
} catch (error) { } catch (error) {
console.error('Error during token expiration:', error); console.error('Error during token expiration:', error);
throw error;
} }
}, timeLeft); }, timeLeft);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export async function setSession(accessToken, refreshToken) { export async function setSession(accessToken) {
try { try {
if (accessToken) { if (accessToken) {
localStorage.setItem(STORAGE_KEY, accessToken); sessionStorage.setItem(STORAGE_KEY, accessToken);
if (refreshToken) {
localStorage.setItem(REFRESH_STORAGE_KEY, refreshToken);
}
axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
const decodedToken = jwtDecode(accessToken); const decodedToken = jwtDecode(accessToken); // ~3 days by minimals server
if (decodedToken && 'exp' in decodedToken) { if (decodedToken && 'exp' in decodedToken) {
tokenExpired(decodedToken.exp); tokenExpired(decodedToken.exp);
@ -87,8 +84,7 @@ export async function setSession(accessToken, refreshToken) {
throw new Error('Invalid access token!'); throw new Error('Invalid access token!');
} }
} else { } else {
localStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(REFRESH_STORAGE_KEY);
delete axios.defaults.headers.common.Authorization; delete axios.defaults.headers.common.Authorization;
} }
} catch (error) { } catch (error) {

View File

@ -1,3 +1,4 @@
'use client';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';

View File

@ -1,3 +1,4 @@
'use client';
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';

View File

@ -1,3 +1,4 @@
'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
@ -9,21 +10,6 @@ import { CONFIG } from 'src/config-global';
import { SplashScreen } from 'src/components/loading-screen'; import { SplashScreen } from 'src/components/loading-screen';
import { useAuthContext } from '../hooks'; import { useAuthContext } from '../hooks';
import { STORAGE_KEY } from '../context/jwt/constant';
import { isValidToken } from '../context/jwt/utils';
// ----------------------------------------------------------------------
// Synchronously check if there's a valid token in localStorage
// to avoid showing SplashScreen on every load when user is already authenticated
function hasValidStoredToken() {
try {
const token = localStorage.getItem(STORAGE_KEY);
return token ? isValidToken(token) : false;
} catch {
return false;
}
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -36,8 +22,7 @@ export function AuthGuard({ children }) {
const { authenticated, loading } = useAuthContext(); const { authenticated, loading } = useAuthContext();
// Skip splash if we already have a valid token avoids flash on every page load const [isChecking, setIsChecking] = useState(true);
const [isChecking, setIsChecking] = useState(() => !hasValidStoredToken());
const createQueryString = useCallback( const createQueryString = useCallback(
(name, value) => { (name, value) => {
@ -55,8 +40,18 @@ export function AuthGuard({ children }) {
} }
if (!authenticated) { if (!authenticated) {
const signInPath = paths.auth.jwt.signIn; const { method } = CONFIG.auth;
const signInPath = {
jwt: paths.auth.jwt.signIn,
auth0: paths.auth.auth0.signIn,
amplify: paths.auth.amplify.signIn,
firebase: paths.auth.firebase.signIn,
supabase: paths.auth.supabase.signIn,
}[method];
const href = `${signInPath}?${createQueryString('returnTo', pathname)}`; const href = `${signInPath}?${createQueryString('returnTo', pathname)}`;
router.replace(href); router.replace(href);
return; return;
} }

View File

@ -1,3 +1,4 @@
'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';

View File

@ -1,3 +1,4 @@
'use client';
import { m } from 'framer-motion'; import { m } from 'framer-motion';

View File

@ -1,58 +0,0 @@
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'src/routes/hooks';
import { paths } from 'src/routes/paths';
import { useAuthContext } from '../hooks';
import axios from 'src/utils/axios';
// Страницы, доступные без подписки
const EXEMPT_PATHS = [
paths.dashboard.payment,
paths.dashboard.profile,
paths.dashboard.referrals,
paths.dashboard.notifications,
];
// ----------------------------------------------------------------------
export function SubscriptionGuard({ children }) {
const { user, loading } = useAuthContext();
const router = useRouter();
const pathname = usePathname();
const [checked, setChecked] = useState(false);
useEffect(() => {
if (loading) return;
// Только для менторов
if (!user || user.role !== 'mentor') {
setChecked(true);
return;
}
// Страница оплаты и некоторые другие не требуют подписки
if (EXEMPT_PATHS.some((p) => pathname.startsWith(p))) {
setChecked(true);
return;
}
axios
.get('/subscriptions/subscriptions/active/')
.then((res) => {
if (res.data) {
setChecked(true);
} else {
router.replace(paths.dashboard.payment);
}
})
.catch(() => {
router.replace(paths.dashboard.payment);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading, pathname]);
if (!checked) return null;
return <>{children}</>;
}

View File

@ -1,3 +1,4 @@
'use client';
import { useContext } from 'react'; import { useContext } from 'react';

View File

@ -1,3 +1,4 @@
'use client';
import { m } from 'framer-motion'; import { m } from 'framer-motion';
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';

View File

@ -33,7 +33,7 @@ export function AnimateLogo1({ logo, sx, ...other }) {
}} }}
sx={{ display: 'inline-flex' }} sx={{ display: 'inline-flex' }}
> >
{logo ?? <Logo disableLink width={64} height={64} mini />} {logo ?? <Logo disableLink width={64} height={64} />}
</Box> </Box>
<Box <Box

View File

@ -1,3 +1,4 @@
'use client';
import { LazyMotion } from 'framer-motion'; import { LazyMotion } from 'framer-motion';

View File

@ -1,3 +1,4 @@
'use client';
import { useRef, useMemo } from 'react'; import { useRef, useMemo } from 'react';
import { useScroll } from 'framer-motion'; import { useScroll } from 'framer-motion';

View File

@ -1,10 +1,21 @@
import { lazy, Suspense } from 'react'; import dynamic from 'next/dynamic';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { withLoadingProps } from 'src/utils/with-loading-props';
import { ChartLoading } from './chart-loading'; import { ChartLoading } from './chart-loading';
const ApexChart = lazy(() => import('react-apexcharts').then((mod) => ({ default: mod.default }))); const ApexChart = withLoadingProps((props) =>
dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
ssr: false,
loading: () => {
const { loading, type } = props();
return loading?.disabled ? null : <ChartLoading type={type} sx={loading?.sx} />;
},
})
);
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -30,13 +41,6 @@ export function Chart({
...sx, ...sx,
}} }}
{...other} {...other}
>
<Suspense
fallback={
loadingProps?.disabled ? null : (
<ChartLoading type={type} sx={loadingProps?.sx} />
)
}
> >
<ApexChart <ApexChart
type={type} type={type}
@ -44,8 +48,8 @@ export function Chart({
options={options} options={options}
width="100%" width="100%"
height="100%" height="100%"
loading={loadingProps}
/> />
</Suspense>
</Box> </Box>
); );
} }

View File

@ -67,9 +67,8 @@ export function useChart(options) {
animations: { animations: {
enabled: true, enabled: true,
speed: 360, speed: 360,
easing: 'easeinout',
animateGradually: { enabled: true, delay: 120 }, animateGradually: { enabled: true, delay: 120 },
dynamicAnimation: { enabled: true, speed: 400, easing: 'easeinout' }, dynamicAnimation: { enabled: true, speed: 360 },
...options?.chart?.animations, ...options?.chart?.animations,
}, },
}, },

View File

@ -21,7 +21,6 @@ export function CustomPopover({ open, onClose, children, anchorEl, slotProps, ..
open={!!open} open={!!open}
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={onClose} onClose={onClose}
disableScrollLock
anchorOrigin={anchorOrigin} anchorOrigin={anchorOrigin}
transformOrigin={transformOrigin} transformOrigin={transformOrigin}
slotProps={{ slotProps={{

View File

@ -1,3 +1,4 @@
'use client';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { Icon, disableCache } from '@iconify/react'; import { Icon, disableCache } from '@iconify/react';

View File

@ -1,3 +1,4 @@
'use client';
import { forwardRef } from 'react'; import { forwardRef } from 'react';

View File

@ -1,3 +1,4 @@
'use client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';

View File

@ -1,3 +1,4 @@
'use client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Portal from '@mui/material/Portal'; import Portal from '@mui/material/Portal';

View File

@ -1,3 +1,4 @@
'use client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Portal from '@mui/material/Portal'; import Portal from '@mui/material/Portal';

View File

@ -1,70 +1,106 @@
'use client';
import { forwardRef } from 'react'; import { useId, forwardRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { Link } from 'react-router-dom'; import NoSsr from '@mui/material/NoSsr';
import { useTheme } from '@mui/material/styles';
import { CONFIG } from 'src/config-global'; import { RouterLink } from 'src/routes/components';
import { logoClasses } from './classes';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
/**
* mini=false (default) full logo (logo.svg)
* mini=true icon only (favicon.png)
*/
export const Logo = forwardRef( export const Logo = forwardRef(
({ width, height, mini = false, disableLink = false, className, href = '/dashboard', sx, ...other }, ref) => { ({ width = 40, height = 40, disableLink = false, className, href = '/', sx, ...other }, ref) => {
const theme = useTheme();
const defaultWidth = mini ? 40 : 134; const gradientId = useId();
const defaultHeight = mini ? 40 : 40;
const w = width ?? defaultWidth; const PRIMARY_LIGHT = theme.vars.palette.primary.light;
const h = height ?? defaultHeight;
const logo = mini ? ( const PRIMARY_MAIN = theme.vars.palette.primary.main;
<Box
component="img" const PRIMARY_DARK = theme.vars.palette.primary.dark;
alt="logo icon"
src={`${CONFIG.site.basePath}/logo/favicon.png`} /*
sx={{ width: w, height: h, objectFit: 'contain' }} * OR using local (public folder)
* const logo = ( <Box alt="logo" component="img" src={`${CONFIG.site.basePath}/logo/logo-single.svg`} width={width} height={height} /> );
*/
const logo = (
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512">
<defs>
<linearGradient id={`${gradientId}-1`} x1="100%" x2="50%" y1="9.946%" y2="50%">
<stop offset="0%" stopColor={PRIMARY_DARK} />
<stop offset="100%" stopColor={PRIMARY_MAIN} />
</linearGradient>
<linearGradient id={`${gradientId}-2`} x1="50%" x2="50%" y1="0%" y2="100%">
<stop offset="0%" stopColor={PRIMARY_LIGHT} />
<stop offset="100%" stopColor={PRIMARY_MAIN} />
</linearGradient>
<linearGradient id={`${gradientId}-3`} x1="50%" x2="50%" y1="0%" y2="100%">
<stop offset="0%" stopColor={PRIMARY_LIGHT} />
<stop offset="100%" stopColor={PRIMARY_MAIN} />
</linearGradient>
</defs>
<g fill={PRIMARY_MAIN} fillRule="evenodd" stroke="none" strokeWidth="1">
<path
fill={`url(#${`${gradientId}-1`})`}
d="M183.168 285.573l-2.918 5.298-2.973 5.363-2.846 5.095-2.274 4.043-2.186 3.857-2.506 4.383-1.6 2.774-2.294 3.939-1.099 1.869-1.416 2.388-1.025 1.713-1.317 2.18-.95 1.558-1.514 2.447-.866 1.38-.833 1.312-.802 1.246-.77 1.18-.739 1.111-.935 1.38-.664.956-.425.6-.41.572-.59.8-.376.497-.537.69-.171.214c-10.76 13.37-22.496 23.493-36.93 29.334-30.346 14.262-68.07 14.929-97.202-2.704l72.347-124.682 2.8-1.72c49.257-29.326 73.08 1.117 94.02 40.927z"
/> />
) : ( <path
<Box fill={`url(#${`${gradientId}-2`})`}
component="img" d="M444.31 229.726c-46.27-80.956-94.1-157.228-149.043-45.344-7.516 14.384-12.995 42.337-25.267 42.337v-.142c-12.272 0-17.75-27.953-25.265-42.337C189.79 72.356 141.96 148.628 95.69 229.584c-3.483 6.106-6.828 11.932-9.69 16.996 106.038-67.127 97.11 135.667 184 137.278V384c86.891-1.611 77.962-204.405 184-137.28-2.86-5.062-6.206-10.888-9.69-16.994"
alt="logo"
src={`${CONFIG.site.basePath}/logo/logo.svg`}
sx={{ width: w, height: h, objectFit: 'contain' }}
/> />
<path
fill={`url(#${`${gradientId}-3`})`}
d="M450 384c26.509 0 48-21.491 48-48s-21.491-48-48-48-48 21.491-48 48 21.491 48 48 48"
/>
</g>
</svg>
); );
const style = { return (
<NoSsr
fallback={
<Box
width={width}
height={height}
className={logoClasses.root.concat(className ? ` ${className}` : '')}
sx={{
flexShrink: 0, flexShrink: 0,
display: 'inline-flex', display: 'inline-flex',
verticalAlign: 'middle', verticalAlign: 'middle',
width: w, ...sx,
height: h, }}
...(disableLink && { pointerEvents: 'none' }), />
};
if (disableLink) {
return (
<Box ref={ref} className={className || ''} sx={{ ...style, ...sx }} {...other}>
{logo}
</Box>
);
} }
>
return ( <Box
<Link
ref={ref} ref={ref}
to={href} component={RouterLink}
className={className || ''} href={href}
width={width}
height={height}
className={logoClasses.root.concat(className ? ` ${className}` : '')}
aria-label="logo" aria-label="logo"
style={{ textDecoration: 'none', ...style }} sx={{
flexShrink: 0,
display: 'inline-flex',
verticalAlign: 'middle',
...(disableLink && { pointerEvents: 'none' }),
...sx,
}}
{...other} {...other}
> >
{logo} {logo}
</Link> </Box>
</NoSsr>
); );
} }
); );

View File

@ -49,7 +49,7 @@ export function NavList({ data, render, depth, slotProps, enabledRootRedirect })
disabled={data.disabled} disabled={data.disabled}
hasChild={!!data.children} hasChild={!!data.children}
open={data.children && openMenu} open={data.children && openMenu}
externalLink={data.externalLink || isExternalLink(data.path)} externalLink={isExternalLink(data.path)}
enabledRootRedirect={enabledRootRedirect} enabledRootRedirect={enabledRootRedirect}
// styles // styles
slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem} slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem}

View File

@ -1,4 +1,5 @@
import { lazy, Suspense, cloneElement } from 'react'; import dynamic from 'next/dynamic';
import { cloneElement } from 'react';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
@ -6,13 +7,13 @@ import { flattenArray } from 'src/utils/helper';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const Tree = lazy(() => const Tree = dynamic(() => import('react-organizational-chart').then((mod) => mod.Tree), {
import('react-organizational-chart').then((mod) => ({ default: mod.Tree })) ssr: false,
); });
const TreeNode = lazy(() => const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), {
import('react-organizational-chart').then((mod) => ({ default: mod.TreeNode })) ssr: false,
); });
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -26,7 +27,6 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
}); });
return ( return (
<Suspense fallback={null}>
<Tree <Tree
lineWidth="1.5px" lineWidth="1.5px"
nodePadding="4px" nodePadding="4px"
@ -39,7 +39,6 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
<TreeList key={index} depth={1} data={list} nodeItem={nodeItem} /> <TreeList key={index} depth={1} data={list} nodeItem={nodeItem} />
))} ))}
</Tree> </Tree>
</Suspense>
); );
} }

View File

@ -1,3 +1,4 @@
'use client';
import './styles.css'; import './styles.css';

Some files were not shown because too many files have changed in this diff Show More