feat: subscriptions, referrals, students/mentors removal, email templates, calendar fixes
Deploy to Dev / deploy-dev (push) Failing after 31s Details

Backend:
- Subscriptions: add duration_days field to plan, fix duration logic, remove allowed_durations restriction
- Users: add StudentMentorViewSet (remove_mentor), fix remove_client (board access + notification)
- Users: add STATUS_REMOVED to MentorStudentConnection, fix re-invite after removal
- Users: add authentication_classes=[] to all public auth endpoints (fix user_not_found 401)
- Users: fix verify-email and reset-password URLs in email tasks
- Users: validate IANA timezone on registration
- Schedule: add group/group_name to LessonCalendarItemSerializer
- Referrals: add tasks.py for Celery, add process_pending_referral_bonuses to beat schedule
- Email templates: redesign all 5 templates (gradient header, icons, Училл branding)

Frontend:
- Calendar: fix SWR revalidation after create/update/delete (match childId key), clear errors on tab switch
- Students: add remove buttons with warning dialog (mentor removes student, student removes mentor)
- Students: add tabs for client (Мои менторы / Входящие / Исходящие), fix pending_student filter
- Payment: fix duration_days from plan, show Бесплатно for 0₽, show X дн. period
- Referrals: full redesign — stats, levels progress, referrals list, earnings history, bonus balance
- Sign-up: add referral code field, auto-fill from ?ref= param
- Subscription guard: redirect mentor to /payment-platform if no active subscription
- Error pages: translate to Russian
- Page titles: dynamic Russian titles via usePageTitle hook
- Logo: fix full-page reload on click (use react-router Link directly)
- Favicon: use /logo/favicon.png
- Remove logo from header (keep in nav only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Server 2026-03-13 00:39:37 +03:00
parent 2877320987
commit e49fa9e746
67 changed files with 5671 additions and 3347 deletions

View File

@ -0,0 +1,38 @@
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

@ -130,15 +130,7 @@ 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
@ -198,7 +190,7 @@ class LessonDetailSerializer(LessonSerializer):
class LessonCreateSerializer(serializers.ModelSerializer): class LessonCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания занятия.""" """Сериализатор для создания занятия."""
mentor = serializers.HiddenField(default=serializers.CurrentUserDefault()) mentor = serializers.HiddenField(default=serializers.CurrentUserDefault())
subject_id = serializers.IntegerField(required=False, allow_null=True, source='subject') subject_id = serializers.IntegerField(required=False, allow_null=True, source='subject')
mentor_subject_id = serializers.IntegerField(required=False, allow_null=True, source='mentor_subject') mentor_subject_id = serializers.IntegerField(required=False, allow_null=True, source='mentor_subject')
@ -212,6 +204,10 @@ 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):
""" """
@ -307,6 +303,12 @@ 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'
@ -380,20 +382,19 @@ 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
# Проверка: допускаем создание занятий до 30 минут в прошлом # Нормализуем start_time к UTC
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)
now = django_timezone.now() # Проверяем что занятие не начинается более 30 минут назад
tolerance = timedelta(minutes=30) if start_time < django_timezone.now() - timedelta(minutes=30):
if start_time < now - tolerance:
raise serializers.ValidationError({ raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад' 'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
}) })
# Рассчитываем время окончания # Рассчитываем время окончания
end_time = start_time + timedelta(minutes=duration) end_time = start_time + timedelta(minutes=duration)
@ -648,6 +649,7 @@ 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):
@ -671,7 +673,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', 'subject', 'subject_name'] fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'group', 'group_name', 'subject', 'subject_name']
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer): class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):

View File

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

View File

@ -0,0 +1,24 @@
# 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,7 +234,8 @@ 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(
@ -254,6 +255,14 @@ 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)],
@ -426,16 +435,12 @@ 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':
@ -443,7 +448,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 # 100 лет return 36500
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(default=30, min_value=1) duration_days = serializers.IntegerField(required=False, allow_null=True, 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,26 +219,9 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
return value return value
def validate_duration_days(self, value): def validate_duration_days(self, value):
"""Валидация длительности.""" """Валидация длительности — любое положительное число дней."""
allowed_durations = [30, 90, 180, 365] if value is not None and value < 1:
if value not in allowed_durations: raise serializers.ValidationError('Длительность должна быть не менее 1 дня')
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):
@ -256,7 +239,11 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
plan = SubscriptionPlan.objects.get(id=plan_id) plan = SubscriptionPlan.objects.get(id=plan_id)
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,15 +326,11 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'}, {'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
duration_days = int(request.data.get('duration_days', 30)) requested_days = request.data.get('duration_days')
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,
@ -788,9 +784,8 @@ class PaymentViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
else: else:
# По умолчанию используем первый доступный период или 30 дней # По умолчанию используем duration_days из тарифного плана
available_durations = plan.get_available_durations() duration_days = plan.get_duration_days()
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' 'mentor', 'client', 'client__user', 'subject', 'mentor_subject', 'group'
) )
# Один запрос для всех подсчетов занятий # Один запрос для всех подсчетов занятий
@ -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, start_time__gte=now - timedelta(minutes=90),
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,6 +163,12 @@ 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
@ -589,7 +595,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__first_name']} {item['client__user__last_name']}".strip() or 'Ученик' else f"{item['client__user__last_name']} {item['client__user__first_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']),
@ -649,9 +655,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, start_time__gte=now - timedelta(minutes=90),
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]
@ -707,17 +713,18 @@ class ClientDashboardViewSet(viewsets.ViewSet):
'title': lesson.title, 'title': lesson.title,
'mentor': { 'mentor': {
'id': lesson.mentor.id, 'id': lesson.mentor.id,
'name': lesson.mentor.get_full_name() 'first_name': lesson.mentor.first_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
} }
for lesson in upcoming_lessons for lesson in upcoming_lessons
] ]
} }
# Сохраняем в кеш на 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)
@ -1181,9 +1188,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, start_time__gte=now - timedelta(minutes=90),
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]
@ -1239,17 +1246,19 @@ class ParentDashboardViewSet(viewsets.ViewSet):
'title': lesson.title, 'title': lesson.title,
'mentor': { 'mentor': {
'id': lesson.mentor.id, 'id': lesson.mentor.id,
'name': lesson.mentor.get_full_name() 'first_name': lesson.mentor.first_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
} }
for lesson in upcoming_lessons for lesson in upcoming_lessons
] ]
} }
# Сохраняем в кеш на 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,6 +15,7 @@ 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:
@ -38,6 +39,10 @@ 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

@ -0,0 +1,24 @@
# 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

@ -0,0 +1,29 @@
# 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,6 +567,7 @@ 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, 'Ожидает ответа ментора'),
@ -574,6 +575,7 @@ 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'

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]) @action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny], authentication_classes=[])
def search_cities_from_csv(self, request): def search_cities_from_csv(self, request):
""" """
Поиск городов из city.csv по запросу. Поиск городов из city.csv по запросу.
@ -921,16 +921,8 @@ 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).
@ -1026,9 +1018,6 @@ 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')
@ -1149,7 +1138,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) if is_new_user or True else None, 'confirm_token': secrets.token_urlsafe(32),
} }
) )
if not created: if not created:
@ -1161,6 +1150,13 @@ 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)
@ -1230,18 +1226,39 @@ 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
}) })
@ -1288,6 +1305,82 @@ class ClientManagementViewSet(viewsets.ViewSet):
}) })
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
})
class InvitationViewSet(viewsets.ViewSet): class InvitationViewSet(viewsets.ViewSet):
""" """
Подтверждение приглашений менторстудент. Подтверждение приглашений менторстудент.

View File

@ -129,7 +129,18 @@ class RegisterSerializer(serializers.ModelSerializer):
def validate_email(self, value): def validate_email(self, value):
"""Нормализация 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 from django.db.models.signals import post_save, post_delete, m2m_changed
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 from .models import MentorStudentConnection, Group
def _invalidate_manage_clients_cache(mentor_id): def _invalidate_manage_clients_cache(mentor_id):
@ -28,3 +28,24 @@ 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,6 +25,7 @@ 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 и текстовые шаблоны
@ -60,7 +61,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}/verify-email?token={verification_token}" verification_url = f"{settings.FRONTEND_URL}/auth/jwt/verify-email?token={verification_token}"
subject = 'Подтвердите ваш email' subject = 'Подтвердите ваш email'
@ -102,7 +103,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}/reset-password?token={reset_token}" reset_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
subject = 'Восстановление пароля' subject = 'Восстановление пароля'
@ -144,7 +145,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}/reset-password?token={reset_token}" set_password_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
subject = 'Добро пожаловать на платформу!' subject = 'Добро пожаловать на платформу!'

View File

@ -4,61 +4,42 @@
<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 %}Uchill{% endblock %}</title> <title>{% block title %}Училл{% endblock %}</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<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: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<!-- Card -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<!-- Header -->
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<!-- Main content table --> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</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);"> <br>
<!-- Header with logo --> <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>
<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>
</tr>
<!-- Content block -->
<tr>
<td style="padding: 0 40px 40px 40px;">
{% block content %}{% endblock %}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<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>
</tr>
</table>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:40px;">
{% block content %}{% endblock %}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
</td>
</tr>
</table> </table>
</td></tr>
</table>
</body> </body>
</html> </html>

View File

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

View File

@ -4,143 +4,93 @@
<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>Восстановление пароля - Uchill</title> <title>Восстановление пароля — Училл</title>
<!--[if mso]> <!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
<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: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
<!-- Wrapper table --> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <tr><td align="center" style="padding:40px 16px;">
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
<td align="center" style="padding: 40px 20px;">
<!-- Main content table --> <!-- Header -->
<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);"> <tr>
<!-- Header with logo --> <td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
<tr> <span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;"> <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>
<!-- Стилизованный текстовый логотип uchill --> </td>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> </tr>
<tr>
<td> <!-- Body -->
<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="background:#ffffff;padding:48px 40px 40px;">
</span>
</td> <!-- Icon -->
</tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
</table> <tr><td style="background:#FEF3C7;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
</td> <span style="font-size:36px;line-height:72px;">🔐</span>
</tr> </td></tr>
</table>
<!-- Content -->
<tr> <h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Восстановление пароля</h1>
<td style="padding: 0 40px 40px 40px;"> <p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Мы получили запрос на сброс пароля</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Title --> <p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
<tr> Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
<td style="padding-bottom: 24px;"> </p>
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Восстановление пароля</h1> <p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
</td> Нажмите на кнопку ниже, чтобы установить новый пароль для вашего аккаунта.
</tr> </p>
<!-- Greeting --> <!-- Button -->
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
<td style="padding-bottom: 24px;"> <tr>
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;"> <td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>! <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;">
</p> Установить новый пароль
</td> </a>
</tr> </td>
</tr>
<!-- Main message --> </table>
<tr>
<td style="padding-bottom: 24px;"> <!-- Link fallback -->
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;"> <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><td style="padding:16px;">
</p> <p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
</td> <p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ reset_url }}</p>
</tr> </td></tr>
</table>
<!-- Button -->
<tr> <!-- Warning -->
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;"> <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;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <tr><td style="padding:14px 16px;">
<tr> <p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
<td style="background-color: #7444FD; border-radius: 4px;"> <strong>Важно:</strong> ссылка действительна в течение 24 часов.
<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;"> </p>
Восстановить пароль </td></tr>
</a> </table>
</td>
</tr> <!-- Security notice -->
</table> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
</td> <tr><td style="padding:14px 16px;">
</tr> <p style="margin:0;font-size:13px;color:#6B7280;line-height:1.6;">
Если вы не запрашивали восстановление пароля — просто проигнорируйте это письмо. Ваш пароль останется без изменений.
<!-- Link fallback --> </p>
<tr> </td></tr>
<td style="padding-bottom: 24px;"> </table>
<p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;">
Или скопируйте и вставьте эту ссылку в браузер: </td>
</p> </tr>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
<tr> <!-- Footer -->
<td style="padding: 12px;"> <tr>
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;"> <td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
{{ reset_url }} <p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
</p> <p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
</td> </td>
</tr> </tr>
</table>
</td> </table>
</tr> </td></tr>
</table>
<!-- 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 -->
<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>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<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>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

@ -32,6 +32,7 @@ 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
@ -53,6 +54,7 @@ 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

@ -42,10 +42,11 @@ from config.throttling import BurstRateThrottle
class TelegramBotInfoView(generics.GenericAPIView): class TelegramBotInfoView(generics.GenericAPIView):
""" """
API endpoint для получения информации о Telegram боте. API endpoint для получения информации о Telegram боте.
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."""
@ -69,11 +70,12 @@ class TelegramBotInfoView(generics.GenericAPIView):
class TelegramAuthView(generics.GenericAPIView): class TelegramAuthView(generics.GenericAPIView):
""" """
API endpoint для авторизации через Telegram Login Widget. API endpoint для авторизации через Telegram Login Widget.
POST /api/auth/telegram/ POST /api/auth/telegram/
""" """
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):
@ -149,12 +151,13 @@ class TelegramAuthView(generics.GenericAPIView):
class RegisterView(generics.CreateAPIView): class RegisterView(generics.CreateAPIView):
""" """
API endpoint для регистрации нового пользователя. API endpoint для регистрации нового пользователя.
POST /api/auth/register/ POST /api/auth/register/
""" """
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):
@ -194,11 +197,12 @@ class RegisterView(generics.CreateAPIView):
class LoginView(generics.GenericAPIView): class LoginView(generics.GenericAPIView):
""" """
API endpoint для входа пользователя. API endpoint для входа пользователя.
POST /api/auth/login/ POST /api/auth/login/
""" """
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):
@ -240,10 +244,11 @@ class LoginView(generics.GenericAPIView):
class LoginByTokenView(generics.GenericAPIView): class LoginByTokenView(generics.GenericAPIView):
""" """
API endpoint для входа по персональному токену. API endpoint для входа по персональному токену.
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):
@ -348,11 +353,12 @@ class ChangePasswordView(generics.GenericAPIView):
class PasswordResetRequestView(generics.GenericAPIView): class PasswordResetRequestView(generics.GenericAPIView):
""" """
API endpoint для запроса восстановления пароля. API endpoint для запроса восстановления пароля.
POST /api/auth/password-reset/ POST /api/auth/password-reset/
""" """
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):
@ -385,11 +391,12 @@ class PasswordResetRequestView(generics.GenericAPIView):
class PasswordResetConfirmView(generics.GenericAPIView): class PasswordResetConfirmView(generics.GenericAPIView):
""" """
API endpoint для подтверждения восстановления пароля. API endpoint для подтверждения восстановления пароля.
POST /api/auth/password-reset-confirm/ POST /api/auth/password-reset-confirm/
""" """
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):
"""Подтверждение восстановления пароля.""" """Подтверждение восстановления пароля."""
@ -421,11 +428,12 @@ class PasswordResetConfirmView(generics.GenericAPIView):
class EmailVerificationView(generics.GenericAPIView): class EmailVerificationView(generics.GenericAPIView):
""" """
API endpoint для подтверждения email. API endpoint для подтверждения email.
POST /api/auth/verify-email/ POST /api/auth/verify-email/
""" """
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 пользователя."""
@ -459,11 +467,12 @@ class EmailVerificationView(generics.GenericAPIView):
class ResendVerificationEmailView(generics.GenericAPIView): class ResendVerificationEmailView(generics.GenericAPIView):
""" """
API endpoint для повторной отправки письма подтверждения email. API endpoint для повторной отправки письма подтверждения email.
POST /api/auth/resend-verification/ POST /api/auth/resend-verification/
Можно использовать с авторизацией или без (передавая email) Можно использовать с авторизацией или без (передавая email)
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Повторная отправка письма подтверждения email.""" """Повторная отправка письма подтверждения email."""
@ -806,8 +815,8 @@ class GroupViewSet(viewsets.ModelViewSet):
distinct=True distinct=True
) )
).only( ).only(
'id', 'name', 'description', 'mentor_id', 'max_students', 'id', 'name', 'description', 'mentor_id',
'is_active', 'created_at', 'updated_at' 'created_at'
) )
return queryset return queryset

View File

@ -159,6 +159,16 @@ 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

@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon/favicon.ico" /> <link rel="icon" type="image/png" href="/logo/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Platform</title> <title>Училл</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -169,14 +169,11 @@ export function useGetGroups() {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function revalidateCalendar(date) { function revalidateCalendar() {
const d = date || new Date(); mutate((key) => Array.isArray(key) && key[0] === 'calendar', undefined, { revalidate: true });
const start = format(startOfMonth(subMonths(d, 1)), 'yyyy-MM-dd');
const end = format(endOfMonth(addMonths(d, 1)), 'yyyy-MM-dd');
mutate(['calendar', start, end]);
} }
export async function createEvent(eventData, currentDate) { export async function createEvent(eventData) {
const isGroup = !!eventData.group; const isGroup = !!eventData.group;
const payload = { const payload = {
title: eventData.title || 'Занятие', title: eventData.title || 'Занятие',
@ -190,7 +187,7 @@ export async function createEvent(eventData, currentDate) {
}; };
const res = await createCalendarLesson(payload); const res = await createCalendarLesson(payload);
revalidateCalendar(currentDate); revalidateCalendar();
return res; return res;
} }
@ -205,11 +202,11 @@ export async function updateEvent(eventData, currentDate) {
if (data.status) updatePayload.status = data.status; if (data.status) updatePayload.status = data.status;
const res = await updateCalendarLesson(String(id), updatePayload); const res = await updateCalendarLesson(String(id), updatePayload);
revalidateCalendar(currentDate); revalidateCalendar();
return res; return res;
} }
export async function deleteEvent(eventId, deleteAllFuture = false, currentDate) { export async function deleteEvent(eventId, deleteAllFuture = false) {
await deleteCalendarLesson(String(eventId), deleteAllFuture); await deleteCalendarLesson(String(eventId), deleteAllFuture);
revalidateCalendar(currentDate); revalidateCalendar();
} }

View File

@ -42,19 +42,10 @@ export const signUp = async ({ email, password, passwordConfirm, firstName, last
timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'), timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
}; };
const res = await axios.post(endpoints.auth.signUp, params); await axios.post(endpoints.auth.signUp, params);
const data = res.data?.data; // Всегда требуем подтверждение email перед входом
const accessToken = data?.tokens?.access; return { requiresVerification: true };
const refreshToken = data?.tokens?.refresh;
if (!accessToken) {
// Регистрация прошла, но токен не выдан (требуется верификация email)
return { requiresVerification: true };
}
await setSession(accessToken, refreshToken);
return { requiresVerification: false };
} catch (error) { } catch (error) {
console.error('Error during sign up:', error); console.error('Error during sign up:', error);
throw error; throw error;

View File

@ -0,0 +1,58 @@
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

@ -67,8 +67,9 @@ 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: 360 }, dynamicAnimation: { enabled: true, speed: 400, easing: 'easeinout' },
...options?.chart?.animations, ...options?.chart?.animations,
}, },
}, },

View File

@ -21,6 +21,7 @@ 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

@ -2,12 +2,9 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import NoSsr from '@mui/material/NoSsr'; import { Link } from 'react-router-dom';
import { CONFIG } from 'src/config-global'; import { CONFIG } from 'src/config-global';
import { RouterLink } from 'src/routes/components';
import { logoClasses } from './classes';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -16,7 +13,7 @@ import { logoClasses } from './classes';
* mini=true icon only (favicon.png) * mini=true icon only (favicon.png)
*/ */
export const Logo = forwardRef( export const Logo = forwardRef(
({ width, height, mini = false, disableLink = false, className, href = '/', sx, ...other }, ref) => { ({ width, height, mini = false, disableLink = false, className, href = '/dashboard', sx, ...other }, ref) => {
const defaultWidth = mini ? 40 : 134; const defaultWidth = mini ? 40 : 134;
const defaultHeight = mini ? 40 : 40; const defaultHeight = mini ? 40 : 40;
@ -40,37 +37,34 @@ export const Logo = forwardRef(
/> />
); );
return ( const style = {
<NoSsr flexShrink: 0,
fallback={ display: 'inline-flex',
<Box verticalAlign: 'middle',
width={w} width: w,
height={h} height: h,
className={logoClasses.root.concat(className ? ` ${className}` : '')} ...(disableLink && { pointerEvents: 'none' }),
sx={{ flexShrink: 0, display: 'inline-flex', verticalAlign: 'middle', ...sx }} };
/>
} if (disableLink) {
> return (
<Box <Box ref={ref} className={className || ''} sx={{ ...style, ...sx }} {...other}>
ref={ref}
component={RouterLink}
href={href}
width={w}
height={h}
className={logoClasses.root.concat(className ? ` ${className}` : '')}
aria-label="logo"
sx={{
flexShrink: 0,
display: 'inline-flex',
verticalAlign: 'middle',
...(disableLink && { pointerEvents: 'none' }),
...sx,
}}
{...other}
>
{logo} {logo}
</Box> </Box>
</NoSsr> );
}
return (
<Link
ref={ref}
to={href}
className={className || ''}
aria-label="logo"
style={{ textDecoration: 'none', ...style }}
{...other}
>
{logo}
</Link>
); );
} }
); );

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={isExternalLink(data.path)} externalLink={data.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

@ -57,6 +57,7 @@
*************************************** */ *************************************** */
html { html {
height: 100%; height: 100%;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
body, body,

View File

@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
const SITE = 'Училл';
const TITLES = {
'/dashboard': SITE,
'/dashboard/schedule': `Расписание | ${SITE}`,
'/dashboard/homework': `Домашние задания | ${SITE}`,
'/dashboard/materials': `Материалы | ${SITE}`,
'/dashboard/students': `Ученики | ${SITE}`,
'/dashboard/notifications': `Уведомления | ${SITE}`,
'/dashboard/board': `Доска | ${SITE}`,
'/dashboard/chat-platform': `Чат | ${SITE}`,
'/dashboard/analytics': `Аналитика | ${SITE}`,
'/dashboard/feedback': `Обратная связь | ${SITE}`,
'/dashboard/profile': `Профиль | ${SITE}`,
'/dashboard/referrals': `Рефералы | ${SITE}`,
'/dashboard/payment-platform': `Оплата | ${SITE}`,
'/dashboard/children': `Дети | ${SITE}`,
'/dashboard/children-progress': `Прогресс детей | ${SITE}`,
'/dashboard/my-progress': `Мой прогресс | ${SITE}`,
'/dashboard/groups': `Группы | ${SITE}`,
'/dashboard/prejoin': `Подключение к уроку | ${SITE}`,
'/auth/jwt/sign-in': `Вход | ${SITE}`,
'/auth/jwt/sign-up': `Регистрация | ${SITE}`,
'/auth/jwt/forgot-password': `Восстановление пароля | ${SITE}`,
'/auth/jwt/reset-password': `Новый пароль | ${SITE}`,
'/auth/jwt/verify-email': `Подтверждение email | ${SITE}`,
'/video-call': `Урок | ${SITE}`,
'/404': `Страница не найдена | ${SITE}`,
};
export function usePageTitle() {
const { pathname } = useLocation();
useEffect(() => {
// Точное совпадение
if (TITLES[pathname]) {
document.title = TITLES[pathname];
return;
}
// Динамические маршруты
if (pathname.match(/^\/dashboard\/students\/.+/)) {
document.title = `Ученик | ${SITE}`;
} else if (pathname.match(/^\/dashboard\/lesson\/.+/)) {
document.title = `Урок | ${SITE}`;
} else if (pathname.match(/^\/dashboard\/homework\/.+/)) {
document.title = `Домашнее задание | ${SITE}`;
} else if (pathname.match(/^\/dashboard\/groups\/.+/)) {
document.title = `Группа | ${SITE}`;
} else if (pathname.match(/^\/invite\/.+/)) {
document.title = `Приглашение | ${SITE}`;
} else {
document.title = SITE;
}
}, [pathname]);
}

View File

@ -98,9 +98,6 @@ export function HeaderBase({
/> />
)} )}
{/* -- Logo -- */}
<Logo data-slot="logo" />
{/* -- Divider -- */} {/* -- Divider -- */}
<StyledDivider data-slot="divider" /> <StyledDivider data-slot="divider" />

View File

@ -8,13 +8,17 @@ import 'dayjs/locale/zh-cn';
import 'dayjs/locale/ar-sa'; import 'dayjs/locale/ar-sa';
import 'dayjs/locale/ru'; // Добавил русский import 'dayjs/locale/ru'; // Добавил русский
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider as Provider } from '@mui/x-date-pickers/LocalizationProvider'; import { LocalizationProvider as Provider } from '@mui/x-date-pickers/LocalizationProvider';
import { useTranslate } from './use-locales'; import { useTranslate } from './use-locales';
dayjs.extend(utc);
dayjs.extend(timezone);
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function LocalizationProvider({ children }) { export function LocalizationProvider({ children }) {

View File

@ -1,8 +1,11 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Navigate, useRoutes, useParams, Outlet } from 'react-router-dom'; import { Navigate, useRoutes, useParams, Outlet } from 'react-router-dom';
import { usePageTitle } from 'src/hooks/use-page-title';
import { AuthGuard } from 'src/auth/guard/auth-guard'; import { AuthGuard } from 'src/auth/guard/auth-guard';
import { GuestGuard } from 'src/auth/guard/guest-guard'; import { GuestGuard } from 'src/auth/guard/guest-guard';
import { SubscriptionGuard } from 'src/auth/guard/subscription-guard';
import { AuthSplitLayout } from 'src/layouts/auth-split'; import { AuthSplitLayout } from 'src/layouts/auth-split';
import { DashboardLayout } from 'src/layouts/dashboard'; import { DashboardLayout } from 'src/layouts/dashboard';
@ -132,9 +135,11 @@ function S({ children }) {
function DashboardLayoutWrapper() { function DashboardLayoutWrapper() {
return ( return (
<AuthGuard> <AuthGuard>
<DashboardLayout> <SubscriptionGuard>
<Outlet /> <DashboardLayout>
</DashboardLayout> <Outlet />
</DashboardLayout>
</SubscriptionGuard>
</AuthGuard> </AuthGuard>
); );
} }
@ -167,6 +172,7 @@ function HomeworkDetailWrapper() {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function Router() { export function Router() {
usePageTitle();
return useRoutes([ return useRoutes([
// Root redirect // Root redirect
{ path: '/', element: <Navigate to="/dashboard" replace /> }, { path: '/', element: <Navigate to="/dashboard" replace /> },

View File

@ -144,12 +144,20 @@ function IncomeTab() {
key={i} key={i}
direction="row" direction="row"
justifyContent="space-between" justifyContent="space-between"
alignItems="center"
py={1} py={1}
sx={{ borderBottom: '1px solid', borderColor: 'divider' }} sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
> >
<Typography variant="body2"> <Box>
{i + 1}. {item.lesson_title || item.target_name || 'Занятие'} <Typography variant="body2">
</Typography> {i + 1}. {item.lesson_title || 'Занятие'}
</Typography>
{item.target_name && (
<Typography variant="caption" color="text.secondary">
{item.target_name}
</Typography>
)}
</Box>
<Typography variant="body2" fontWeight={600} color="primary.main"> <Typography variant="body2" fontWeight={600} color="primary.main">
{formatCurrency(item.total_income)} {formatCurrency(item.total_income)}
</Typography> </Typography>

View File

@ -31,6 +31,8 @@ import { signUp } from 'src/auth/context/jwt';
import { parseApiError } from 'src/utils/parse-api-error'; import { parseApiError } from 'src/utils/parse-api-error';
import { useAuthContext } from 'src/auth/hooks'; import { useAuthContext } from 'src/auth/hooks';
import { searchCities } from 'src/utils/profile-api'; import { searchCities } from 'src/utils/profile-api';
import { setReferrer } from 'src/utils/referrals-api';
import { useSearchParams } from 'src/routes/hooks';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -122,6 +124,7 @@ function CityAutocomplete({ value, onChange, error, helperText }) {
export function JwtSignUpView() { export function JwtSignUpView() {
const { checkUserSession } = useAuthContext(); const { checkUserSession } = useAuthContext();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const password = useBoolean(); const password = useBoolean();
const passwordConfirm = useBoolean(); const passwordConfirm = useBoolean();
@ -129,6 +132,7 @@ export function JwtSignUpView() {
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
const [successMsg, setSuccessMsg] = useState(''); const [successMsg, setSuccessMsg] = useState('');
const [consent, setConsent] = useState(false); const [consent, setConsent] = useState(false);
const [refCode, setRefCode] = useState(() => searchParams.get('ref') || '');
const defaultValues = { const defaultValues = {
firstName: '', firstName: '',
@ -168,6 +172,11 @@ export function JwtSignUpView() {
city: data.city, city: data.city,
}); });
// Применяем реферальный код если указан (после регистрации, до редиректа)
if (refCode.trim()) {
try { await setReferrer(refCode.trim()); } catch { /* игнорируем — не блокируем регистрацию */ }
}
if (result?.requiresVerification) { if (result?.requiresVerification) {
setSuccessMsg( setSuccessMsg(
`Письмо с подтверждением отправлено на ${data.email}. Пройдите по ссылке в письме для активации аккаунта.` `Письмо с подтверждением отправлено на ${data.email}. Пройдите по ссылке в письме для активации аккаунта.`
@ -201,15 +210,23 @@ export function JwtSignUpView() {
if (successMsg) { if (successMsg) {
return ( return (
<> <Stack spacing={3} alignItems="center" sx={{ textAlign: 'center', py: 2 }}>
{renderHead} <Iconify icon="solar:letter-bold" width={64} sx={{ color: 'primary.main' }} />
<Alert severity="success">{successMsg}</Alert> <Stack spacing={1}>
<Stack sx={{ mt: 3 }}> <Typography variant="h5">Подтвердите ваш email</Typography>
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2"> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
Вернуться ко входу Мы отправили письмо с ссылкой для активации аккаунта на&nbsp;адрес:
</Link> </Typography>
<Typography variant="subtitle2">{methods.getValues('email')}</Typography>
</Stack> </Stack>
</> <Alert severity="info" sx={{ textAlign: 'left', width: '100%' }}>
Перейдите по ссылке в письме, чтобы завершить регистрацию. Если письмо не пришло
проверьте папку «Спам».
</Alert>
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
Вернуться ко входу
</Link>
</Stack>
); );
} }
@ -275,6 +292,22 @@ export function JwtSignUpView() {
}} }}
/> />
<TextField
label="Реферальный код (необязательно)"
placeholder="Например: ABC12345"
value={refCode}
onChange={(e) => setRefCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8))}
size="small"
InputLabelProps={{ shrink: true }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Iconify icon="solar:gift-bold" width={18} sx={{ color: 'text.disabled' }} />
</InputAdornment>
),
}}
/>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />} control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />}
label={ label={

View File

@ -16,50 +16,12 @@ import CardContent from '@mui/material/CardContent';
import CardActionArea from '@mui/material/CardActionArea'; import CardActionArea from '@mui/material/CardActionArea';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { CONFIG } from 'src/config-global';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';
import { useAuthContext } from 'src/auth/hooks'; import { useAuthContext } from 'src/auth/hooks';
import { getMyBoards, getSharedBoards, getOrCreateMentorStudentBoard } from 'src/utils/board-api'; import { getMyBoards, getSharedBoards, getOrCreateMentorStudentBoard, getOrCreateGroupBoard, buildExcalidrawSrc } from 'src/utils/board-api';
import { getMentorStudents } from 'src/utils/dashboard-api'; import { getMentorStudents } from 'src/utils/dashboard-api';
import { getGroups } from 'src/utils/groups-api';
// ----------------------------------------------------------------------
function buildExcalidrawSrc(boardId, user) {
const token =
typeof window !== 'undefined'
? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
: '';
const serverUrl = CONFIG.site.serverUrl || '';
const apiUrl = serverUrl.replace(/\/api\/?$/, '') || '';
const isMentor = user?.role === 'mentor';
const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
const excalidrawPort = process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001';
const yjsPort = process.env.NEXT_PUBLIC_YJS_PORT || '1236';
if (excalidrawUrl && excalidrawUrl.startsWith('http')) {
const url = new URL(`${excalidrawUrl}/`);
url.searchParams.set('boardId', boardId);
url.searchParams.set('apiUrl', apiUrl);
url.searchParams.set('yjsPort', yjsPort);
if (token) url.searchParams.set('token', token);
if (isMentor) url.searchParams.set('isMentor', '1');
return url.toString();
}
const origin = excalidrawPath
? (typeof window !== 'undefined' ? window.location.origin : '')
: `${typeof window !== 'undefined' ? window.location.protocol : 'https:'}//${typeof window !== 'undefined' ? window.location.hostname : ''}:${excalidrawPort}`;
const pathname = excalidrawPath ? `/${excalidrawPath.replace(/^\//, '')}/` : '/';
const params = new URLSearchParams({ boardId, apiUrl });
params.set('yjsPort', yjsPort);
if (token) params.set('token', token);
if (isMentor) params.set('isMentor', '1');
return `${origin}${pathname}?${params.toString()}`;
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -128,6 +90,7 @@ function getUserInitials(u) {
function BoardCard({ board, currentUser, onClick }) { function BoardCard({ board, currentUser, onClick }) {
const isMentor = currentUser?.role === 'mentor'; const isMentor = currentUser?.role === 'mentor';
const isGroup = board.access_type === 'group';
const otherPerson = isMentor ? board.student : board.mentor; const otherPerson = isMentor ? board.student : board.mentor;
const otherName = getUserName(otherPerson); const otherName = getUserName(otherPerson);
const otherInitials = getUserInitials(otherPerson); const otherInitials = getUserInitials(otherPerson);
@ -138,30 +101,23 @@ function BoardCard({ board, currentUser, onClick }) {
return ( return (
<Card sx={{ borderRadius: 2, height: '100%' }}> <Card sx={{ borderRadius: 2, height: '100%' }}>
<CardActionArea onClick={onClick} sx={{ height: '100%' }}> <CardActionArea onClick={onClick} sx={{ height: '100%' }}>
{/* Preview area */}
<Box <Box
sx={{ sx={{
height: 140, height: 140,
bgcolor: 'primary.lighter', bgcolor: isGroup ? 'info.lighter' : 'primary.lighter',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
position: 'relative', position: 'relative',
}} }}
> >
<Iconify icon="solar:pen-new-square-bold-duotone" width={48} sx={{ color: 'primary.main', opacity: 0.4 }} /> <Iconify
icon={isGroup ? 'solar:users-group-rounded-bold-duotone' : 'solar:pen-new-square-bold-duotone'}
width={48}
sx={{ color: isGroup ? 'info.main' : 'primary.main', opacity: 0.4 }}
/>
{board.elements_count > 0 && ( {board.elements_count > 0 && (
<Box <Box sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'background.paper', borderRadius: 1, px: 1, py: 0.25 }}>
sx={{
position: 'absolute',
bottom: 8,
right: 8,
bgcolor: 'background.paper',
borderRadius: 1,
px: 1,
py: 0.25,
}}
>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{board.elements_count} элем. {board.elements_count} элем.
</Typography> </Typography>
@ -174,14 +130,23 @@ function BoardCard({ board, currentUser, onClick }) {
{board.title || 'Без названия'} {board.title || 'Без названия'}
</Typography> </Typography>
<Stack direction="row" alignItems="center" spacing={1}> {isGroup ? (
<Avatar sx={{ width: 24, height: 24, fontSize: 11, bgcolor: 'primary.main' }}> <Stack direction="row" alignItems="center" spacing={1}>
{otherInitials} <Iconify icon="solar:users-group-rounded-bold" width={16} sx={{ color: 'info.main' }} />
</Avatar> <Typography variant="caption" color="text.secondary" noWrap>
<Typography variant="caption" color="text.secondary" noWrap> Групповая доска
{isMentor ? 'Ученик: ' : 'Ментор: '}{otherName} </Typography>
</Typography> </Stack>
</Stack> ) : (
<Stack direction="row" alignItems="center" spacing={1}>
<Avatar sx={{ width: 24, height: 24, fontSize: 11, bgcolor: 'primary.main' }}>
{otherInitials}
</Avatar>
<Typography variant="caption" color="text.secondary" noWrap>
{isMentor ? 'Ученик: ' : 'Ментор: '}{otherName}
</Typography>
</Stack>
)}
{lastEdited && ( {lastEdited && (
<Typography variant="caption" color="text.disabled" display="block" mt={0.5}> <Typography variant="caption" color="text.disabled" display="block" mt={0.5}>
@ -203,6 +168,7 @@ function BoardListView({ onOpen }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [students, setStudents] = useState([]); const [students, setStudents] = useState([]);
const [groups, setGroups] = useState([]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@ -220,7 +186,7 @@ function BoardListView({ onOpen }) {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
// Ментор может создать доску с учеником // Ментор: загружаем учеников и группы
useEffect(() => { useEffect(() => {
if (user?.role !== 'mentor') return; if (user?.role !== 'mentor') return;
getMentorStudents() getMentorStudents()
@ -229,6 +195,7 @@ function BoardListView({ onOpen }) {
setStudents(list); setStudents(list);
}) })
.catch(() => {}); .catch(() => {});
getGroups().then(setGroups).catch(() => {});
}, [user?.role]); }, [user?.role]);
const handleCreateWithStudent = async (student) => { const handleCreateWithStudent = async (student) => {
@ -244,6 +211,19 @@ function BoardListView({ onOpen }) {
} }
}; };
const handleOpenGroupBoard = async (group) => {
setCreating(true);
try {
const board = await getOrCreateGroupBoard(group.id);
await load();
onOpen(board.board_id);
} catch (e) {
console.error(e);
} finally {
setCreating(false);
}
};
const excalidrawAvailable = !!( const excalidrawAvailable = !!(
process.env.NEXT_PUBLIC_EXCALIDRAW_URL || process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || process.env.NEXT_PUBLIC_EXCALIDRAW_PATH ||
@ -274,9 +254,25 @@ function BoardListView({ onOpen }) {
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3} flexWrap="wrap" gap={2}> <Stack direction="row" alignItems="center" justifyContent="space-between" mb={3} flexWrap="wrap" gap={2}>
<Typography variant="h4">Доски</Typography> <Typography variant="h4">Доски</Typography>
{user?.role === 'mentor' && students.length > 0 && ( {user?.role === 'mentor' && (
<Stack direction="row" spacing={1} flexWrap="wrap"> <Stack direction="row" spacing={1} flexWrap="wrap">
{students.slice(0, 6).map((s) => { {/* Групповые доски */}
{groups.slice(0, 4).map((g) => (
<Tooltip key={`g-${g.id}`} title={`Открыть доску группы: ${g.name}`}>
<Button
variant="outlined"
size="small"
color="info"
startIcon={<Iconify icon="solar:users-group-rounded-bold" width={16} />}
onClick={() => handleOpenGroupBoard(g)}
disabled={creating}
>
{g.name}
</Button>
</Tooltip>
))}
{/* Индивидуальные доски */}
{students.slice(0, 5).map((s) => {
const name = getUserName(s.user || s); const name = getUserName(s.user || s);
const initials = getUserInitials(s.user || s); const initials = getUserInitials(s.user || s);
return ( return (

View File

@ -1,15 +1,13 @@
// eslint-disable-next-line perfectionist/sort-imports
import 'dayjs/locale/ru'; import 'dayjs/locale/ru';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { z as zod } from 'zod'; import { z as zod } from 'zod';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMemo, useState, useCallback } from 'react'; import { useMemo, useState, useCallback, useEffect } from 'react';
// ----------------------------------------------------------------------
import { useRouter } from 'src/routes/hooks'; import { useRouter } from 'src/routes/hooks';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Alert from '@mui/material/Alert';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
@ -23,14 +21,15 @@ import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import Typography from '@mui/material/Typography';
import DialogContentText from '@mui/material/DialogContentText';
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import { createLiveKitRoom } from 'src/utils/livekit-api'; import { createLiveKitRoom } from 'src/utils/livekit-api';
import { getLesson } from 'src/utils/dashboard-api';
import { useGetStudents, useGetSubjects } from 'src/actions/calendar'; import { useGetStudents, useGetSubjects, useGetGroups } from 'src/actions/calendar';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
import { Form, Field } from 'src/components/hook-form'; import { Form, Field } from 'src/components/hook-form';
@ -56,34 +55,50 @@ export function CalendarForm({
onCreateEvent, onCreateEvent,
onUpdateEvent, onUpdateEvent,
onDeleteEvent, onDeleteEvent,
onCompleteLesson,
}) { }) {
const router = useRouter(); const router = useRouter();
const { students } = useGetStudents(); const { students } = useGetStudents();
const { subjects } = useGetSubjects(); const { subjects } = useGetSubjects();
const { groups } = useGetGroups();
const [joiningVideo, setJoiningVideo] = useState(false); const [joiningVideo, setJoiningVideo] = useState(false);
const [fullLesson, setFullLesson] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [submitError, setSubmitError] = useState(null);
const EventSchema = zod.object({ // individual | group
client: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'), const [lessonType, setLessonType] = useState('individual');
subject: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите предмет'),
const isGroup = lessonType === 'group';
const isEditing = !!currentEvent?.id;
// Dynamic schema based on lesson type
const EventSchema = useMemo(() => zod.object({
client: isGroup
? zod.union([zod.string(), zod.number()]).optional()
: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'),
group: isGroup
? zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите группу')
: zod.union([zod.string(), zod.number()]).optional(),
subject: zod.string().min(1, 'Введите название предмета'),
description: zod.string().max(5000).optional(), description: zod.string().max(5000).optional(),
start_time: zod.any().refine((val) => val !== null, 'Выберите начало занятия'), start_time: zod.any().refine((val) => val !== null, 'Выберите начало занятия'),
duration: zod.number().min(1, 'Выберите длительность'), duration: zod.number().min(1, 'Выберите длительность'),
price: zod.coerce.number().min(0, 'Цена не может быть отрицательной'), price: zod.coerce.number().min(0, 'Цена не может быть отрицательной'),
is_recurring: zod.boolean().optional(), is_recurring: zod.boolean().optional(),
}); }), [isGroup]);
const defaultValues = useMemo(() => { const defaultValues = useMemo(() => ({
const start = currentEvent?.start || range?.start || new Date(); client: '',
return { group: '',
client: currentEvent?.extendedProps?.client?.id || '', subject: '',
subject: currentEvent?.extendedProps?.mentor_subject?.id || '', subjectText: '',
description: currentEvent?.description || '', description: '',
start_time: dayjs(start), start_time: dayjs(range?.start || new Date()),
duration: 60, duration: 60,
price: 1500, price: 1500,
is_recurring: false, is_recurring: false,
}; }), [range]);
}, [currentEvent, range]);
const methods = useForm({ const methods = useForm({
resolver: zodResolver(EventSchema), resolver: zodResolver(EventSchema),
@ -98,36 +113,141 @@ export function CalendarForm({
formState: { isSubmitting }, formState: { isSubmitting },
} = methods; } = methods;
// При открытии формы
useEffect(() => {
if (!open) { setFullLesson(null); setSubmitError(null); return; }
if (!currentEvent?.id) {
setLessonType('individual');
reset({
client: '',
group: '',
subject: '',
description: '',
start_time: dayjs(range?.start || new Date()),
duration: 60,
price: 1500,
is_recurring: false,
});
return;
}
// Редактирование грузим полные данные
getLesson(currentEvent.id)
.then((data) => {
const lesson = data?.data ?? data;
setFullLesson(lesson);
// Определяем тип занятия
const hasGroup = !!lesson.group;
setLessonType(hasGroup ? 'group' : 'individual');
const clientId = lesson.client?.id ?? (typeof lesson.client === 'number' ? lesson.client : '') ?? '';
const groupId = lesson.group?.id ?? (typeof lesson.group === 'number' ? lesson.group : '') ?? '';
const subjectName = lesson.subject_name || lesson.subject || '';
let duration = lesson.duration ?? 60;
if (!lesson.duration && lesson.start_time && lesson.end_time) {
const diff = (new Date(lesson.end_time) - new Date(lesson.start_time)) / 60000;
if (diff > 0) duration = Math.round(diff);
}
reset({
client: clientId,
group: groupId,
subject: subjectName,
description: lesson.description || '',
start_time: dayjs(lesson.start_time || currentEvent.start),
duration,
price: lesson.price ?? 1500,
is_recurring: lesson.is_recurring ?? false,
});
})
.catch(() => {
const ep = currentEvent.extendedProps || {};
const hasGroup = !!ep.group;
setLessonType(hasGroup ? 'group' : 'individual');
reset({
client: typeof ep.client === 'number' ? ep.client : '',
group: ep.group?.id ?? '',
subject: '',
description: ep.description || '',
start_time: dayjs(currentEvent.start),
duration: 60,
price: 1500,
is_recurring: false,
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentEvent?.id, subjects.length]);
// При смене типа сбрасываем ошибки (не триггерим валидацию до сабмита)
useEffect(() => {
methods.clearErrors();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lessonType]);
const values = watch(); const values = watch();
const canJoinLesson = (() => {
if (!currentEvent?.id) return false;
if (currentEvent?.extendedProps?.status === 'completed') return false;
const now = new Date();
const startTime = currentEvent?.start ? new Date(currentEvent.start) : null;
const endTime = currentEvent?.end ? new Date(currentEvent.end) : null;
const started = startTime && startTime <= now;
const endedTooLong = endTime && (now - endTime) / 60000 > 15;
return started && !endedTooLong;
})();
const canComplete = (() => {
if (!currentEvent?.id) return false;
const status = String(currentEvent?.extendedProps?.status || '').toLowerCase();
if (status === 'completed' || status === 'cancelled') return false;
const now = new Date();
const startTime = currentEvent?.start ? new Date(currentEvent.start) : null;
return startTime && startTime <= now;
})();
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
setSubmitError(null);
try { try {
const startTime = dayjs(data.start_time); const startTime = dayjs(data.start_time);
const endTime = startTime.add(data.duration, 'minute'); const endTime = startTime.add(data.duration, 'minute');
// Формируем динамический title: Предмет - Преподаватель - Ученик #ID const subjectName = data.subject || 'Урок';
const selectedSubject = subjects.find(s => s.id === data.subject);
const selectedStudent = students.find(s => s.id === data.client); let displayTitle;
if (isGroup) {
const subjectName = selectedSubject?.name || selectedSubject?.subject_name || 'Урок'; const selectedGroup = groups.find(g => g.id === data.group || g.id === Number(data.group));
const groupName = selectedGroup?.name || `Группа ${data.group}`;
const studentFullName = selectedStudent?.user ? `${selectedStudent.user.last_name} ${selectedStudent.user.first_name}` : (selectedStudent?.full_name || 'Ученик'); const lessonNumber = currentEvent?.id ? `${currentEvent.id}` : '';
displayTitle = `${subjectName}${groupName}${lessonNumber}`.trim();
const mentorFullName = currentEvent?.extendedProps?.mentor ? `${currentEvent.extendedProps.mentor.last_name} ${currentEvent.extendedProps.mentor.first_name}` : 'Ментор'; } else {
const selectedStudent = students.find(s => s.id === data.client);
const lessonNumber = currentEvent?.id ? `${currentEvent.id}` : ''; const studentFullName = selectedStudent?.user
? `${selectedStudent.user.last_name} ${selectedStudent.user.first_name}`
const displayTitle = `${subjectName} ${mentorFullName} - ${studentFullName}${lessonNumber}`.trim(); : (selectedStudent?.full_name || 'Ученик');
const lessonNumber = currentEvent?.id ? `${currentEvent.id}` : '';
displayTitle = `${subjectName}${studentFullName}${lessonNumber}`.trim();
}
// Находим id предмета по названию если есть
const foundSubject = subjects.find(
(s) => (s.name || s.subject_name || '').toLowerCase() === subjectName.toLowerCase()
);
const payload = { const payload = {
title: displayTitle, title: displayTitle,
client: data.client, subject: foundSubject?.id ?? null,
subject: data.subject, subject_name: subjectName,
description: data.description, description: data.description,
start_time: startTime.toISOString(), start_time: startTime.toISOString(),
duration: data.duration, duration: data.duration,
price: data.price, price: data.price,
is_recurring: data.is_recurring, is_recurring: data.is_recurring,
...(isGroup ? { group: data.group } : { client: data.client }),
}; };
if (currentEvent?.id) { if (currentEvent?.id) {
@ -138,7 +258,18 @@ export function CalendarForm({
onClose(); onClose();
reset(); reset();
} catch (error) { } catch (error) {
console.error(error); const errData = error?.response?.data;
const details = errData?.error?.details;
const msg =
errData?.error?.message ||
details?.start_time?.[0] ||
details?.start_time ||
details?.detail ||
details?.non_field_errors?.[0] ||
errData?.detail ||
'Не удалось сохранить занятие';
const raw = Array.isArray(msg) ? msg[0] : String(msg);
setSubmitError(raw.replace(/^\w+:\s*/, ''));
} }
}); });
@ -148,7 +279,7 @@ export function CalendarForm({
try { try {
const room = await createLiveKitRoom(currentEvent.id); const room = await createLiveKitRoom(currentEvent.id);
const token = room.access_token || room.token; const token = room.access_token || room.token;
router.push(`${paths.videoCall}?token=${encodeURIComponent(token)}&lesson_id=${currentEvent.id}`); router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${currentEvent.id}`);
onClose(); onClose();
} catch (e) { } catch (e) {
console.error('LiveKit join error', e); console.error('LiveKit join error', e);
@ -176,64 +307,97 @@ export function CalendarForm({
<DialogContent sx={{ p: 3, pb: 0, overflow: 'unset' }}> <DialogContent sx={{ p: 3, pb: 0, overflow: 'unset' }}>
<Stack spacing={3}> <Stack spacing={3}>
<Field.Select name="client" label="Ученик *">
<MenuItem value="" disabled>Выберите ученика</MenuItem>
{students.map((student) => {
const studentName = student.user?.full_name ||
student.full_name ||
(student.user?.first_name ? `${student.user.first_name} ${student.user.last_name || ''}` : '') ||
student.email ||
`ID: ${student.id}`;
const studentAvatar = student.user?.avatar || student.avatar;
const letter = studentName.charAt(0).toUpperCase();
return ( {/* Тип занятия — только при создании */}
<MenuItem key={student.id} value={student.id}> {!isEditing && (
<Stack direction="row" alignItems="center" spacing={1.5}> <ToggleButtonGroup
<Avatar value={lessonType}
src={studentAvatar} exclusive
sx={{ width: 24, height: 24, fontSize: 12 }} onChange={(_, val) => { if (val) setLessonType(val); }}
> fullWidth
{letter} size="small"
</Avatar> >
<Box component="span">{studentName}</Box> <ToggleButton value="individual">
<Iconify icon="solar:user-bold" width={16} sx={{ mr: 1 }} />
Индивидуальное
</ToggleButton>
<ToggleButton value="group">
<Iconify icon="solar:users-group-rounded-bold" width={16} sx={{ mr: 1 }} />
Групповое
</ToggleButton>
</ToggleButtonGroup>
)}
{/* Ученик или Группа */}
{isGroup ? (
<Field.Select name="group" label="Группа *" disabled={isEditing}>
<MenuItem value="" disabled>Выберите группу</MenuItem>
{groups.map((g) => (
<MenuItem key={g.id} value={g.id}>
<Stack direction="row" alignItems="center" spacing={1}>
<Iconify icon="solar:users-group-rounded-bold" width={18} sx={{ color: 'text.secondary' }} />
<Box component="span">
{g.name}
{g.students_count != null && (
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 1 }}>
({g.students_count} уч.)
</Typography>
)}
</Box>
</Stack> </Stack>
</MenuItem> </MenuItem>
); ))}
})} </Field.Select>
</Field.Select> ) : (
<Field.Select name="client" label="Ученик *" disabled={isEditing}>
<MenuItem value="" disabled>Выберите ученика</MenuItem>
{students.map((student) => {
const studentName = student.user?.full_name ||
student.full_name ||
(student.user?.first_name ? `${student.user.first_name} ${student.user.last_name || ''}` : '') ||
student.email ||
`ID: ${student.id}`;
const studentAvatar = student.user?.avatar || student.avatar;
const letter = studentName.charAt(0).toUpperCase();
return (
<MenuItem key={student.id} value={student.id}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar src={studentAvatar} sx={{ width: 24, height: 24, fontSize: 12 }}>
{letter}
</Avatar>
<Box component="span">{studentName}</Box>
</Stack>
</MenuItem>
);
})}
</Field.Select>
)}
<Field.Select name="subject" label="Предмет *"> <Field.Select name="subject" label="Предмет *">
<MenuItem value="" disabled>Выберите предмет</MenuItem> <MenuItem value="" disabled>Выберите предмет</MenuItem>
{subjects.map((sub) => ( {subjects.map((sub) => (
<MenuItem key={sub.id} value={sub.id}> <MenuItem key={sub.id} value={sub.name || sub.subject_name || sub.title || ''}>
{sub.name || sub.subject_name || sub.title || `Предмет ID: ${sub.id}`} {sub.name || sub.subject_name || sub.title || `Предмет ID: ${sub.id}`}
</MenuItem> </MenuItem>
))} ))}
</Field.Select> </Field.Select>
<Field.Text <Field.Text
name="description" name="description"
label="Описание" label="Описание"
placeholder="Дополнительная информация о занятии" placeholder="Дополнительная информация о занятии"
multiline multiline
rows={2} rows={2}
/> />
<Field.MobileDateTimePicker <Field.MobileDateTimePicker
name="start_time" name="start_time"
label="Начало занятия *" label="Начало занятия *"
format="DD.MM.YYYY HH:mm" format="DD.MM.YYYY HH:mm"
ampm={false} ampm={false}
slotProps={{ slotProps={{
toolbar: { toolbar: { hidden: false },
hidden: false, actionBar: { actions: ['clear', 'cancel', 'accept'] },
},
actionBar: {
actions: ['clear', 'cancel', 'accept'],
},
}} }}
localeText={{ localeText={{
cancelButtonLabel: 'Отмена', cancelButtonLabel: 'Отмена',
@ -244,7 +408,7 @@ export function CalendarForm({
endLabel: 'Конец', endLabel: 'Конец',
toolbarTitle: 'Выберите дату и время', toolbarTitle: 'Выберите дату и время',
}} }}
sx={{ width: 1 }} sx={{ width: 1 }}
/> />
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
@ -268,24 +432,38 @@ export function CalendarForm({
/> />
</Stack> </Stack>
<FormControlLabel {currentEvent?.id ? (
control={ values.is_recurring && (
<Controller <Typography variant="body2" color="text.secondary">
name="is_recurring" Постоянное занятие (повторяется еженедельно)
control={control} </Typography>
render={({ field }) => <Switch {...field} checked={field.value} />} )
/> ) : (
} <FormControlLabel
label="Постоянное занятие (повторяется еженедельно)" control={
sx={{ ml: 0 }} <Controller
/> name="is_recurring"
control={control}
render={({ field }) => <Switch {...field} checked={field.value} />}
/>
}
label="Постоянное занятие (повторяется еженедельно)"
sx={{ ml: 0 }}
/>
)}
</Stack> </Stack>
</DialogContent> </DialogContent>
{submitError && (
<Alert severity="error" onClose={() => setSubmitError(null)} sx={{ mx: 3, mb: 1 }}>
{submitError}
</Alert>
)}
<DialogActions sx={{ p: 3 }}> <DialogActions sx={{ p: 3 }}>
{currentEvent?.id && ( {currentEvent?.id && (
<Tooltip title="Удалить"> <Tooltip title="Удалить">
<IconButton onClick={onDelete} color="error"> <IconButton onClick={() => setConfirmDelete(true)} color="error">
<Iconify icon="solar:trash-bin-trash-bold" /> <Iconify icon="solar:trash-bin-trash-bold" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -293,15 +471,28 @@ export function CalendarForm({
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
{currentEvent?.id && currentEvent?.extendedProps?.status !== 'completed' && ( {canComplete && onCompleteLesson && (
<Tooltip title="Заполнить оценки, комментарий и ДЗ">
<Button
variant="outlined"
color="warning"
onClick={() => onCompleteLesson(currentEvent.id)}
startIcon={<Iconify icon="solar:clipboard-check-bold" width={18} />}
>
Завершить урок
</Button>
</Tooltip>
)}
{canJoinLesson && (
<LoadingButton <LoadingButton
variant="soft" variant="contained"
color="success" color="primary"
loading={joiningVideo} loading={joiningVideo}
onClick={onJoinVideo} onClick={onJoinVideo}
startIcon={<Iconify icon="solar:videocamera-record-bold" />} startIcon={<Iconify icon="solar:videocamera-record-bold" />}
> >
Войти Подключиться
</LoadingButton> </LoadingButton>
)} )}
@ -310,10 +501,27 @@ export function CalendarForm({
</Button> </Button>
<LoadingButton type="submit" variant="contained" loading={isSubmitting}> <LoadingButton type="submit" variant="contained" loading={isSubmitting}>
{currentEvent?.id ? 'Сохранить изменения' : 'Создать'} {currentEvent?.id ? 'Сохранить' : 'Создать'}
</LoadingButton> </LoadingButton>
</DialogActions> </DialogActions>
</Form> </Form>
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)} maxWidth="xs" fullWidth>
<DialogTitle>Удалить занятие?</DialogTitle>
<DialogContent>
<DialogContentText>
Это действие нельзя отменить. Занятие будет безвозвратно удалено.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button variant="outlined" color="inherit" onClick={() => setConfirmDelete(false)}>
Отмена
</Button>
<Button variant="contained" color="error" onClick={() => { setConfirmDelete(false); onDelete(); }}>
Удалить
</Button>
</DialogActions>
</Dialog>
</Dialog> </Dialog>
); );
} }

View File

@ -343,9 +343,14 @@ function CompleteLessonDialog({ lessonId: propLessonId, open: propOpen, onClose:
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function isValidTimezone(tz) {
if (!tz) return false;
try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return true; } catch { return false; }
}
function fTime(str, timezone) { function fTime(str, timezone) {
if (!str) return ''; if (!str) return '';
const opts = { hour: '2-digit', minute: '2-digit', ...(timezone ? { timeZone: timezone } : {}) }; const opts = { hour: '2-digit', minute: '2-digit', ...(isValidTimezone(timezone) ? { timeZone: timezone } : {}) };
return new Date(str).toLocaleTimeString('ru-RU', opts); return new Date(str).toLocaleTimeString('ru-RU', opts);
} }
@ -396,10 +401,12 @@ function UpcomingLessonsBar({ events, isMentor, timezone }) {
const canJoin = diffMin < 11 && diffMin > -90; const canJoin = diffMin < 11 && diffMin > -90;
const isJoining = joiningId === ev.id; const isJoining = joiningId === ev.id;
// Mentor sees student name; student sees mentor name // Групповое занятие показываем название группы, индивидуальное имя ученика/ментора
const personName = isMentor const personName = ep.group_name
? (ep.student || ep.client_name || '') ? ep.group_name
: (ep.mentor_name || ep.mentor || ''); : isMentor
? (ep.student || ep.client_name || '')
: (ep.mentor_name || ep.mentor || '');
const subject = ep.subject_name || ep.subject || ev.title || 'Занятие'; const subject = ep.subject_name || ep.subject || ev.title || 'Занятие';
const initial = (personName[0] || subject[0] || 'З').toUpperCase(); const initial = (personName[0] || subject[0] || 'З').toUpperCase();
@ -604,7 +611,7 @@ export function CalendarView() {
}} }}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]} plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]}
locale={ruLocale} locale={ruLocale}
timeZone={user?.timezone || 'local'} timeZone={isValidTimezone(user?.timezone) ? user.timezone : 'local'}
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
/> />

File diff suppressed because it is too large Load Diff

View File

@ -20,14 +20,13 @@ export function View403() {
<Container component={MotionContainer}> <Container component={MotionContainer}>
<m.div variants={varBounce().in}> <m.div variants={varBounce().in}>
<Typography variant="h3" sx={{ mb: 2 }}> <Typography variant="h3" sx={{ mb: 2 }}>
No permission Доступ запрещён
</Typography> </Typography>
</m.div> </m.div>
<m.div variants={varBounce().in}> <m.div variants={varBounce().in}>
<Typography sx={{ color: 'text.secondary' }}> <Typography sx={{ color: text.secondary }}>
The page youre trying to access has restricted access. Please refer to your system У вас нет прав для просмотра этой страницы.
administrator.
</Typography> </Typography>
</m.div> </m.div>
@ -36,7 +35,7 @@ export function View403() {
</m.div> </m.div>
<Button component={RouterLink} href="/" size="large" variant="contained"> <Button component={RouterLink} href="/" size="large" variant="contained">
Go to home На главную
</Button> </Button>
</Container> </Container>
</SimpleLayout> </SimpleLayout>

View File

@ -20,13 +20,13 @@ export function View500() {
<Container component={MotionContainer}> <Container component={MotionContainer}>
<m.div variants={varBounce().in}> <m.div variants={varBounce().in}>
<Typography variant="h3" sx={{ mb: 2 }}> <Typography variant="h3" sx={{ mb: 2 }}>
500 Internal server error 500 Ошибка сервера
</Typography> </Typography>
</m.div> </m.div>
<m.div variants={varBounce().in}> <m.div variants={varBounce().in}>
<Typography sx={{ color: 'text.secondary' }}> <Typography sx={{ color: 'text.secondary' }}>
There was an error, please try again later. Что-то пошло не так. Пожалуйста, попробуйте позже.
</Typography> </Typography>
</m.div> </m.div>
@ -35,7 +35,7 @@ export function View500() {
</m.div> </m.div>
<Button component={RouterLink} href="/" size="large" variant="contained"> <Button component={RouterLink} href="/" size="large" variant="contained">
Go to home На главную
</Button> </Button>
</Container> </Container>
</SimpleLayout> </SimpleLayout>

View File

@ -20,14 +20,13 @@ export function NotFoundView() {
<Container component={MotionContainer}> <Container component={MotionContainer}>
<m.div variants={varBounce().in}> <m.div variants={varBounce().in}>
<Typography variant="h3" sx={{ mb: 2 }}> <Typography variant="h3" sx={{ mb: 2 }}>
Sorry, page not found! Страница не найдена
</Typography> </Typography>
</m.div> </m.div>
<m.div variants={varBounce().in}> <m.div variants={varBounce().in}>
<Typography sx={{ color: 'text.secondary' }}> <Typography sx={{ color: text.secondary }}>
Sorry, we couldnt find the page youre looking for. Perhaps youve mistyped the URL? Be Такой страницы не существует. Возможно, вы ошиблись в адресе или страница была удалена.
sure to check your spelling.
</Typography> </Typography>
</m.div> </m.div>
@ -36,7 +35,7 @@ export function NotFoundView() {
</m.div> </m.div>
<Button component={RouterLink} href="/" size="large" variant="contained"> <Button component={RouterLink} href="/" size="large" variant="contained">
Go to home На главную
</Button> </Button>
</Container> </Container>
</SimpleLayout> </SimpleLayout>

View File

@ -17,6 +17,7 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { resolveMediaUrl } from 'src/utils/axios';
import { import {
getMySubmission, getMySubmission,
gradeSubmission, gradeSubmission,
@ -24,20 +25,13 @@ import {
checkSubmissionWithAi, checkSubmissionWithAi,
getHomeworkSubmissions, getHomeworkSubmissions,
returnSubmissionForRevision, returnSubmissionForRevision,
} from 'src/utils/homework-api'; } from 'src/utils/homework-api';
import { CONFIG } from 'src/config-global';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function fileUrl(href) { const fileUrl = (href) => resolveMediaUrl(href);
if (!href) return '';
if (href.startsWith('http://') || href.startsWith('https://')) return href;
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
return base + (href.startsWith('/') ? href : `/${href}`);
}
function formatDateTime(s) { function formatDateTime(s) {
if (!s) return '—'; if (!s) return '—';
@ -465,6 +459,11 @@ export function HomeworkDetailsDrawer({ open, homework, userRole, childId, onClo
> >
<Box sx={{ flex: 1, pr: 2 }}> <Box sx={{ flex: 1, pr: 2 }}>
<Typography variant="h6">{homework.title}</Typography> <Typography variant="h6">{homework.title}</Typography>
{isMentor && Array.isArray(homework.assigned_to) && homework.assigned_to.length > 1 && (
<Typography variant="caption" color="primary" sx={{ display: 'block' }}>
Групповое ДЗ {homework.assigned_to.length} учеников
</Typography>
)}
{homework.deadline && ( {homework.deadline && (
<Typography variant="caption" color={homework.is_overdue ? 'error' : 'text.secondary'}> <Typography variant="caption" color={homework.is_overdue ? 'error' : 'text.secondary'}>
Дедлайн: {formatDateTime(homework.deadline)} Дедлайн: {formatDateTime(homework.deadline)}

View File

@ -12,20 +12,14 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress'; import LinearProgress from '@mui/material/LinearProgress';
import { resolveMediaUrl } from 'src/utils/axios';
import { submitHomework, getHomeworkById, validateHomeworkFiles } from 'src/utils/homework-api'; import { submitHomework, getHomeworkById, validateHomeworkFiles } from 'src/utils/homework-api';
import { CONFIG } from 'src/config-global';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function fileUrl(href) { const fileUrl = (href) => resolveMediaUrl(href);
if (!href) return '';
if (href.startsWith('http://') || href.startsWith('https://')) return href;
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
return base + (href.startsWith('/') ? href : `/${href}`);
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------

View File

@ -1 +1,2 @@
export * from './homework-view'; export * from './homework-view';
export * from './homework-detail-view';

File diff suppressed because it is too large Load Diff

View File

@ -18,16 +18,16 @@ import IconButton from '@mui/material/IconButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import { import {
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
getNotifications, getNotifications,
deleteNotification, deleteNotification,
} from 'src/utils/notifications-api'; } from 'src/utils/notifications-api';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
@ -140,11 +140,32 @@ export function NotificationsView() {
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>} {error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}> <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
<Tab value="unread" label={ <Tab
<Badge badgeContent={unreadCount} color="error"> value="unread"
<Box sx={{ pr: unreadCount > 0 ? 1.5 : 0 }}>Непрочитанные</Box> label={
</Badge> <Stack direction="row" spacing={0.75} alignItems="center">
} /> <span>Непрочитанные</span>
{unreadCount > 0 && (
<Box
sx={{
px: 0.75,
py: 0.1,
borderRadius: 1,
bgcolor: 'error.main',
color: 'white',
fontSize: 11,
fontWeight: 700,
lineHeight: '18px',
minWidth: 18,
textAlign: 'center',
}}
>
{unreadCount}
</Box>
)}
</Stack>
}
/>
<Tab value="all" label="Все" /> <Tab value="all" label="Все" />
</Tabs> </Tabs>

View File

@ -11,6 +11,7 @@ import { useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import { useRouter } from 'src/routes/hooks'; import { useRouter } from 'src/routes/hooks';
import { CONFIG } from 'src/config-global'; import { CONFIG } from 'src/config-global';
import { varAlpha } from 'src/theme/styles'; import { varAlpha } from 'src/theme/styles';
@ -58,7 +59,7 @@ function UpcomingLessonCard({ lesson }) {
setJoining(true); setJoining(true);
const res = await createLiveKitRoom(lesson.id); const res = await createLiveKitRoom(lesson.id);
const token = res?.access_token || res?.token; const token = res?.access_token || res?.token;
router.push(`/video-call?token=${token}&lesson_id=${lesson.id}`); router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${lesson.id}`);
} catch (err) { } catch (err) {
console.error('Join error:', err); console.error('Join error:', err);
setJoining(false); setJoining(false);
@ -230,6 +231,13 @@ export function OverviewCourseView() {
fetchData(period); fetchData(period);
}, [period, fetchData]); }, [period, fetchData]);
// Периодическое обновление ближайших занятий (каждые 60 сек) чтобы кнопка «Подключиться»
// появлялась автоматически, когда до занятия остаётся 10 минут
useEffect(() => {
const id = setInterval(() => fetchData(period), 60_000);
return () => clearInterval(id);
}, [period, fetchData]);
if (loading && !stats) { if (loading && !stats) {
return ( return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}> <Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
@ -294,19 +302,19 @@ export function OverviewCourseView() {
> >
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: { xs: 'column', lg: 'row' } }}> <Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: { xs: 'column', lg: 'row' } }}>
{/* ЛЕВАЯ ЧАСТЬ */} {/* ЛЕВАЯ ЧАСТЬ */}
<Box sx={{ gap: 3, display: 'flex', flex: '1 1 auto', flexDirection: 'column', px: { xs: 2, sm: 3, xl: 5 }, py: 3, borderRight: { lg: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}` } }}> <Box sx={{ gap: 3, display: 'flex', flex: '1 1 auto', minWidth: 0, flexDirection: 'column', px: { xs: 2, sm: 3, xl: 5 }, py: 3, borderRight: { lg: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}` } }}>
<Box sx={{ gap: 3, display: 'grid', gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' } }}> <Box sx={{ gap: 3, display: 'grid', gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' } }}>
<CourseWidgetSummary title="Ученики" total={Number(stats?.summary?.total_clients || 0)} icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-progress.svg`} /> <CourseWidgetSummary title="Ученики" total={Number(stats?.summary?.total_clients || 0)} icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-progress.svg`} />
<CourseWidgetSummary title="Занятий на неделе" total={lessonsThisWeek} color="info" icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-certificates.svg`} /> <CourseWidgetSummary title="Занятий на неделе" total={lessonsThisWeek} color="info" icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-certificates.svg`} />
<CourseWidgetSummary title="На проверку" total={Number(stats?.summary?.pending_submissions || 0)} color="error" icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-completed.svg`} /> <CourseWidgetSummary title="На проверку" total={Number(stats?.summary?.pending_submissions || 0)} color="error" icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-completed.svg`} />
</Box> </Box>
<Box sx={{ gap: 3, display: 'grid', gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' } }}> <Box sx={{ gap: 3, display: 'grid', gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' }, overflow: 'hidden' }}>
<Stack spacing={3}> <Stack spacing={3} sx={{ minWidth: 0, overflow: 'hidden' }}>
<CourseWidgetSummary title="Доход (мес / всего)" total={revenueDisplay} color="success" icon={`${CONFIG.site.basePath}/assets/icons/glass/ic-glass-bag.svg`} /> <CourseWidgetSummary title="Доход (мес / всего)" total={revenueDisplay} color="success" icon={`${CONFIG.site.basePath}/assets/icons/glass/ic-glass-bag.svg`} />
{incomeChart && <CourseHoursSpent title="Статистика дохода" value={period} chart={{ ...incomeChart, colors: [theme.palette.success.main], options: { tooltip: { y: { formatter: (val) => `${val}` } } } }} onValueChange={(val) => setPeriod(val)} />} {incomeChart && <CourseHoursSpent title="Статистика дохода" value={period} chart={{ ...incomeChart, colors: [theme.palette.success.main], options: { tooltip: { y: { formatter: (val) => `${val}` } } } }} onValueChange={(val) => setPeriod(val)} />}
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3} sx={{ minWidth: 0, overflow: 'hidden' }}>
<CourseWidgetSummary title="Занятия (мес / всего)" total={lessonsDisplay} color="warning" icon={`${CONFIG.site.basePath}/assets/icons/glass/ic-glass-buy.svg`} /> <CourseWidgetSummary title="Занятия (мес / всего)" total={lessonsDisplay} color="warning" icon={`${CONFIG.site.basePath}/assets/icons/glass/ic-glass-buy.svg`} />
{lessonsChart && <CourseHoursSpent title="Статистика занятий" value={period} chart={{ ...lessonsChart, colors: [theme.palette.warning.main], options: { tooltip: { y: { formatter: (val) => `${val} занятий` } } } }} onValueChange={(val) => setPeriod(val)} />} {lessonsChart && <CourseHoursSpent title="Статистика занятий" value={period} chart={{ ...lessonsChart, colors: [theme.palette.warning.main], options: { tooltip: { y: { formatter: (val) => `${val} занятий` } } } }} onValueChange={(val) => setPeriod(val)} />}
</Stack> </Stack>
@ -316,7 +324,7 @@ export function OverviewCourseView() {
</Box> </Box>
{/* ПРАВАЯ ЧАСТЬ */} {/* ПРАВАЯ ЧАСТЬ */}
<Box sx={{ width: 1, display: 'flex', flexDirection: 'column', px: { xs: 2, sm: 3, xl: 5 }, pt: { lg: 8, xl: 10 }, pb: { xs: 8, xl: 10 }, flexShrink: 0, gap: 5, maxWidth: { lg: 320, xl: 360 }, bgcolor: { lg: 'background.neutral' } }}> <Box sx={{ width: 1, display: 'flex', flexDirection: 'column', px: { xs: 2, sm: 3, xl: 5 }, pt: 3, pb: { xs: 8, xl: 10 }, flexShrink: 0, gap: 5, maxWidth: { lg: 320, xl: 360 }, bgcolor: { lg: 'background.neutral' } }}>
{/* Ближайшие уроки */} {/* Ближайшие уроки */}
<Stack spacing={1.5}> <Stack spacing={1.5}>
<Stack direction="row" alignItems="center" justifyContent="space-between"> <Stack direction="row" alignItems="center" justifyContent="space-between">

View File

@ -5,72 +5,607 @@ import { useState, useEffect, useCallback } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import Avatar from '@mui/material/Avatar';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import axios from 'src/utils/axios'; import axios from 'src/utils/axios';
import { DashboardContent } from 'src/layouts/dashboard'; import { varAlpha } from 'src/theme/styles';
import { AvatarShape } from 'src/assets/illustrations';
import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
async function getPaymentInfo() { async function getPlans() {
const res = await axios.get('/payments/subscriptions/'); const res = await axios.get('/subscriptions/plans/');
const {data} = res; const { data } = res;
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
return data?.results ?? []; return data?.results ?? [];
} }
async function getPaymentHistory() { async function getActiveSubscription() {
const res = await axios.get('/payments/history/'); const res = await axios.get('/subscriptions/subscriptions/active/');
const {data} = res; return res.data ?? null;
if (Array.isArray(data)) return data; }
return data?.results ?? [];
async function createSubscription(planId, durationDays) {
const res = await axios.post('/subscriptions/subscriptions/', {
plan_id: planId,
duration_days: durationDays,
});
return res.data;
}
async function cancelSubscription(id) {
const res = await axios.post(`/subscriptions/subscriptions/${id}/cancel/`);
return res.data;
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const STATUS_LABELS = {
trial: { label: 'Пробная', color: 'info' },
active: { label: 'Активна', color: 'success' },
past_due: { label: 'Просрочена', color: 'warning' },
cancelled: { label: 'Отменена', color: 'error' },
expired: { label: 'Истекла', color: 'error' },
};
const BILLING_LABELS = {
monthly: 'мес',
quarterly: 'кварт',
yearly: 'год',
lifetime: 'навсегда',
};
function getPeriodLabel(plan) {
if (plan.duration_days) return `${plan.duration_days} дн.`;
return BILLING_LABELS[plan.billing_period] || '—';
}
function getPriceLabel(plan) {
if (Number(plan.price) === 0) return 'Бесплатно';
return `${plan.price}`;
}
const FEATURE_ICONS = {
video_calls: { icon: 'eva:video-outline', label: 'Видеозвонки' },
screen_sharing: { icon: 'eva:monitor-outline', label: 'Демонстрация экрана' },
whiteboard: { icon: 'eva:edit-2-outline', label: 'Интерактивная доска' },
homework: { icon: 'eva:book-open-outline', label: 'Домашние задания' },
materials: { icon: 'eva:folder-outline', label: 'Материалы' },
analytics: { icon: 'eva:bar-chart-outline', label: 'Аналитика' },
telegram_bot: { icon: 'eva:paper-plane-outline', label: 'Telegram-бот' },
api_access: { icon: 'eva:code-outline', label: 'API-доступ' },
};
const PLAN_GRADIENTS = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
];
const PLAN_ICONS = [
'eva:star-outline',
'eva:award-outline',
'eva:flash-outline',
'eva:trending-up-outline',
'eva:shield-outline',
'eva:layers-outline',
];
// Special gradient for active subscription card
const ACTIVE_GRADIENT = 'linear-gradient(135deg, #0cebeb 0%, #20e3b2 50%, #29ffc6 100%)';
function formatDate(ts) { function formatDate(ts) {
if (!ts) return '—'; if (!ts) return '—';
try { try {
return new Date(ts).toLocaleDateString('ru-RU'); return new Date(ts).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
} catch { } catch {
return ts; return ts;
} }
} }
function formatAmount(amount, currency) { function formatStorage(mb) {
if (amount == null) return '—'; if (!mb) return '—';
return `${amount} ${currency || '₽'}`; if (mb >= 1024) return `${(mb / 1024).toFixed(0)} ГБ`;
return `${mb} МБ`;
}
// ----------------------------------------------------------------------
function UsageBar({ label, used, limit, unlimited, unit = '' }) {
if (unlimited) {
return (
<Stack spacing={0.5}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="caption" color="success.main"></Typography>
</Stack>
<LinearProgress variant="determinate" value={0} color="success" sx={{ height: 4, borderRadius: 2 }} />
</Stack>
);
}
if (!limit) return null;
const pct = Math.min(Math.round((used / limit) * 100), 100);
const color = pct >= 90 ? 'error' : pct >= 70 ? 'warning' : 'primary';
return (
<Stack spacing={0.5}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="caption" color="text.secondary">
{used ?? 0}{unit} / {limit}{unit}
</Typography>
</Stack>
<LinearProgress variant="determinate" value={pct} color={color} sx={{ height: 4, borderRadius: 2 }} />
</Stack>
);
}
// ----------------------------------------------------------------------
// Shared banner/avatar block used by both cards
function CardBanner({ gradient, icon, badgeTop, badgeTopRight }) {
return (
<Box sx={{ position: 'relative' }}>
<AvatarShape
sx={{
left: 0,
right: 0,
zIndex: 10,
mx: 'auto',
bottom: -26,
position: 'absolute',
}}
/>
<Avatar
sx={{
width: 64,
height: 64,
zIndex: 11,
left: 0,
right: 0,
bottom: -32,
mx: 'auto',
position: 'absolute',
bgcolor: 'background.paper',
boxShadow: (theme) => `0 8px 16px 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.24)}`,
}}
>
<Iconify
icon={icon}
width={32}
sx={{
background: gradient,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
/>
</Avatar>
{badgeTopRight && (
<Box sx={{ position: 'absolute', top: 12, right: 12, zIndex: 20 }}>
{badgeTopRight}
</Box>
)}
<Box
sx={{
height: 120,
background: gradient,
position: 'relative',
overflow: 'hidden',
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
background: (theme) => varAlpha(theme.vars.palette.grey['900Channel'], 0.24),
},
}}
>
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
border: '1px solid',
borderColor: (theme) => varAlpha(theme.vars.palette.common.whiteChannel, 0.24),
position: 'absolute',
top: -16,
left: -16,
}}
/>
<Box
sx={{
width: 120,
height: 120,
borderRadius: '50%',
border: '1px solid',
borderColor: (theme) => varAlpha(theme.vars.palette.common.whiteChannel, 0.16),
position: 'absolute',
bottom: -32,
right: -24,
}}
/>
</Box>
</Box>
);
}
// ----------------------------------------------------------------------
function ActiveSubscriptionCard({ subscription, onCancel, cancelling }) {
const plan = subscription.plan || {};
const status = STATUS_LABELS[subscription.status] || { label: subscription.status, color: 'default' };
const usage = subscription.usage || {};
const features = plan.features || {};
const activeFeatures = Object.entries(FEATURE_ICONS).filter(([key]) => features[key]);
return (
<Card sx={{ textAlign: 'center', position: 'relative' }}>
<CardBanner
gradient={ACTIVE_GRADIENT}
icon="eva:award-fill"
badgeTopRight={
<Chip label={status.label} color={status.color} size="small" />
}
/>
{/* Name */}
<Box sx={{ mt: 7, mb: 0.5, px: 2 }}>
<Typography variant="subtitle1">{plan.name || 'Моя подписка'}</Typography>
{plan.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{plan.description}
</Typography>
)}
</Box>
{/* Days left */}
{subscription.days_left != null && (
<Box sx={{ mb: 1 }}>
<Chip
label={
subscription.days_left <= 0
? 'Истекла'
: `${subscription.days_left} дн. осталось`
}
size="small"
color={subscription.days_left <= 7 ? 'error' : 'default'}
variant="soft"
/>
</Box>
)}
{/* Features */}
{activeFeatures.length > 0 && (
<Stack spacing={0.5} sx={{ px: 3, py: 1, textAlign: 'left' }}>
{activeFeatures.map(([key, { label }]) => (
<Stack key={key} direction="row" spacing={1} alignItems="center">
<Iconify icon="eva:checkmark-circle-2-fill" width={16} color="success.main" />
<Typography variant="caption">{label}</Typography>
</Stack>
))}
</Stack>
)}
{/* Usage bars */}
{Object.keys(usage).length > 0 && (
<Stack spacing={1} sx={{ px: 2.5, pb: 1.5, textAlign: 'left' }}>
{usage.lessons && (
<UsageBar
label="Занятия"
used={usage.lessons.used}
limit={usage.lessons.limit}
unlimited={usage.lessons.unlimited}
/>
)}
{usage.storage && (
<UsageBar
label="Хранилище"
used={usage.storage.used_mb}
limit={usage.storage.limit_mb}
unlimited={false}
/>
)}
{usage.video_minutes && (
<UsageBar
label="Видео (мин)"
used={usage.video_minutes.used}
limit={usage.video_minutes.limit}
unlimited={usage.video_minutes.unlimited}
unit=" мин"
/>
)}
</Stack>
)}
<Divider sx={{ borderStyle: 'dashed', mx: 2 }} />
{/* Stats grid */}
<Box
display="grid"
gridTemplateColumns="repeat(3, 1fr)"
sx={{ py: 2.5, px: 1, typography: 'subtitle1' }}
>
<Box>
<Typography variant="caption" component="div" sx={{ mb: 0.5, color: 'text.secondary' }}>
Начало
</Typography>
<Typography variant="caption" fontWeight={600}>
{subscription.start_date
? new Date(subscription.start_date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
: '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" component="div" sx={{ mb: 0.5, color: 'text.secondary' }}>
Окончание
</Typography>
<Typography variant="caption" fontWeight={600}>
{subscription.end_date
? new Date(subscription.end_date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
: '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" component="div" sx={{ mb: 0.5, color: 'text.secondary' }}>
Период
</Typography>
<Typography variant="caption" fontWeight={600}>
{getPeriodLabel(plan)}
</Typography>
</Box>
</Box>
<Divider sx={{ borderStyle: 'dashed', mx: 2 }} />
<Box sx={{ px: 2.5, py: 2.5 }}>
{['active', 'trial'].includes(subscription.status) ? (
<Button
variant="outlined"
color="error"
fullWidth
onClick={() => onCancel(subscription.id)}
disabled={cancelling}
startIcon={
cancelling
? <Iconify icon="svg-spinners:8-dots-rotate" />
: <Iconify icon="eva:close-circle-outline" />
}
>
Отменить
</Button>
) : (
<Button variant="outlined" fullWidth disabled>
Неактивна
</Button>
)}
</Box>
</Card>
);
}
// ----------------------------------------------------------------------
function PlanCard({ plan, isCurrent, onSubscribe, subscribing, index }) {
const isPerStudent = plan.subscription_type === 'per_student';
const features = plan.features || {};
const activeFeatures = Object.entries(FEATURE_ICONS).filter(([key]) => features[key]);
const gradient = PLAN_GRADIENTS[index % PLAN_GRADIENTS.length];
const planIcon = PLAN_ICONS[index % PLAN_ICONS.length];
return (
<Card sx={{ textAlign: 'center', position: 'relative' }}>
<CardBanner
gradient={gradient}
icon={planIcon}
badgeTopRight={
plan.is_featured
? <Chip label="Рекомендуем" color="primary" size="small" />
: isCurrent
? <Chip label="Текущий" color="success" size="small" />
: null
}
/>
<Box sx={{ mt: 7, mb: 0.5, px: 2 }}>
<Typography variant="subtitle1">{plan.name}</Typography>
{plan.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{plan.description}
</Typography>
)}
</Box>
{plan.trial_days > 0 && (
<Box sx={{ mb: 1 }}>
<Chip label={`${plan.trial_days} дней бесплатно`} size="small" color="success" variant="soft" />
</Box>
)}
<Stack spacing={0.5} sx={{ px: 3, py: 1.5, textAlign: 'left' }}>
{activeFeatures.map(([key, { label }]) => (
<Stack key={key} direction="row" spacing={1} alignItems="center">
<Iconify icon="eva:checkmark-circle-2-fill" width={16} color="success.main" />
<Typography variant="caption">{label}</Typography>
</Stack>
))}
</Stack>
{isPerStudent && plan.bulk_discounts?.length > 0 && (
<Box sx={{ mx: 2, mb: 1.5, p: 1.5, bgcolor: 'background.neutral', borderRadius: 1, textAlign: 'left' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Прогрессивные скидки:
</Typography>
{plan.bulk_discounts.map((d, i) => (
// eslint-disable-next-line react/no-array-index-key
<Stack key={i} direction="row" justifyContent="space-between">
<Typography variant="caption">
{d.min_students}{d.max_students ? `${d.max_students}` : '+'} уч.
</Typography>
<Typography variant="caption" color="primary.main">
{d.price_per_student} /уч.
</Typography>
</Stack>
))}
</Box>
)}
<Divider sx={{ borderStyle: 'dashed', mx: 2 }} />
<Box
display="grid"
gridTemplateColumns="repeat(3, 1fr)"
sx={{ py: 2.5, px: 1 }}
>
<Box>
<Typography variant="caption" component="div" sx={{ mb: 0.5, color: 'text.secondary' }}>
Ученики
</Typography>
<Typography variant="subtitle2">
{plan.max_clients != null ? plan.max_clients : '∞'}
</Typography>
</Box>
<Box>
<Typography variant="caption" component="div" sx={{ mb: 0.5, color: 'text.secondary' }}>
Занятий/мес
</Typography>
<Typography variant="subtitle2">
{plan.max_lessons_per_month != null ? plan.max_lessons_per_month : '∞'}
</Typography>
</Box>
<Box>
<Typography variant="caption" component="div" sx={{ mb: 0.5, color: 'text.secondary' }}>
Хранилище
</Typography>
<Typography variant="subtitle2">
{formatStorage(plan.max_storage_mb)}
</Typography>
</Box>
</Box>
<Divider sx={{ borderStyle: 'dashed', mx: 2 }} />
<Box sx={{ px: 2.5, py: 2.5 }}>
<Typography variant="h5" sx={{ mb: 1.5 }}>
{isPerStudent
? <>{plan.price_per_student ?? plan.price} <Typography component="span" variant="body2" color="text.secondary"> / ученик</Typography></>
: Number(plan.price) === 0
? <Typography component="span" color="success.main">Бесплатно</Typography>
: <>{plan.price} <Typography component="span" variant="body2" color="text.secondary"> / {getPeriodLabel(plan)}</Typography></>
}
</Typography>
<Button
variant={plan.is_featured ? 'contained' : 'outlined'}
fullWidth
disabled={isCurrent || subscribing}
onClick={() => onSubscribe(plan)}
startIcon={
subscribing
? <Iconify icon="svg-spinners:8-dots-rotate" />
: isCurrent
? <Iconify icon="eva:checkmark-fill" />
: <Iconify icon="eva:flash-outline" />
}
sx={
plan.is_featured
? { background: gradient, '&:hover': { background: gradient, filter: 'brightness(0.9)' } }
: {}
}
>
{isCurrent ? 'Текущий план' : plan.trial_days > 0 ? 'Начать бесплатно' : 'Подключить'}
</Button>
</Box>
</Card>
);
}
// ----------------------------------------------------------------------
function ConfirmSubscribeDialog({ open, plan, onConfirm, onClose, loading }) {
if (!plan) return null;
return (
<Dialog open={open} onClose={loading ? undefined : onClose} maxWidth="xs" fullWidth>
<DialogTitle>Подключить тариф</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 1 }}>
Вы выбрали тариф <strong>{plan.name}</strong>.
</Typography>
{plan.trial_days > 0 && (
<Alert severity="info" sx={{ mb: 1 }}>
Первые {plan.trial_days} дней бесплатно
</Alert>
)}
<Typography variant="body2" color="text.secondary">
{plan.subscription_type === 'per_student'
? `Цена: ${plan.price_per_student ?? plan.price} ₽ за ученика`
: Number(plan.price) === 0
? 'Бесплатно'
: `Цена: ${plan.price} ₽ / ${getPeriodLabel(plan)}`}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Отмена</Button>
<Button
variant="contained"
onClick={onConfirm}
disabled={loading}
startIcon={loading ? <Iconify icon="svg-spinners:8-dots-rotate" /> : null}
>
Подтвердить
</Button>
</DialogActions>
</Dialog>
);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function PaymentPlatformView() { export function PaymentPlatformView() {
const [subscriptions, setSubscriptions] = useState([]); const [plans, setPlans] = useState([]);
const [history, setHistory] = useState([]); const [activeSub, setActiveSub] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [cancelling, setCancelling] = useState(false);
const [subscribingPlan, setSubscribingPlan] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [subscribing, setSubscribing] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const [subs, hist] = await Promise.all([ setError(null);
getPaymentInfo().catch(() => []), const [plansRes, subRes] = await Promise.allSettled([
getPaymentHistory().catch(() => []), getPlans(),
getActiveSubscription(),
]); ]);
setSubscriptions(subs); if (plansRes.status === 'fulfilled') setPlans(plansRes.value);
setHistory(hist); if (subRes.status === 'fulfilled') setActiveSub(subRes.value);
} catch (e) { } catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки'); setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally { } finally {
@ -78,9 +613,46 @@ export function PaymentPlatformView() {
} }
}, []); }, []);
useEffect(() => { useEffect(() => { load(); }, [load]);
load();
}, [load]); const handleCancel = async (id) => {
try {
setCancelling(true);
setError(null);
await cancelSubscription(id);
setSuccess('Подписка отменена');
await load();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка отмены');
} finally {
setCancelling(false);
}
};
const handleSubscribeClick = (plan) => {
setSubscribingPlan(plan);
setConfirmOpen(true);
};
const handleSubscribeConfirm = async () => {
if (!subscribingPlan) return;
try {
setSubscribing(true);
setError(null);
await createSubscription(subscribingPlan.id, subscribingPlan.duration_days ?? null);
setSuccess(`Тариф «${subscribingPlan.name}» подключён`);
setConfirmOpen(false);
setSubscribingPlan(null);
await load();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка подключения');
} finally {
setSubscribing(false);
}
};
// Build the full card list: active subscription first (if exists), then plans
const totalCols = 3;
return ( return (
<DashboardContent> <DashboardContent>
@ -90,116 +662,66 @@ export function PaymentPlatformView() {
sx={{ mb: 3 }} sx={{ mb: 3 }}
/> />
{error && ( {error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
<Alert severity="error" sx={{ mb: 3 }}> {success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
{error}
</Alert>
)}
{loading ? ( {loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : ( ) : (
<Stack spacing={3}> <Box
{/* Subscriptions */} sx={{
{subscriptions.length > 0 ? ( display: 'grid',
<Card variant="outlined"> gap: 3,
<CardContent> gridTemplateColumns: {
<Typography variant="subtitle1" sx={{ mb: 2 }}> xs: '1fr',
Активные подписки sm: 'repeat(2, 1fr)',
</Typography> md: `repeat(${totalCols}, 1fr)`,
<Stack spacing={1.5}> },
{subscriptions.map((sub, idx) => ( alignItems: 'start',
// eslint-disable-next-line react/no-array-index-key }}
<Box key={idx}> >
{idx > 0 && <Divider sx={{ mb: 1.5 }} />} {/* Active subscription — always 1 column */}
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}> {activeSub ? (
<Box> <ActiveSubscriptionCard
<Typography variant="subtitle2"> subscription={activeSub}
{sub.plan_name || sub.name || 'Подписка'} onCancel={handleCancel}
</Typography> cancelling={cancelling}
{sub.expires_at && ( />
<Typography variant="caption" color="text.secondary">
До {formatDate(sub.expires_at)}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} alignItems="center">
{sub.status && (
<Chip
label={sub.status}
size="small"
color={sub.status === 'active' ? 'success' : 'default'}
/>
)}
{sub.price != null && (
<Typography variant="subtitle2">
{formatAmount(sub.price, sub.currency)}
</Typography>
)}
</Stack>
</Stack>
</Box>
))}
</Stack>
</CardContent>
</Card>
) : ( ) : (
<Card variant="outlined"> <Card sx={{ textAlign: 'center' }}>
<CardContent> <CardContent sx={{ py: 5 }}>
<Stack alignItems="center" spacing={2} sx={{ py: 3 }}> <Iconify icon="eva:credit-card-outline" width={48} color="text.disabled" sx={{ mb: 1 }} />
<Iconify icon="eva:credit-card-outline" width={48} color="text.disabled" /> <Typography variant="subtitle1" sx={{ mb: 0.5 }}>Нет подписки</Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Нет активных подписок Выберите тариф справа
</Typography> </Typography>
<Button variant="contained" startIcon={<Iconify icon="eva:plus-fill" />}>
Подключить подписку
</Button>
</Stack>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Payment history */} {/* Plan cards */}
{history.length > 0 && ( {plans.map((plan, index) => (
<Card variant="outlined"> <PlanCard
<CardContent> key={plan.id}
<Typography variant="subtitle1" sx={{ mb: 2 }}> plan={plan}
История платежей index={index}
</Typography> isCurrent={activeSub?.plan?.id === plan.id}
<Stack spacing={1}> onSubscribe={handleSubscribeClick}
{history.map((item, idx) => ( subscribing={subscribing && subscribingPlan?.id === plan.id}
// eslint-disable-next-line react/no-array-index-key />
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 0.75 }}> ))}
<Box> </Box>
<Typography variant="body2">
{item.description || item.plan_name || 'Платёж'}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(item.created_at || item.date)}
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="subtitle2">
{formatAmount(item.amount, item.currency)}
</Typography>
{item.status && (
<Chip
label={item.status}
size="small"
color={item.status === 'success' || item.status === 'paid' ? 'success' : 'default'}
/>
)}
</Stack>
</Stack>
))}
</Stack>
</CardContent>
</Card>
)}
</Stack>
)} )}
<ConfirmSubscribeDialog
open={confirmOpen}
plan={subscribingPlan}
onConfirm={handleSubscribeConfirm}
onClose={() => { setConfirmOpen(false); setSubscribingPlan(null); }}
loading={subscribing}
/>
</DashboardContent> </DashboardContent>
); );
} }

View File

@ -2,139 +2,154 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Table from '@mui/material/Table';
import Tabs from '@mui/material/Tabs';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import TableRow from '@mui/material/TableRow';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import LinearProgress from '@mui/material/LinearProgress';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import { getMyReferrals, getReferralStats, getReferralProfile } from 'src/utils/referrals-api'; import {
getReferralStats,
getReferralLevels,
getMyReferrals,
getBonusBalance,
getBonusTransactions,
getReferralEarnings,
} from 'src/utils/referrals-api';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function StatCard({ label, value, icon, color }) { function StatCard({ label, value, icon, color = 'primary' }) {
return ( return (
<Card variant="outlined" sx={{ flex: 1, minWidth: 140 }}> <Card sx={{ flex: 1, minWidth: 150 }}>
<CardContent> <CardContent>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}> <Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<Box <Box sx={{
sx={{ width: 44, height: 44, borderRadius: 1.5,
width: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
height: 40, bgcolor: `${color}.lighter`,
borderRadius: 1.5, }}>
display: 'flex', <Iconify icon={icon} width={24} color={`${color}.main`} />
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${color}.lighter`,
}}
>
<Iconify icon={icon} width={22} color={`${color}.main`} />
</Box> </Box>
<Typography variant="h5" fontWeight="bold"> <Typography variant="h4" fontWeight={700}>{value ?? '—'}</Typography>
{value ?? '—'}
</Typography>
</Stack> </Stack>
<Typography variant="caption" color="text.secondary"> <Typography variant="body2" color="text.secondary">{label}</Typography>
{label}
</Typography>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function ReferralTable({ title, items }) { function CopyField({ label, value }) {
if (!items || items.length === 0) return null; const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return ( return (
<Box> <TextField
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}> label={label}
{title} value={value}
</Typography> fullWidth
<Stack spacing={1}> size="small"
{items.map((item, idx) => ( InputProps={{
// eslint-disable-next-line react/no-array-index-key readOnly: true,
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1, bgcolor: 'background.neutral', borderRadius: 1 }}> endAdornment: (
<Typography variant="body2">{item.email}</Typography> <InputAdornment position="end">
<Stack direction="row" spacing={1} alignItems="center"> <Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
<Chip label={item.level} size="small" variant="outlined" /> <IconButton size="small" onClick={handleCopy}>
<Typography variant="caption" color="text.secondary"> <Iconify icon={copied ? 'eva:checkmark-circle-2-fill' : 'eva:copy-outline'}
{item.total_points} pts color={copied ? 'success.main' : 'inherit'} />
</Typography> </IconButton>
</Stack> </Tooltip>
</Stack> </InputAdornment>
))} ),
</Stack> }}
</Box> />
); );
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function ReferralsView() { export function ReferralsView() {
const [profile, setProfile] = useState(null); const [tab, setTab] = useState(0);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [levels, setLevels] = useState([]);
const [referrals, setReferrals] = useState(null); const [referrals, setReferrals] = useState(null);
const [bonusBalance, setBonusBalance] = useState(null);
const [bonusTxns, setBonusTxns] = useState([]);
const [earnings, setEarnings] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [copied, setCopied] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const [p, s, r] = await Promise.all([ const [s, l, r, b, bt, e] = await Promise.all([
getReferralProfile(),
getReferralStats(), getReferralStats(),
getMyReferrals().catch(() => null), getReferralLevels(),
getMyReferrals(),
getBonusBalance(),
getBonusTransactions(),
getReferralEarnings(),
]); ]);
setProfile(p);
setStats(s); setStats(s);
setLevels(Array.isArray(l) ? l : []);
setReferrals(r); setReferrals(r);
} catch (e) { setBonusBalance(b);
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки'); setBonusTxns(Array.isArray(bt) ? bt : []);
setEarnings(Array.isArray(e) ? e : []);
} catch (err) {
setError(err?.message || 'Ошибка загрузки');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => { load(); }, [load]);
load();
}, [load]);
const handleCopyLink = () => { const referralCode = stats?.referral_code || '';
const link = profile?.referral_link || stats?.referral_code || ''; const referralLink = referralCode
if (!link) return; ? `${window.location.origin}/auth/jwt/sign-up?ref=${referralCode}`
navigator.clipboard.writeText(link).then(() => { : '';
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleCopyCode = () => { const currentLevel = stats?.current_level;
const code = profile?.referral_code || stats?.referral_code || ''; const nextLevelObj = levels.find((l) => l.level === (currentLevel?.level ?? 0) + 1);
if (!code) return; const progressPct = nextLevelObj
navigator.clipboard.writeText(code).then(() => { ? Math.min(Math.round(((stats?.total_points ?? 0) / nextLevelObj.points_required) * 100), 100)
setCopied(true); : 100;
setTimeout(() => setCopied(false), 2000);
});
};
const referralCode = profile?.referral_code || stats?.referral_code || ''; if (loading) return (
const referralLink = profile?.referral_link || ''; <DashboardContent>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>
</DashboardContent>
);
return ( return (
<DashboardContent> <DashboardContent>
@ -144,155 +159,290 @@ export function ReferralsView() {
sx={{ mb: 3 }} sx={{ mb: 3 }}
/> />
{error && ( {error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading ? ( <Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={3}>
{/* Stats */}
{stats && (
<Stack direction="row" flexWrap="wrap" gap={2}>
<StatCard
label="Прямые рефералы"
value={stats.referrals?.direct}
icon="eva:people-outline"
color="primary"
/>
<StatCard
label="Непрямые рефералы"
value={stats.referrals?.indirect}
icon="eva:person-add-outline"
color="info"
/>
<StatCard
label="Всего баллов"
value={stats.total_points}
icon="eva:star-outline"
color="warning"
/>
<StatCard
label="Заработано"
value={stats.earnings?.total !== undefined ? `${stats.earnings.total}` : '—'}
icon="eva:credit-card-outline"
color="success"
/>
<StatCard
label="Бонусный баланс"
value={stats.bonus_account?.balance !== undefined ? `${stats.bonus_account.balance}` : '—'}
icon="eva:gift-outline"
color="error"
/>
</Stack>
)}
{/* Level */} {/* Stats */}
{stats?.current_level && ( <Stack direction="row" flexWrap="wrap" gap={2}>
<Card variant="outlined"> <StatCard label="Прямые рефералы" value={stats?.referrals?.direct ?? 0}
<CardContent> icon="solar:users-group-rounded-bold" color="primary" />
<Stack direction="row" alignItems="center" spacing={2}> <StatCard label="Непрямые рефералы" value={stats?.referrals?.indirect ?? 0}
<Iconify icon="eva:award-outline" width={32} color="warning.main" /> icon="solar:user-plus-bold" color="info" />
<Box> <StatCard label="Всего очков" value={stats?.total_points ?? 0}
<Typography variant="subtitle1"> icon="solar:star-bold" color="warning" />
Уровень {stats.current_level.level} {stats.current_level.name} <StatCard label="Заработано" value={stats?.earnings?.total != null ? `${stats.earnings.total}` : '0 ₽'}
</Typography> icon="solar:card-bold" color="success" />
<Typography variant="body2" color="text.secondary"> <StatCard label="Бонусный баланс" value={bonusBalance?.balance != null ? `${bonusBalance.balance}` : '0 ₽'}
Ваш текущий реферальный уровень icon="solar:gift-bold" color="error" />
</Typography>
</Box>
</Stack>
</CardContent>
</Card>
)}
{/* Referral code & link */}
{referralCode && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Ваш реферальный код
</Typography>
<Stack spacing={2}>
<TextField
label="Реферальный код"
value={referralCode}
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
<IconButton onClick={handleCopyCode} size="small">
<Iconify icon={copied ? 'eva:checkmark-outline' : 'eva:copy-outline'} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
fullWidth
size="small"
/>
{referralLink && (
<TextField
label="Реферальная ссылка"
value={referralLink}
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
<IconButton onClick={handleCopyLink} size="small">
<Iconify icon={copied ? 'eva:checkmark-outline' : 'eva:copy-outline'} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
fullWidth
size="small"
/>
)}
<Button
variant="outlined"
startIcon={<Iconify icon="eva:share-outline" />}
onClick={handleCopyLink || handleCopyCode}
sx={{ alignSelf: 'flex-start' }}
>
Поделиться
</Button>
</Stack>
</CardContent>
</Card>
)}
{/* Referrals list */}
{referrals && (referrals.direct?.length > 0 || referrals.indirect?.length > 0) && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Мои рефералы
</Typography>
<Stack spacing={3}>
<ReferralTable title="Прямые" items={referrals.direct} />
{referrals.direct?.length > 0 && referrals.indirect?.length > 0 && <Divider />}
<ReferralTable title="Непрямые" items={referrals.indirect} />
</Stack>
</CardContent>
</Card>
)}
{!referralCode && !loading && (
<Typography variant="body1" color="text.secondary">
Реферальная программа недоступна
</Typography>
)}
</Stack> </Stack>
)}
{/* Level progress */}
{currentLevel && (
<Card>
<CardContent>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
<Box sx={{
width: 52, height: 52, borderRadius: '50%',
bgcolor: 'warning.lighter', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Iconify icon="solar:medal-ribbons-star-bold" width={28} color="warning.main" />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1">
Уровень {currentLevel.level} {currentLevel.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{stats?.total_points ?? 0} очков
{nextLevelObj ? ` / ${nextLevelObj.points_required} до следующего уровня` : ' — максимальный уровень'}
</Typography>
</Box>
{currentLevel.bonus_payment_percent > 0 && (
<Chip label={`До ${currentLevel.bonus_payment_percent}% бонусами`} color="warning" size="small" />
)}
</Stack>
{nextLevelObj && (
<LinearProgress variant="determinate" value={progressPct}
sx={{ height: 8, borderRadius: 4, bgcolor: 'warning.lighter',
'& .MuiLinearProgress-bar': { bgcolor: 'warning.main' } }} />
)}
</CardContent>
</Card>
)}
{/* Referral code & link */}
{referralCode && (
<Card>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>Ваш реферальный код</Typography>
<Stack spacing={2}>
<CopyField label="Реферальный код" value={referralCode} />
<CopyField label="Реферальная ссылка" value={referralLink} />
<Typography variant="caption" color="text.secondary">
Поделитесь ссылкой когда новый пользователь зарегистрируется по ней,
вы получите бонусные очки и процент с его платежей.
</Typography>
</Stack>
</CardContent>
</Card>
)}
{/* Tabs: Рефералы / Уровни / История */}
<Card>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ px: 2, pt: 1 }}>
<Tab label="Мои рефералы" />
<Tab label="Уровни" />
<Tab label="История начислений" />
<Tab label="Бонусный баланс" />
</Tabs>
<Divider />
{/* Рефералы */}
{tab === 0 && (
<CardContent>
{(!referrals?.direct?.length && !referrals?.indirect?.length) ? (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Iconify icon="solar:users-group-rounded-bold" width={48} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Пока нет рефералов. Поделитесь ссылкой!
</Typography>
</Box>
) : (
<Stack spacing={3}>
{referrals?.direct?.length > 0 && (
<>
<Typography variant="subtitle2" color="text.secondary">Прямые ({referrals.direct.length})</Typography>
<Stack spacing={1}>
{referrals.direct.map((r, i) => (
// eslint-disable-next-line react/no-array-index-key
<Stack key={i} direction="row" alignItems="center" spacing={2}
sx={{ p: 1.5, bgcolor: 'background.neutral', borderRadius: 1 }}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: 14 }}>
{(r.email?.[0] || '?').toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ flex: 1 }}>{r.email}</Typography>
<Chip label={`Ур. ${r.level}`} size="small" color="primary" variant="soft" />
<Typography variant="caption" color="text.secondary">{r.total_points} pts</Typography>
</Stack>
))}
</Stack>
</>
)}
{referrals?.indirect?.length > 0 && (
<>
{referrals?.direct?.length > 0 && <Divider />}
<Typography variant="subtitle2" color="text.secondary">Непрямые ({referrals.indirect.length})</Typography>
<Stack spacing={1}>
{referrals.indirect.map((r, i) => (
// eslint-disable-next-line react/no-array-index-key
<Stack key={i} direction="row" alignItems="center" spacing={2}
sx={{ p: 1.5, bgcolor: 'background.neutral', borderRadius: 1 }}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'info.main', fontSize: 14 }}>
{(r.email?.[0] || '?').toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ flex: 1 }}>{r.email}</Typography>
<Chip label={`Ур. ${r.level}`} size="small" color="info" variant="soft" />
<Typography variant="caption" color="text.secondary">{r.total_points} pts</Typography>
</Stack>
))}
</Stack>
</>
)}
</Stack>
)}
</CardContent>
)}
{/* Уровни */}
{tab === 1 && (
<CardContent>
{levels.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 3, textAlign: 'center' }}>
Информация об уровнях недоступна
</Typography>
) : (
<Stack spacing={1.5}>
{levels.map((lvl) => {
const isActive = currentLevel?.level === lvl.level;
return (
<Stack key={lvl.level} direction="row" alignItems="center" spacing={2}
sx={{
p: 2, borderRadius: 1.5,
bgcolor: isActive ? 'warning.lighter' : 'background.neutral',
border: isActive ? '1.5px solid' : '1.5px solid transparent',
borderColor: isActive ? 'warning.main' : 'transparent',
}}>
<Box sx={{
width: 40, height: 40, borderRadius: '50%',
bgcolor: isActive ? 'warning.main' : 'background.paper',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '2px solid', borderColor: isActive ? 'warning.dark' : 'divider',
}}>
<Typography variant="subtitle2" color={isActive ? 'white' : 'text.secondary'}>
{lvl.level}
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2">{lvl.name}</Typography>
<Typography variant="caption" color="text.secondary">
От {lvl.points_required} очков
</Typography>
</Box>
{lvl.bonus_payment_percent > 0 && (
<Chip label={`До ${lvl.bonus_payment_percent}% бонусами`}
size="small" color={isActive ? 'warning' : 'default'} variant="soft" />
)}
{isActive && <Chip label="Текущий" size="small" color="warning" />}
</Stack>
);
})}
</Stack>
)}
</CardContent>
)}
{/* История начислений */}
{tab === 2 && (
<CardContent>
{earnings.length === 0 ? (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Iconify icon="solar:card-bold" width={48} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">Нет начислений</Typography>
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Дата</TableCell>
<TableCell>Реферал</TableCell>
<TableCell>Уровень</TableCell>
<TableCell align="right">Сумма</TableCell>
</TableRow>
</TableHead>
<TableBody>
{earnings.map((e, i) => (
// eslint-disable-next-line react/no-array-index-key
<TableRow key={i}>
<TableCell>
<Typography variant="caption">
{e.created_at ? new Date(e.created_at).toLocaleDateString('ru-RU') : '—'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{e.referred_user_email || '—'}</Typography>
</TableCell>
<TableCell>
<Chip label={e.level === 1 ? 'Прямой' : 'Непрямой'}
size="small" color={e.level === 1 ? 'primary' : 'info'} variant="soft" />
</TableCell>
<TableCell align="right">
<Typography variant="body2" color="success.main" fontWeight={600}>
+{e.amount}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
)}
{/* Бонусный баланс */}
{tab === 3 && (
<CardContent>
{bonusBalance && (
<Stack direction="row" gap={2} flexWrap="wrap" sx={{ mb: 3 }}>
<StatCard label="Текущий баланс" value={`${bonusBalance.balance}`}
icon="solar:wallet-bold" color="success" />
<StatCard label="Всего заработано" value={`${bonusBalance.total_earned}`}
icon="solar:card-recive-bold" color="info" />
<StatCard label="Потрачено" value={`${bonusBalance.total_spent}`}
icon="solar:card-send-bold" color="warning" />
</Stack>
)}
{bonusTxns.length === 0 ? (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">Нет транзакций</Typography>
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Дата</TableCell>
<TableCell>Описание</TableCell>
<TableCell align="right">Сумма</TableCell>
</TableRow>
</TableHead>
<TableBody>
{bonusTxns.map((t, i) => (
// eslint-disable-next-line react/no-array-index-key
<TableRow key={i}>
<TableCell>
<Typography variant="caption">
{t.created_at ? new Date(t.created_at).toLocaleDateString('ru-RU') : '—'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{t.description || t.transaction_type || '—'}</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2"
color={t.transaction_type === 'earn' ? 'success.main' : 'error.main'}
fontWeight={600}>
{t.transaction_type === 'earn' ? '+' : '-'}{t.amount}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
)}
</Card>
</Stack>
</DashboardContent> </DashboardContent>
); );
} }

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import Tab from '@mui/material/Tab'; import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
@ -37,6 +37,7 @@ import { resolveMediaUrl } from 'src/utils/axios';
import { import {
getStudents, getStudents,
getMyMentors, getMyMentors,
getMyRequests,
getMyInvitations, getMyInvitations,
addStudentInvitation, addStudentInvitation,
generateInvitationLink, generateInvitationLink,
@ -46,6 +47,8 @@ import {
rejectInvitationAsStudent, rejectInvitationAsStudent,
confirmInvitationAsStudent, confirmInvitationAsStudent,
getMentorshipRequestsPending, getMentorshipRequestsPending,
removeStudent,
removeMentor,
} from 'src/utils/students-api'; } from 'src/utils/students-api';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';
@ -63,6 +66,53 @@ function initials(firstName, lastName) {
return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase(); return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase();
} }
// ----------------------------------------------------------------------
function RemoveConnectionDialog({ open, onClose, onConfirm, name, type, loading }) {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>
{type === 'student' ? `Удалить ученика ${name}?` : `Удалить ментора ${name}?`}
</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
Это действие нельзя отменить автоматически
</Alert>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
При удалении произойдёт следующее:
</Typography>
<Stack spacing={0.75}>
{[
'Все будущие занятия будут отменены',
'Доступ к общим доскам будет приостановлен',
'Доступ к материалам будет закрыт',
].map((item) => (
<Stack key={item} direction="row" spacing={1} alignItems="flex-start">
<Iconify icon="eva:close-circle-fill" width={16} sx={{ color: 'error.main', mt: 0.3, flexShrink: 0 }} />
<Typography variant="body2">{item}</Typography>
</Stack>
))}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
<strong>Доски и файлы не удаляются.</strong> Если связь будет восстановлена доступ вернётся автоматически.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Отмена</Button>
<Button
variant="contained"
color="error"
onClick={onConfirm}
disabled={loading}
startIcon={loading ? <CircularProgress size={16} /> : null}
>
{loading ? 'Удаление...' : 'Удалить'}
</Button>
</DialogActions>
</Dialog>
);
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// MENTOR VIEWS // MENTOR VIEWS
@ -72,6 +122,23 @@ function MentorStudentList({ onRefresh }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [removeTarget, setRemoveTarget] = useState(null); // { id, name }
const [removing, setRemoving] = useState(false);
const handleRemove = async () => {
try {
setRemoving(true);
await removeStudent(removeTarget.id);
setRemoveTarget(null);
load();
onRefresh?.();
} catch (e) {
setError(e?.response?.data?.error?.message || e?.message || 'Ошибка удаления');
setRemoveTarget(null);
} finally {
setRemoving(false);
}
};
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -129,12 +196,11 @@ function MentorStudentList({ onRefresh }) {
return ( return (
<Grid item key={s.id} xs={12} sm={6} md={4} lg={3}> <Grid item key={s.id} xs={12} sm={6} md={4} lg={3}>
<Card <Card
onClick={() => router.push(paths.dashboard.studentDetail(s.id))}
sx={{ sx={{
p: 3, p: 3,
textAlign: 'center', textAlign: 'center',
height: '100%', height: '100%',
cursor: 'pointer', position: 'relative',
transition: 'box-shadow 0.2s, transform 0.2s', transition: 'box-shadow 0.2s, transform 0.2s',
'&:hover': { '&:hover': {
transform: 'translateY(-2px)', transform: 'translateY(-2px)',
@ -142,47 +208,49 @@ function MentorStudentList({ onRefresh }) {
}, },
}} }}
> >
<Avatar <Tooltip title="Удалить ученика">
src={avatarUrl(u.avatar_url || u.avatar)} <IconButton
sx={{ size="small"
width: 72, color="error"
height: 72, onClick={(e) => { e.stopPropagation(); setRemoveTarget({ id: s.id, name }); }}
mx: 'auto', sx={{ position: 'absolute', top: 8, right: 8 }}
mb: 2, >
fontSize: 26, <Iconify icon="solar:trash-bin-trash-bold" width={18} />
bgcolor: 'primary.main', </IconButton>
}} </Tooltip>
>
{initials(u.first_name, u.last_name)}
</Avatar>
<Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}> <Box onClick={() => router.push(paths.dashboard.studentDetail(s.id))} sx={{ cursor: 'pointer' }}>
{name} <Avatar
</Typography> src={avatarUrl(u.avatar_url || u.avatar)}
<Typography variant="body2" color="text.secondary" noWrap sx={{ mb: 2 }}> sx={{ width: 72, height: 72, mx: 'auto', mb: 2, fontSize: 26, bgcolor: 'primary.main' }}
{u.email || ''} >
</Typography> {initials(u.first_name, u.last_name)}
</Avatar>
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap> <Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}>{name}</Typography>
{s.total_lessons != null && ( <Typography variant="body2" color="text.secondary" noWrap sx={{ mb: 2 }}>{u.email || ''}</Typography>
<Chip <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
label={`${s.total_lessons} уроков`} {s.total_lessons != null && (
size="small" <Chip label={`${s.total_lessons} уроков`} size="small" color="primary" variant="soft"
color="primary" icon={<Iconify icon="solar:calendar-bold" width={14} />} />
variant="soft" )}
icon={<Iconify icon="solar:calendar-bold" width={14} />} {s.subject && <Chip label={s.subject} size="small" variant="outlined" />}
/> </Stack>
)} </Box>
{s.subject && (
<Chip label={s.subject} size="small" variant="outlined" />
)}
</Stack>
</Card> </Card>
</Grid> </Grid>
); );
})} })}
</Grid> </Grid>
)} )}
<RemoveConnectionDialog
open={!!removeTarget}
onClose={() => setRemoveTarget(null)}
onConfirm={handleRemove}
name={removeTarget?.name || ''}
type="student"
loading={removing}
/>
</Stack> </Stack>
); );
} }
@ -279,56 +347,30 @@ function MentorRequests({ onRefresh }) {
// 8 отдельных полей для ввода кода // 8 отдельных полей для ввода кода
function CodeInput({ value, onChange, disabled }) { function CodeInput({ value, onChange, disabled }) {
const refs = useRef([]); const handleChange = (e) => {
const chars = (value || '').padEnd(8, '').split('').slice(0, 8); const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
onChange(v);
const handleChange = (i, v) => {
const ch = v.toUpperCase().replace(/[^A-Z0-9]/g, '');
const next = chars.slice();
next[i] = ch[0] || '';
onChange(next.join('').trimEnd());
if (ch && i < 7) refs.current[i + 1]?.focus();
};
const handleKeyDown = (i, e) => {
if (e.key === 'Backspace' && !chars[i] && i > 0) refs.current[i - 1]?.focus();
if (e.key === 'ArrowLeft' && i > 0) refs.current[i - 1]?.focus();
if (e.key === 'ArrowRight' && i < 7) refs.current[i + 1]?.focus();
};
const handlePaste = (e) => {
e.preventDefault();
const text = e.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
onChange(text);
refs.current[Math.min(text.length, 7)]?.focus();
}; };
return ( return (
<Stack direction="row" spacing={0.5} justifyContent="center"> <TextField
{chars.map((ch, i) => ( value={value}
<TextField onChange={handleChange}
key={i} disabled={disabled}
inputRef={(el) => { refs.current[i] = el; }} fullWidth
value={ch} placeholder="ABCD1234"
onChange={(e) => handleChange(i, e.target.value)} autoComplete="off"
onKeyDown={(e) => handleKeyDown(i, e)} inputProps={{
onPaste={i === 0 ? handlePaste : undefined} maxLength: 8,
disabled={disabled} style: {
inputProps={{ textAlign: 'center',
maxLength: 1, fontWeight: 700,
style: { fontSize: 22,
textAlign: 'center', letterSpacing: 6,
fontWeight: 700, textTransform: 'uppercase',
fontSize: 18, },
letterSpacing: 0, }}
padding: '10px 0', />
width: 32,
},
}}
sx={{ width: 40 }}
/>
))}
</Stack>
); );
} }
@ -508,14 +550,32 @@ function InviteDialog({ open, onClose, onSuccess }) {
function ClientMentorList() { function ClientMentorList() {
const [mentors, setMentors] = useState([]); const [mentors, setMentors] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [removeTarget, setRemoveTarget] = useState(null);
const [removing, setRemoving] = useState(false);
useEffect(() => { const load = useCallback(() => {
setLoading(true);
getMyMentors() getMyMentors()
.then((list) => setMentors(Array.isArray(list) ? list : [])) .then((list) => setMentors(Array.isArray(list) ? list : []))
.catch(() => setMentors([])) .catch(() => setMentors([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => { load(); }, [load]);
const handleRemove = async () => {
try {
setRemoving(true);
await removeMentor(removeTarget.id);
setRemoveTarget(null);
load();
} catch (e) {
setRemoveTarget(null);
} finally {
setRemoving(false);
}
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>; if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
if (mentors.length === 0) { if (mentors.length === 0) {
@ -528,47 +588,59 @@ function ClientMentorList() {
} }
return ( return (
<Grid container spacing={2}> <>
{mentors.map((m) => { <Grid container spacing={2}>
const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—'; {mentors.map((m) => {
return ( const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
<Grid item key={m.id} xs={12} sm={6} md={4} lg={3}> return (
<Card <Grid item key={m.id} xs={12} sm={6} md={4} lg={3}>
sx={{ <Card
p: 3,
textAlign: 'center',
height: '100%',
transition: 'box-shadow 0.2s, transform 0.2s',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: (t) => t.customShadows?.z16 || '0 8px 24px rgba(0,0,0,0.12)',
},
}}
>
<Avatar
src={avatarUrl(m.avatar_url)}
sx={{ sx={{
width: 72, p: 3,
height: 72, textAlign: 'center',
mx: 'auto', height: '100%',
mb: 2, position: 'relative',
fontSize: 26, transition: 'box-shadow 0.2s, transform 0.2s',
bgcolor: 'secondary.main', '&:hover': {
transform: 'translateY(-2px)',
boxShadow: (t) => t.customShadows?.z16 || '0 8px 24px rgba(0,0,0,0.12)',
},
}} }}
> >
{initials(m.first_name, m.last_name)} <Tooltip title="Удалить ментора">
</Avatar> <IconButton
<Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}> size="small"
{name} color="error"
</Typography> onClick={() => setRemoveTarget({ id: m.id, name })}
<Typography variant="body2" color="text.secondary" noWrap> sx={{ position: 'absolute', top: 8, right: 8 }}
{m.email || ''} >
</Typography> <Iconify icon="solar:trash-bin-trash-bold" width={18} />
</Card> </IconButton>
</Grid> </Tooltip>
);
})} <Avatar
</Grid> src={avatarUrl(m.avatar_url)}
sx={{ width: 72, height: 72, mx: 'auto', mb: 2, fontSize: 26, bgcolor: 'secondary.main' }}
>
{initials(m.first_name, m.last_name)}
</Avatar>
<Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}>{name}</Typography>
<Typography variant="body2" color="text.secondary" noWrap>{m.email || ''}</Typography>
</Card>
</Grid>
);
})}
</Grid>
<RemoveConnectionDialog
open={!!removeTarget}
onClose={() => setRemoveTarget(null)}
onConfirm={handleRemove}
name={removeTarget?.name || ''}
type="mentor"
loading={removing}
/>
</>
); );
} }
@ -582,7 +654,7 @@ function ClientInvitations({ onRefresh }) {
try { try {
setLoading(true); setLoading(true);
const res = await getMyInvitations(); const res = await getMyInvitations();
setInvitations(Array.isArray(res) ? res.filter((i) => i.status === 'pending') : []); setInvitations(Array.isArray(res) ? res.filter((i) => ['pending', 'pending_student', 'pending_parent'].includes(i.status)) : []);
} catch { } catch {
setInvitations([]); setInvitations([]);
} finally { } finally {
@ -606,8 +678,14 @@ function ClientInvitations({ onRefresh }) {
} }
}; };
if (loading) return null; if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
if (invitations.length === 0) return null;
if (invitations.length === 0) return (
<Box sx={{ py: 8, textAlign: 'center' }}>
<Iconify icon="solar:letter-bold" width={48} sx={{ color: 'text.disabled', mb: 2 }} />
<Typography variant="body2" color="text.secondary">Нет входящих приглашений</Typography>
</Box>
);
return ( return (
<Card variant="outlined" sx={{ mb: 3 }}> <Card variant="outlined" sx={{ mb: 3 }}>
@ -698,6 +776,63 @@ function SendRequestDialog({ open, onClose, onSuccess }) {
); );
} }
const STATUS_LABELS = {
pending_mentor: { label: 'Ожидает ментора', color: 'warning' },
pending_student: { label: 'Ожидает вас', color: 'info' },
accepted: { label: 'Принято', color: 'success' },
rejected: { label: 'Отклонено', color: 'error' },
removed: { label: 'Удалено', color: 'default' },
};
function ClientOutgoingRequests() {
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getMyRequests()
.then((data) => setRequests(Array.isArray(data) ? data : []))
.catch(() => setRequests([]))
.finally(() => setLoading(false));
}, []);
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
if (requests.length === 0) return (
<Box sx={{ py: 8, textAlign: 'center' }}>
<Iconify icon="solar:outbox-bold" width={48} sx={{ color: 'text.disabled', mb: 2 }} />
<Typography variant="body2" color="text.secondary">Нет исходящих заявок</Typography>
</Box>
);
return (
<Card variant="outlined">
<CardHeader title="Исходящие заявки к менторам" titleTypographyProps={{ variant: 'subtitle1' }} />
<Divider />
<List disablePadding>
{requests.map((req) => {
const m = req.mentor || {};
const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
const statusInfo = STATUS_LABELS[req.status] || { label: req.status, color: 'default' };
return (
<ListItem key={req.id} divider>
<ListItemAvatar>
<Avatar src={avatarUrl(m.avatar_url)} sx={{ bgcolor: 'secondary.main' }}>
{initials(m.first_name, m.last_name)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={name}
secondary={m.email}
/>
<Chip label={statusInfo.label} color={statusInfo.color} size="small" variant="soft" />
</ListItem>
);
})}
</List>
</Card>
);
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function StudentsView() { export function StudentsView() {
@ -742,8 +877,14 @@ export function StudentsView() {
</> </>
) : ( ) : (
<> <>
<ClientInvitations key={refreshKey} onRefresh={refresh} /> <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
<ClientMentorList key={refreshKey} /> <Tab label="Мои менторы" />
<Tab label="Входящие приглашения" />
<Tab label="Исходящие заявки" />
</Tabs>
{tab === 0 && <ClientMentorList key={refreshKey} />}
{tab === 1 && <ClientInvitations key={refreshKey} onRefresh={refresh} />}
{tab === 2 && <ClientOutgoingRequests key={refreshKey} />}
<SendRequestDialog open={requestOpen} onClose={() => setRequestOpen(false)} onSuccess={refresh} /> <SendRequestDialog open={requestOpen} onClose={() => setRequestOpen(false)} onSuccess={refresh} />
</> </>
)} )}

View File

@ -19,11 +19,19 @@ import {
ConnectionStateToast, ConnectionStateToast,
} from '@livekit/components-react'; } from '@livekit/components-react';
import { getLesson } from 'src/utils/dashboard-api'; import { paths } from 'src/routes/paths';
import { getOrCreateLessonBoard } from 'src/utils/board-api'; import { getLesson, completeLesson } from 'src/utils/dashboard-api';
import { getLiveKitConfig, participantConnected } from 'src/utils/livekit-api'; import { buildExcalidrawSrc, getOrCreateLessonBoard } from 'src/utils/board-api';
import { getLiveKitConfig, participantConnected, terminateRoom } from 'src/utils/livekit-api';
import { createChat, normalizeChat } from 'src/utils/chat-api';
import { ExitLessonModal } from 'src/sections/video-call/livekit/exit-lesson-modal'; // Payload sent via LiveKit DataChannel to force-disconnect all participants
const TERMINATE_MSG = JSON.stringify({ type: 'room_terminate' });
import { Iconify } from 'src/components/iconify';
import { ChatWindow } from 'src/sections/chat/chat-window';
import { NavMobile } from 'src/layouts/dashboard/nav-mobile';
import { getNavData } from 'src/layouts/config-nav-dashboard';
import { useAuthContext } from 'src/auth/hooks'; import { useAuthContext } from 'src/auth/hooks';
@ -151,11 +159,16 @@ function StartAudioOverlay() {
}; };
return ( return (
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.75)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: 24 }}> <div style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20 }}>
<p style={{ color: '#fff', fontSize: 18, textAlign: 'center', margin: 0 }}>Чтобы слышать собеседника, разрешите воспроизведение звука</p> <div style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', borderRadius: 20, padding: '32px 40px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20, maxWidth: 360, textAlign: 'center' }}>
<button type="button" onClick={handleClick} style={{ padding: '16px 32px', fontSize: 18, fontWeight: 600, borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer' }}> <div style={{ width: 56, height: 56, borderRadius: '50%', background: 'rgba(25,118,210,0.2)', border: '1px solid rgba(25,118,210,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
🔊 Разрешить звук <Iconify icon="solar:volume-loud-bold" width={28} style={{ color: '#60a5fa' }} />
</button> </div>
<p style={{ color: '#fff', fontSize: 16, margin: 0, lineHeight: 1.5 }}>Чтобы слышать собеседника, разрешите воспроизведение звука</p>
<button type="button" onClick={handleClick} style={{ padding: '12px 28px', fontSize: 14, fontWeight: 600, borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer', letterSpacing: '0.02em' }}>
Разрешить звук
</button>
</div>
</div> </div>
); );
} }
@ -167,7 +180,7 @@ function RemoteParticipantPiP({ chatOpen }) {
const remoteRef = tracks.find((ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal); const remoteRef = tracks.find((ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal);
if (!remoteRef || !isTrackReference(remoteRef)) return null; if (!remoteRef || !isTrackReference(remoteRef)) return null;
return ( return (
<div style={{ position: 'fixed', bottom: 24, right: chatOpen ? CHAT_PANEL_WIDTH + 24 : 24, width: 280, height: 158, zIndex: 10000, borderRadius: 12, overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', border: '2px solid rgba(255,255,255,0.2)', background: '#000' }}> <div style={{ position: 'fixed', bottom: 96, right: chatOpen ? CHAT_PANEL_WIDTH + 16 : 16, width: 220, height: 124, zIndex: 10000, borderRadius: 12, overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.1)', background: '#000' }}>
<ParticipantTile trackRef={remoteRef} /> <ParticipantTile trackRef={remoteRef} />
</div> </div>
); );
@ -175,16 +188,20 @@ function RemoteParticipantPiP({ chatOpen }) {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function WhiteboardIframe({ boardId, showingBoard }) { function WhiteboardIframe({ boardId, showingBoard, user }) {
const excalidrawUrl = process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '';
const iframeRef = useRef(null); const iframeRef = useRef(null);
const excalidrawConfigured = !!(
process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
process.env.NEXT_PUBLIC_EXCALIDRAW_PATH
);
useEffect(() => { useEffect(() => {
if (!excalidrawUrl || !boardId) return undefined; if (!excalidrawConfigured || !boardId) return undefined;
const container = iframeRef.current; const container = iframeRef.current;
if (!container) return undefined; if (!container) return undefined;
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = `${excalidrawUrl}?boardId=${boardId}`; iframe.src = buildExcalidrawSrc(boardId, user);
iframe.style.cssText = 'width:100%;height:100%;border:none;'; iframe.style.cssText = 'width:100%;height:100%;border:none;';
iframe.allow = 'camera; microphone; fullscreen'; iframe.allow = 'camera; microphone; fullscreen';
container.innerHTML = ''; container.innerHTML = '';
@ -192,9 +209,9 @@ function WhiteboardIframe({ boardId, showingBoard }) {
return () => { return () => {
container.innerHTML = ''; container.innerHTML = '';
}; };
}, [boardId, excalidrawUrl]); }, [boardId, excalidrawConfigured, user]);
if (!excalidrawUrl) { if (!excalidrawConfigured) {
return ( return (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', background: '#111' }}> <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', background: '#111' }}>
Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL) Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
@ -227,7 +244,7 @@ function PreJoinScreen({ onJoin, onCancel }) {
if (!videoEnabled) return undefined; if (!videoEnabled) return undefined;
let stream = null; let stream = null;
navigator.mediaDevices navigator.mediaDevices
.getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false }) .getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 } }, audio: false })
.then((s) => { .then((s) => {
stream = s; stream = s;
if (videoRef.current) videoRef.current.srcObject = s; if (videoRef.current) videoRef.current.srcObject = s;
@ -246,42 +263,64 @@ function PreJoinScreen({ onJoin, onCancel }) {
onJoin(audioEnabled, videoEnabled); onJoin(audioEnabled, videoEnabled);
}; };
const S = {
page: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0d0d0d', overflow: 'hidden' },
card: { background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.10)', borderRadius: 22, width: '100%', maxWidth: 480, overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.6)' },
header: { padding: '24px 28px 20px', borderBottom: '1px solid rgba(255,255,255,0.08)' },
title: { fontSize: 20, fontWeight: 700, color: '#fff', margin: 0 },
sub: { fontSize: 13, color: 'rgba(255,255,255,0.5)', margin: '4px 0 0' },
body: { padding: 24, display: 'flex', flexDirection: 'column', gap: 16 },
preview: { background: '#111', borderRadius: 14, aspectRatio: '16/9', overflow: 'hidden', position: 'relative' },
previewOff: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10, color: 'rgba(255,255,255,0.35)' },
toggleRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 12 },
toggleLabel: { display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(255,255,255,0.85)', fontSize: 14 },
toggleBtn: (on) => ({ padding: '7px 18px', borderRadius: 9, border: 'none', background: on ? 'rgba(25,118,210,0.25)' : 'rgba(255,255,255,0.08)', color: on ? '#60a5fa' : 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 600, cursor: 'pointer', transition: 'all 0.15s' }),
actions: { display: 'flex', gap: 10 },
cancelBtn: { flex: 1, padding: '13px', borderRadius: 12, border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: 'rgba(255,255,255,0.6)', fontSize: 14, cursor: 'pointer' },
joinBtn: { flex: 2, padding: '13px', borderRadius: 12, border: 'none', background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)', color: '#fff', fontSize: 14, fontWeight: 700, cursor: 'pointer', letterSpacing: '0.02em', boxShadow: '0 4px 16px rgba(25,118,210,0.35)' },
};
const devices = [
{ key: 'mic', label: 'Микрофон', icon: audioEnabled ? 'solar:microphone-bold' : 'solar:microphone-slash-bold', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) },
{ key: 'cam', label: 'Камера', icon: videoEnabled ? 'solar:camera-bold' : 'solar:camera-slash-bold', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) },
];
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%)' }}> <div style={S.page}>
<div style={{ background: '#fff', borderRadius: 20, maxWidth: 520, width: '100%', overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.1)' }}> <div style={S.card}>
<div style={{ background: 'linear-gradient(135deg, #1976d2 0%, #7c4dff 100%)', padding: 24, color: '#fff' }}> <div style={S.header}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>Настройки перед входом</h1> <p style={S.title}>Настройки перед входом</p>
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>Настройте камеру и микрофон</p> <p style={S.sub}>Проверьте камеру и микрофон</p>
</div> </div>
<div style={{ padding: 24 }}> <div style={S.body}>
<div style={{ marginBottom: 24, background: '#000', borderRadius: 12, aspectRatio: '16/9', overflow: 'hidden' }}> <div style={S.preview}>
{videoEnabled ? ( {videoEnabled
<video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : ( : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', color: '#666', minHeight: 120 }}> <div style={S.previewOff}>
<span style={{ fontSize: 48 }}>📵</span> <Iconify icon="solar:camera-slash-bold" width={40} />
<p style={{ margin: 8 }}>Камера выключена</p> <span style={{ fontSize: 13 }}>Камера выключена</span>
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
{[ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ label: 'Микрофон', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) }, {devices.map(({ key, label, icon, enabled, toggle }) => (
{ label: 'Камера', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) }, <div key={key} style={S.toggleRow}>
].map(({ label, enabled, toggle }) => ( <span style={S.toggleLabel}>
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 16, background: '#f5f5f5', borderRadius: 12 }}> <Iconify icon={icon} width={20} style={{ color: enabled ? '#60a5fa' : 'rgba(255,255,255,0.35)' }} />
<span>{label}</span> {label}
<button onClick={toggle} type="button" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: enabled ? '#1976d2' : '#888', color: '#fff', cursor: 'pointer' }}> </span>
{enabled ? 'Выключить' : 'Включить'} <button type="button" onClick={toggle} style={S.toggleBtn(enabled)}>
{enabled ? 'Вкл' : 'Выкл'}
</button> </button>
</div> </div>
))} ))}
</div> </div>
<div style={{ display: 'flex', gap: 12 }}>
<button type="button" onClick={onCancel} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: '1px solid #ddd', background: 'transparent', cursor: 'pointer' }}>Отмена</button> <div style={S.actions}>
<button type="button" onClick={handleJoin} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: 'none', background: '#1976d2', color: '#fff', fontWeight: 600, cursor: 'pointer' }}> <button type="button" onClick={onCancel} style={S.cancelBtn}>Отмена</button>
📹 Войти в конференцию <button type="button" onClick={handleJoin} style={S.joinBtn}>Войти в конференцию</button>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -291,13 +330,113 @@ function PreJoinScreen({ onJoin, onCancel }) {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard }) { // Panel width constant already defined at top
// ----------------------------------------------------------------------
function VideoCallChatPanel({ lesson, currentUser, onClose }) {
const [chat, setChat] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!lesson || !currentUser) return;
const getOtherUserId = () => {
if (currentUser.role === 'mentor') {
const client = lesson.client;
if (!client) return null;
if (typeof client === 'object') return client.user?.id ?? client.id ?? null;
return client;
}
const mentor = lesson.mentor;
if (!mentor) return null;
if (typeof mentor === 'object') return mentor.id ?? null;
return mentor;
};
const otherId = getOtherUserId();
if (!otherId) {
setError('Не удалось определить собеседника');
setLoading(false);
return;
}
setLoading(true);
createChat(otherId)
.then((raw) => {
const enriched = { ...raw };
if (!enriched.other_participant) {
// fallback: determine name from lesson data
const other = currentUser.role === 'mentor' ? lesson.client : lesson.mentor;
if (other && typeof other === 'object') {
const u = other.user || other;
enriched.other_participant = {
id: otherId,
first_name: u.first_name,
last_name: u.last_name,
avatar_url: u.avatar_url || u.avatar || null,
};
}
}
setChat(normalizeChat(enriched));
})
.catch((e) => setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки чата'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson?.id, currentUser?.id]);
return (
<div style={{ position: 'fixed', top: 0, right: 0, width: CHAT_PANEL_WIDTH, height: '100vh', background: '#0f0f0f', borderLeft: '1px solid rgba(255,255,255,0.08)', display: 'flex', flexDirection: 'column', zIndex: 10002, boxShadow: '-8px 0 40px rgba(0,0,0,0.5)' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)', flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', background: 'linear-gradient(135deg, #1976d2, #7c4dff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Iconify icon="solar:chat-round-bold" width={18} style={{ color: '#fff' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#fff', fontWeight: 600, fontSize: 14, lineHeight: 1.3 }}>Чат</div>
</div>
<button type="button" onClick={onClose} style={{ width: 32, height: 32, borderRadius: 8, border: 'none', background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Iconify icon="solar:close-circle-bold" width={18} />
</button>
</div>
{loading && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, color: 'rgba(255,255,255,0.35)' }}>
<Iconify icon="svg-spinners:ring-resize" width={28} style={{ color: '#1976d2' }} />
<span style={{ fontSize: 13 }}>Загрузка чата</span>
</div>
)}
{error && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, textAlign: 'center', color: '#fc8181', fontSize: 13 }}>
{error}
</div>
)}
{!loading && !error && (
<ChatWindow chat={chat} currentUserId={currentUser?.id ?? null} hideHeader />
)}
</div>
);
}
// ----------------------------------------------------------------------
function RoomContent({ lessonId, lesson, boardId, boardLoading, showBoard, setShowBoard, postDisconnectRef }) {
const room = useRoomContext(); const room = useRoomContext();
const router = useRouter(); const router = useRouter();
const { user } = useAuthContext(); const { user } = useAuthContext();
const [showPlatformChat, setShowPlatformChat] = useState(false); const [showPlatformChat, setShowPlatformChat] = useState(false);
const [showExitModal, setShowExitModal] = useState(false); const [showExitMenu, setShowExitMenu] = useState(false);
const [showNavMenu, setShowNavMenu] = useState(false); const [showNavMenu, setShowNavMenu] = useState(false);
const [terminatingAll, setTerminatingAll] = useState(false);
const [exitBtnRect, setExitBtnRect] = useState(null);
const exitBtnRef = useRef(null);
const isMentor = user?.role === 'mentor';
const handleToggleExitMenu = () => {
if (!showExitMenu && exitBtnRef.current) {
setExitBtnRect(exitBtnRef.current.getBoundingClientRect());
}
setShowExitMenu((v) => !v);
};
useEffect(() => { useEffect(() => {
const onConnected = () => { const onConnected = () => {
@ -335,7 +474,7 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
btn.className = 'lk-button lk-custom-exit-button'; btn.className = 'lk-button lk-custom-exit-button';
btn.title = 'Выйти'; btn.title = 'Выйти';
btn.textContent = '🚪'; btn.textContent = '🚪';
btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click'))); btn.addEventListener('click', (ev) => window.dispatchEvent(new CustomEvent('livekit-exit-click', { detail: { target: ev.currentTarget } })));
bar.appendChild(btn); bar.appendChild(btn);
} }
}, 800); }, 800);
@ -349,16 +488,61 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
}, []); }, []);
useEffect(() => { useEffect(() => {
const handler = () => { // Control bar button click: capture its rect for popup positioning
if (user?.role === 'mentor') { const handler = (e) => {
setShowExitModal(true); const btn = e?.detail?.target ?? document.querySelector('.lk-custom-exit-button');
} else { if (btn) setExitBtnRect(btn.getBoundingClientRect());
room.disconnect(); setShowExitMenu(true);
}
}; };
window.addEventListener('livekit-exit-click', handler); window.addEventListener('livekit-exit-click', handler);
return () => window.removeEventListener('livekit-exit-click', handler); return () => window.removeEventListener('livekit-exit-click', handler);
}, [user?.role, room]); }, []);
const handleJustExit = () => {
setShowExitMenu(false);
room.disconnect();
};
const handleTerminateForAll = async () => {
setShowExitMenu(false);
setTerminatingAll(true);
if (lessonId != null) {
try { sessionStorage.setItem('complete_lesson_id', String(lessonId)); } catch { /* ignore */ }
if (postDisconnectRef) postDisconnectRef.current = paths.dashboard.calendar;
// 1. Broadcast terminate signal via LiveKit DataChannel so clients
// disconnect immediately (fallback if backend call is slow/fails).
try {
await room.localParticipant.publishData(
new TextEncoder().encode(TERMINATE_MSG),
{ reliable: true }
);
} catch { /* ignore */ }
// 2. Backend: terminate LiveKit room + mark lesson as completed.
try { await terminateRoom(lessonId); } catch { /* ignore */ }
// 3. Ensure lesson status is "completed" on backend even if
// terminateRoom doesn't handle it.
try { await completeLesson(String(lessonId), '', undefined, undefined, undefined, false, undefined); } catch { /* ignore */ }
}
room.disconnect();
};
// Listen for terminate broadcast from mentor (runs on client side).
useEffect(() => {
if (isMentor) return undefined;
const onData = (payload) => {
try {
const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg?.type === 'room_terminate') {
room.disconnect();
}
} catch { /* ignore */ }
};
room.on(RoomEvent.DataReceived, onData);
return () => { room.off(RoomEvent.DataReceived, onData); };
}, [room, isMentor]);
// Save audio/video state on track events // Save audio/video state on track events
useEffect(() => { useEffect(() => {
@ -401,103 +585,159 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
}; };
}, [room]); }, [room]);
const sidebarStyle = { const sidebarRight = showPlatformChat ? CHAT_PANEL_WIDTH + 12 : 12;
position: 'fixed',
right: showPlatformChat ? CHAT_PANEL_WIDTH + 16 : 16,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 8,
background: 'rgba(0,0,0,0.7)',
borderRadius: 12,
zIndex: 10001,
};
const iconBtn = (active, disabled, title, icon, onClick) => ( const SideBtn = ({ active, disabled: dis, title, icon, onClick: handleClick }) => (
<button <button
type="button" type="button"
onClick={onClick} onClick={handleClick}
disabled={disabled} disabled={dis}
title={title} title={title}
style={{ style={{
width: 48, width: 44,
height: 48, height: 44,
borderRadius: 12, borderRadius: 12,
border: 'none', border: active ? '1px solid rgba(25,118,210,0.5)' : '1px solid rgba(255,255,255,0.08)',
background: active ? '#1976d2' : 'rgba(255,255,255,0.2)', background: active ? 'rgba(25,118,210,0.25)' : 'rgba(255,255,255,0.06)',
color: '#fff', color: active ? '#60a5fa' : dis ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.7)',
cursor: disabled ? 'not-allowed' : 'pointer', cursor: dis ? 'not-allowed' : 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: 20, transition: 'all 0.15s ease',
opacity: disabled ? 0.6 : 1, flexShrink: 0,
}} }}
> >
{icon} <Iconify icon={icon} width={20} />
</button> </button>
); );
return ( return (
<div style={{ height: '100vh', position: 'relative', display: 'flex' }}> <div style={{ height: '100vh', width: '100%', position: 'relative', overflow: 'hidden', background: '#0d0d0d' }}>
<StartAudioOverlay /> <StartAudioOverlay />
<div style={{ flex: 1, position: 'relative', background: '#000' }}> <div style={{ position: 'absolute', inset: 0, zIndex: showBoard ? 0 : 1 }}>
<div style={{ position: 'absolute', inset: 0, zIndex: showBoard ? 0 : 1 }}> <LiveKitLayoutErrorBoundary>
<LiveKitLayoutErrorBoundary> <VideoConference chatMessageFormatter={(message) => message} />
<VideoConference chatMessageFormatter={(message) => message} /> </LiveKitLayoutErrorBoundary>
</LiveKitLayoutErrorBoundary>
</div>
</div> </div>
{typeof document !== 'undefined' && {typeof document !== 'undefined' &&
createPortal( createPortal(
<> <>
{showBoard && <RemoteParticipantPiP chatOpen={showPlatformChat} />} {showBoard && <RemoteParticipantPiP chatOpen={showPlatformChat} />}
{/* Board burger button */}
{showBoard && ( {showBoard && (
<button <button
type="button" type="button"
onClick={() => setShowNavMenu((v) => !v)} onClick={() => setShowNavMenu((v) => !v)}
style={{ position: 'fixed', left: 16, bottom: 64, width: 48, height: 48, borderRadius: 12, border: 'none', background: 'rgba(0,0,0,0.7)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002, fontSize: 20 }}
title="Меню" title="Меню"
style={{ position: 'fixed', left: 12, bottom: 96, width: 44, height: 44, borderRadius: 12, border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(15,15,15,0.85)', backdropFilter: 'blur(12px)', color: 'rgba(255,255,255,0.7)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002 }}
> >
<Iconify icon="solar:hamburger-menu-bold" width={20} />
</button> </button>
)} )}
{showNavMenu && (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions {/* Nav menu — existing NavMobile, all links open in new tab */}
<div <NavMobile
onClick={() => setShowNavMenu(false)} open={showNavMenu}
onKeyDown={(e) => e.key === 'Escape' && setShowNavMenu(false)} onClose={() => setShowNavMenu(false)}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} data={getNavData(user?.role).map((section) => ({
...section,
items: section.items.map((item) => ({ ...item, externalLink: true })),
}))}
sx={{ bgcolor: 'grey.900', width: 300 }}
/>
{/* Right sidebar */}
<div style={{ position: 'fixed', right: sidebarRight, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', gap: 6, padding: 6, background: 'rgba(10,10,10,0.80)', backdropFilter: 'blur(16px)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16, zIndex: 10001, transition: 'right 0.25s ease' }}>
<SideBtn active={!showBoard} disabled={false} title="Видеоконференция" icon="solar:videocamera-bold" onClick={() => setShowBoard(false)} />
<SideBtn
active={showBoard}
disabled={!boardId || boardLoading}
title={boardLoading ? 'Загрузка доски…' : !boardId ? 'Доска недоступна' : 'Доска'}
icon={boardLoading ? 'svg-spinners:ring-resize' : 'solar:pen-new-square-bold'}
onClick={() => boardId && !boardLoading && setShowBoard(true)}
/>
{lessonId != null && (
<SideBtn active={showPlatformChat} disabled={false} title="Чат" icon="solar:chat-round-bold" onClick={() => setShowPlatformChat((v) => !v)} />
)}
{/* Divider */}
<div style={{ height: 1, background: 'rgba(255,255,255,0.10)', margin: '2px 0' }} />
{/* Exit button */}
<button
ref={exitBtnRef}
type="button"
onClick={handleToggleExitMenu}
title="Выйти"
style={{ width: 44, height: 44, borderRadius: 12, border: `1px solid ${showExitMenu ? 'rgba(239,68,68,0.6)' : 'rgba(239,68,68,0.35)'}`, background: showExitMenu ? 'rgba(239,68,68,0.22)' : 'rgba(239,68,68,0.12)', color: '#f87171', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.15s ease', flexShrink: 0 }}
> >
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} <Iconify icon={showExitMenu ? 'solar:close-bold' : 'solar:exit-bold'} width={20} />
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 16, padding: 24, minWidth: 240 }}> </button>
<button type="button" onClick={() => { setShowNavMenu(false); router.push('/dashboard'); }} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
🏠 На главную
</button>
<button type="button" onClick={() => setShowNavMenu(false)} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
Закрыть
</button>
</div>
</div>
)}
<div style={sidebarStyle}>
{iconBtn(!showBoard, false, 'Камера', '📹', () => setShowBoard(false))}
{iconBtn(showBoard, !boardId || boardLoading, boardLoading ? 'Загрузка доски...' : !boardId ? 'Доска недоступна' : 'Доска', boardLoading ? '⏳' : '🎨', () => boardId && !boardLoading && setShowBoard(true))}
{lessonId != null && iconBtn(showPlatformChat, false, 'Чат', '💬', () => setShowPlatformChat((v) => !v))}
</div> </div>
{/* Exit menu popup — anchored to the exit button rect */}
{showExitMenu && exitBtnRect && (
<>
{/* Backdrop */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 10010 }}
onClick={() => setShowExitMenu(false)}
/>
{/* Popup: right edge aligned with exit button right edge, bottom edge at exit button top - 8px */}
<div style={{
position: 'fixed',
right: window.innerWidth - exitBtnRect.right,
bottom: window.innerHeight - exitBtnRect.top + 8,
zIndex: 10011,
background: 'rgba(18,18,18,0.97)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 14,
padding: 8,
backdropFilter: 'blur(20px)',
display: 'flex',
flexDirection: 'column',
gap: 6,
minWidth: 240,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}>
<button
type="button"
onClick={handleJustExit}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', borderRadius: 10, border: 'none', background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.85)', fontSize: 14, cursor: 'pointer', textAlign: 'left', whiteSpace: 'nowrap' }}
>
<Iconify icon="solar:exit-bold" width={18} style={{ color: '#94a3b8', flexShrink: 0 }} />
Выйти
</button>
{isMentor && (
<button
type="button"
onClick={handleTerminateForAll}
disabled={terminatingAll}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', borderRadius: 10, border: 'none', background: 'rgba(239,68,68,0.12)', color: terminatingAll ? 'rgba(248,113,113,0.5)' : '#f87171', fontSize: 14, cursor: terminatingAll ? 'not-allowed' : 'pointer', textAlign: 'left', whiteSpace: 'nowrap' }}
>
<Iconify
icon={terminatingAll ? 'svg-spinners:ring-resize' : 'solar:close-circle-bold'}
width={18}
style={{ color: terminatingAll ? 'rgba(248,113,113,0.5)' : '#f87171', flexShrink: 0 }}
/>
{terminatingAll ? 'Завершение…' : 'Выйти и завершить для всех'}
</button>
)}
</div>
</>
)}
{showPlatformChat && lesson && (
<VideoCallChatPanel
lesson={lesson}
currentUser={user}
onClose={() => setShowPlatformChat(false)}
/>
)}
</>, </>,
document.body document.body
)} )}
<ExitLessonModal
isOpen={showExitModal}
lessonId={lessonId}
onClose={() => setShowExitModal(false)}
onExit={() => room.disconnect()}
/>
</div> </div>
); );
} }
@ -518,10 +758,23 @@ export function VideoCallView() {
const [avReady, setAvReady] = useState(false); const [avReady, setAvReady] = useState(false);
const [lessonCompleted, setLessonCompleted] = useState(false); const [lessonCompleted, setLessonCompleted] = useState(false);
const [effectiveLessonId, setEffectiveLessonId] = useState(null); const [effectiveLessonId, setEffectiveLessonId] = useState(null);
const [lesson, setLesson] = useState(null);
const [boardId, setBoardId] = useState(null); const [boardId, setBoardId] = useState(null);
const [boardLoading, setBoardLoading] = useState(false); const [boardLoading, setBoardLoading] = useState(false);
const [showBoard, setShowBoard] = useState(false); const [showBoard, setShowBoard] = useState(false);
const boardPollRef = useRef(null); const boardPollRef = useRef(null);
const postDisconnectRef = useRef('/dashboard');
// Lock scroll while on the video-call page, restore on unmount
useEffect(() => {
const prev = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
return () => {
document.documentElement.style.overflow = prev;
document.body.style.overflow = '';
};
}, []);
// Load audio/video preferences from localStorage after mount // Load audio/video preferences from localStorage after mount
useEffect(() => { useEffect(() => {
@ -553,6 +806,7 @@ export function VideoCallView() {
if (lessonIdParam) { if (lessonIdParam) {
try { try {
const l = await getLesson(lessonIdParam); const l = await getLesson(lessonIdParam);
setLesson(l);
if (l.status === 'completed') { if (l.status === 'completed') {
const now = new Date(); const now = new Date();
const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time); const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time);
@ -668,7 +922,7 @@ export function VideoCallView() {
key="board-layer" key="board-layer"
style={{ position: 'fixed', inset: 0, zIndex: showBoard ? 9999 : 0, pointerEvents: showBoard ? 'auto' : 'none' }} style={{ position: 'fixed', inset: 0, zIndex: showBoard ? 9999 : 0, pointerEvents: showBoard ? 'auto' : 'none' }}
> >
<WhiteboardIframe boardId={boardId} showingBoard={showBoard} /> <WhiteboardIframe boardId={boardId} showingBoard={showBoard} user={user} />
</div> </div>
)} )}
<LiveKitRoom <LiveKitRoom
@ -677,7 +931,7 @@ export function VideoCallView() {
connect connect
audio={audioEnabled} audio={audioEnabled}
video={videoEnabled} video={videoEnabled}
onDisconnected={() => router.push('/dashboard')} onDisconnected={() => router.push(postDisconnectRef.current)}
style={{ height: '100vh' }} style={{ height: '100vh' }}
data-lk-theme="default" data-lk-theme="default"
options={{ options={{
@ -697,10 +951,12 @@ export function VideoCallView() {
> >
<RoomContent <RoomContent
lessonId={effectiveLessonId} lessonId={effectiveLessonId}
lesson={lesson}
boardId={boardId} boardId={boardId}
boardLoading={boardLoading} boardLoading={boardLoading}
showBoard={showBoard} showBoard={showBoard}
setShowBoard={setShowBoard} setShowBoard={setShowBoard}
postDisconnectRef={postDisconnectRef}
/> />
<RoomAudioRenderer /> <RoomAudioRenderer />
<ConnectionStateToast /> <ConnectionStateToast />

View File

@ -1,429 +1,369 @@
/** /**
* Кастомизация LiveKit через CSS переменные. * LiveKit custom theme platform edition
* Все стили и скрипты LiveKit отдаются с нашего сервера (бандл + этот файл).
*/ */
@keyframes lk-spin { /* ─── No scroll on video-call page (applied via useEffect in VideoCallView) ── */
to { transform: rotate(360deg); }
}
/* ─── CSS Variables ─────────────────────────────────────────────────────────── */
:root { :root {
/* Цвета фона */ --vc-bg: #0d0d0d;
--lk-bg: #1a1a1a; --vc-surface: rgba(255, 255, 255, 0.06);
--lk-bg2: #2a2a2a; --vc-surface-hover: rgba(255, 255, 255, 0.12);
--lk-bg3: #3a3a3a; --vc-border: rgba(255, 255, 255, 0.10);
--vc-accent: #1976d2;
/* Цвета текста */ --vc-accent-hover: #1565c0;
--lk-fg: #ffffff; --vc-danger: #ef5350;
--lk-fg2: rgba(255, 255, 255, 0.7); --vc-text: #ffffff;
--vc-text-dim: rgba(255, 255, 255, 0.55);
/* Основные цвета */ --vc-radius: 14px;
--lk-control-bg: var(--md-sys-color-primary); --vc-blur: blur(18px);
--lk-control-hover-bg: var(--md-sys-color-primary-container);
--lk-button-bg: rgba(255, 255, 255, 0.15); /* LiveKit CSS vars */
--lk-button-hover-bg: rgba(255, 255, 255, 0.25); --lk-bg: #0d0d0d;
--lk-bg2: #1a1a1a;
/* Границы */ --lk-bg3: #262626;
--lk-border-color: rgba(255, 255, 255, 0.1); --lk-fg: #ffffff;
--lk-border-radius: 12px; --lk-fg2: rgba(255, 255, 255, 0.7);
--lk-control-bg: rgba(255, 255, 255, 0.08);
/* Фокус */ --lk-control-hover-bg: rgba(255, 255, 255, 0.16);
--lk-focus-ring: var(--md-sys-color-primary); --lk-button-bg: rgba(255, 255, 255, 0.08);
--lk-border-color: rgba(255, 255, 255, 0.10);
/* Ошибки */ --lk-border-radius: 12px;
--lk-danger: var(--md-sys-color-error); --lk-danger: #ef5350;
--lk-accent-bg: #1976d2;
/* Размеры */ --lk-accent-fg: #ffffff;
--lk-control-bar-height: 80px; --lk-control-bar-height: 76px;
--lk-participant-tile-gap: 12px; --lk-grid-gap: 10px;
} }
/* Панель управления — без ограничения по ширине */ /* ─── Room root ─────────────────────────────────────────────────────────────── */
.lk-room-container {
background: var(--vc-bg) !important;
overflow: hidden !important;
}
/* ─── Control bar ───────────────────────────────────────────────────────────── */
.lk-control-bar { .lk-control-bar {
background: rgba(0, 0, 0, 0.8) !important; position: fixed !important;
backdrop-filter: blur(20px) !important; bottom: 20px !important;
border-radius: 16px !important; left: 50% !important;
padding: 12px 16px !important; transform: translateX(-50%) !important;
margin: 16px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
max-width: none !important;
width: auto !important; width: auto !important;
max-width: calc(100vw - 40px) !important;
background: rgba(15, 15, 15, 0.85) !important;
backdrop-filter: var(--vc-blur) !important;
-webkit-backdrop-filter: var(--vc-blur) !important;
border: 1px solid var(--vc-border) !important;
border-radius: 20px !important;
padding: 10px 16px !important;
gap: 6px !important;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6), 0 2px 8px rgba(0, 0, 0, 0.4) !important;
z-index: 9000 !important;
} }
.lk-control-bar .lk-button-group, .lk-control-bar .lk-button-group,
.lk-control-bar .lk-button-group-menu { .lk-control-bar .lk-button-group-menu {
max-width: none !important; max-width: none !important;
width: auto !important;
} }
/* Кнопки управления — ширина по контенту, без жёсткого ограничения */ /* ─── All buttons ───────────────────────────────────────────────────────────── */
.lk-control-bar .lk-button { .lk-button,
min-width: 48px !important; .lk-start-audio-button,
width: auto !important; .lk-chat-toggle,
height: 48px !important; .lk-disconnect-button {
min-width: 46px !important;
height: 46px !important;
border-radius: 12px !important; border-radius: 12px !important;
transition: all 0.2s ease !important; transition: background 0.18s ease, transform 0.12s ease !important;
padding-left: 12px !important; padding: 0 14px !important;
padding-right: 12px !important; font-size: 13px !important;
font-weight: 500 !important;
letter-spacing: 0.01em !important;
gap: 7px !important;
} }
/* Русские подписи: скрываем английский текст, показываем свой */ .lk-button:not(:disabled):hover {
.lk-control-bar .lk-button[data-lk-source="microphone"], transform: translateY(-1px) !important;
.lk-control-bar .lk-button[data-lk-source="camera"], background: var(--vc-surface-hover) !important;
.lk-control-bar .lk-button[data-lk-source="screen_share"], }
.lk-button:active { transform: scale(0.96) !important; }
/* Active (enabled) state */
.lk-button[data-lk-enabled="true"] {
background: rgba(25, 118, 210, 0.25) !important;
color: #60a5fa !important;
}
.lk-button[data-lk-enabled="true"]:hover {
background: rgba(25, 118, 210, 0.35) !important;
}
/* Screen share active */
.lk-button[data-lk-source="screen_share"][data-lk-enabled="true"] {
background: rgba(25, 118, 210, 0.3) !important;
color: #60a5fa !important;
}
/* ─── Russian labels ────────────────────────────────────────────────────────── */
.lk-control-bar .lk-button[data-lk-source],
.lk-control-bar .lk-chat-toggle, .lk-control-bar .lk-chat-toggle,
.lk-control-bar .lk-disconnect-button, .lk-control-bar .lk-disconnect-button,
.lk-control-bar .lk-start-audio-button { .lk-control-bar .lk-start-audio-button {
font-size: 0 !important; font-size: 0 !important;
} }
.lk-control-bar .lk-button[data-lk-source="microphone"] > svg, .lk-control-bar .lk-button > svg,
.lk-control-bar .lk-button[data-lk-source="camera"] > svg,
.lk-control-bar .lk-button[data-lk-source="screen_share"] > svg,
.lk-control-bar .lk-chat-toggle > svg, .lk-control-bar .lk-chat-toggle > svg,
.lk-control-bar .lk-disconnect-button > svg { .lk-control-bar .lk-disconnect-button > svg {
width: 16px !important; width: 18px !important;
height: 16px !important; height: 18px !important;
flex-shrink: 0 !important; flex-shrink: 0 !important;
} }
.lk-control-bar .lk-button[data-lk-source="microphone"]::after { .lk-control-bar .lk-button[data-lk-source="microphone"]::after { content: "Микрофон"; font-size: 13px; }
content: "Микрофон"; .lk-control-bar .lk-button[data-lk-source="camera"]::after { content: "Камера"; font-size: 13px; }
font-size: 1rem; .lk-control-bar .lk-button[data-lk-source="screen_share"]::after { content: "Экран"; font-size: 13px; }
} .lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after { content: "Стоп"; }
.lk-control-bar .lk-chat-toggle::after { content: "Чат"; font-size: 13px; }
.lk-control-bar .lk-button[data-lk-source="camera"]::after { /* ─── Burger & custom exit (injected via JS) ────────────────────────────────── */
content: "Камера";
font-size: 1rem;
}
.lk-control-bar .lk-button[data-lk-source="screen_share"]::after {
content: "Поделиться экраном";
font-size: 1rem;
}
.lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after {
content: "Остановить демонстрацию";
}
.lk-control-bar .lk-chat-toggle::after {
content: "Чат";
font-size: 1rem;
}
/* Кнопка бургер слева от микрофона — в панели LiveKit */
.lk-burger-button { .lk-burger-button {
background: rgba(255, 255, 255, 0.15) !important; background: var(--vc-surface) !important;
color: #fff !important; color: var(--vc-text) !important;
border-radius: 12px !important;
}
.lk-burger-button:hover {
background: var(--vc-surface-hover) !important;
} }
/* Скрываем стандартную кнопку «Выйти» — используем свою внутри панели (модалка: Выйти / Выйти и завершить занятие) */ /* Hide default disconnect — we use our own */
.lk-control-bar .lk-disconnect-button { .lk-control-bar .lk-disconnect-button { display: none !important; }
display: none !important;
}
.lk-control-bar .lk-disconnect-button::after {
content: "Выйти";
font-size: 1rem;
}
/* Наша кнопка «Выйти» — внутри панели, рядом с «Поделиться экраном» */ /* Our exit button */
.lk-control-bar .lk-custom-exit-button { .lk-control-bar .lk-custom-exit-button {
font-size: 0 !important; font-size: 0 !important;
background: var(--md-sys-color-error) !important; background: rgba(239, 83, 80, 0.18) !important;
color: #fff !important; color: #fc8181 !important;
border: none; border: 1px solid rgba(239, 83, 80, 0.30) !important;
cursor: pointer; border-radius: 12px !important;
cursor: pointer !important;
display: inline-flex !important; display: inline-flex !important;
align-items: center; align-items: center !important;
justify-content: center; justify-content: center !important;
gap: 7px !important;
padding: 0 14px !important;
min-width: 46px !important;
height: 46px !important;
transition: background 0.18s ease !important;
} }
.lk-control-bar .lk-custom-exit-button::after { .lk-control-bar .lk-custom-exit-button::after {
content: "Выйти"; content: "Выйти";
font-size: 1rem; font-size: 13px;
font-weight: 500;
} }
.lk-control-bar .lk-custom-exit-button > .material-symbols-outlined { .lk-control-bar .lk-custom-exit-button:hover {
color: #fff !important; background: rgba(239, 83, 80, 0.30) !important;
} }
/* Скрываем кнопку «Начать видео» — у нас свой StartAudioOverlay */ /* Hide audio start button (we handle it ourselves) */
.lk-control-bar .lk-start-audio-button { .lk-control-bar .lk-start-audio-button { display: none !important; }
display: none !important; /* Hide LiveKit chat toggle (we use our own) */
} .lk-control-bar .lk-chat-toggle { display: none !important; }
/* Кнопки без текста (только иконка) — минимальный размер */ /* ─── Participant tiles ──────────────────────────────────────────────────────── */
.lk-button {
min-width: 48px !important;
width: auto !important;
height: 48px !important;
border-radius: 12px !important;
transition: all 0.2s ease !important;
}
.lk-button:hover {
transform: scale(1.05);
}
.lk-button:active {
transform: scale(0.95);
}
/* Активная кнопка */
.lk-button[data-lk-enabled="true"] {
background: var(--md-sys-color-primary) !important;
}
/* Кнопка отключения — белые иконка и текст */
.lk-disconnect-button {
background: var(--md-sys-color-error) !important;
color: #fff !important;
}
.lk-disconnect-button > svg {
color: #fff !important;
fill: currentColor;
}
/* Плитки участников */
.lk-participant-tile { .lk-participant-tile {
border-radius: 12px !important; border-radius: 14px !important;
overflow: hidden !important; overflow: hidden !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; background: #111 !important;
} }
/* Плейсхолдер без камеры: скрываем дефолтную SVG, показываем аватар из API */ .lk-participant-tile[data-lk-speaking="true"]:not([data-lk-source="screen_share"])::after {
.lk-participant-tile .lk-participant-placeholder svg { border-width: 2px !important;
display: none !important; border-color: #60a5fa !important;
transition-delay: 0s !important;
transition-duration: 0.15s !important;
} }
/* Контейнер для аватара — нужен для container queries */
.lk-participant-tile .lk-participant-placeholder { .lk-participant-tile .lk-participant-placeholder {
container-type: size; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%) !important;
} }
.lk-participant-tile .lk-participant-placeholder svg { display: none !important; }
.lk-participant-tile .lk-participant-placeholder { container-type: size; }
.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img { .lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
/* Квадрат: меньшая сторона контейнера, максимум 400px */ --avatar-size: min(min(70cqw, 70cqh), 360px);
--avatar-size: min(min(80cqw, 80cqh), 400px);
width: var(--avatar-size); width: var(--avatar-size);
height: var(--avatar-size); height: var(--avatar-size);
aspect-ratio: 1 / 1;
object-fit: cover;
object-position: center;
border-radius: 50%; border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 0 3px rgba(255,255,255,0.12);
} }
/* Fallback для браузеров без container queries */
@supports not (width: 1cqw) { @supports not (width: 1cqw) {
.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img { .lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
width: 200px; width: 180px; height: 180px;
height: 200px;
} }
} }
/* Имя участника — белый текст (Камера, PiP) */ /* Participant name badge */
.lk-participant-name { .lk-participant-metadata { bottom: 10px !important; left: 10px !important; right: 10px !important; }
background: rgba(0, 0, 0, 0.7) !important; .lk-participant-metadata-item {
backdrop-filter: blur(10px) !important; background: rgba(0, 0, 0, 0.55) !important;
backdrop-filter: blur(8px) !important;
border-radius: 8px !important; border-radius: 8px !important;
padding: 6px 12px !important; padding: 4px 10px !important;
}
.lk-participant-name {
font-size: 12px !important;
font-weight: 600 !important; font-weight: 600 !important;
color: #fff !important; color: #fff !important;
letter-spacing: 0.02em !important;
} }
/* Чат LiveKit скрыт — используем чат сервиса (платформы) */ /* ─── Video layouts ──────────────────────────────────────────────────────────── */
.lk-video-conference .lk-chat {
display: none !important;
}
.lk-control-bar .lk-chat-toggle {
display: none !important;
}
/* Стили чата платформы оставляем для других страниц */
.lk-chat {
background: var(--md-sys-color-surface) !important;
border-left: 1px solid var(--md-sys-color-outline) !important;
}
.lk-chat-entry {
background: var(--md-sys-color-surface-container) !important;
border-radius: 12px !important;
padding: 12px !important;
margin-bottom: 12px !important;
}
/* Сетка участников */
.lk-grid-layout { .lk-grid-layout {
gap: 12px !important; gap: var(--lk-grid-gap) !important;
padding: 12px !important; padding: var(--lk-grid-gap) !important;
min-height: 0 !important;
} }
.lk-grid-layout .lk-participant-tile { min-height: 200px; }
/* Меню выбора устройств — без ограничения по ширине */ /* Focus layout: remote fills screen, local in PiP */
.lk-device-menu,
.lk-media-device-select {
max-width: none !important;
width: max-content !important;
min-width: 0 !important;
}
.lk-media-device-select {
background: rgba(0, 0, 0, 0.95) !important;
backdrop-filter: blur(20px) !important;
border-radius: 12px !important;
padding: 8px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.lk-media-device-select button {
border-radius: 8px !important;
padding: 10px 14px !important;
transition: background 0.2s ease !important;
width: 100% !important;
min-width: 0 !important;
white-space: normal !important;
text-align: left !important;
}
.lk-media-device-select button:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
.lk-media-device-select button[data-lk-active="true"] {
background: var(--md-sys-color-primary) !important;
}
/* Индикатор говорящего */
.lk-participant-tile[data-lk-speaking="true"] {
box-shadow: 0 0 0 3px var(--md-sys-color-primary) !important;
}
/* Layout для 1-на-1: собеседник на весь экран, своя камера в углу */
/* Карусель position:absolute выходит из flow — остаётся только основной контент. */
/* Сетка 5fr 1fr: единственный grid-ребёнок (основное видео) получает 5fr (расширяется). */
.lk-focus-layout { .lk-focus-layout {
position: relative !important; position: relative !important;
grid-template-columns: 5fr 1fr !important; grid-template-columns: 5fr 1fr !important;
} }
/* Основное видео (собеседник) на весь экран */
.lk-focus-layout .lk-focus-layout-wrapper { .lk-focus-layout .lk-focus-layout-wrapper {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
} }
.lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile { .lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
border-radius: 0 !important; border-radius: 0 !important;
} }
/* Демонстрация экрана — на весь экран только в режиме фокуса (после клика на раскрытие) */ /* Carousel (local camera) — bottom-right PiP */
/* Структура: .lk-focus-layout-wrapper > .lk-focus-layout > .lk-participant-tile */
.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
position: absolute !important;
width: 100% !important;
height: 100% !important;
top: 0 !important;
left: 0 !important;
border-radius: 0 !important;
z-index: 50 !important;
}
/* Карусель с локальным видео (своя камера) */
.lk-focus-layout .lk-carousel { .lk-focus-layout .lk-carousel {
position: absolute !important; position: absolute !important;
bottom: 80px !important; bottom: 96px !important;
right: 16px !important; right: 16px !important;
width: 280px !important; width: 240px !important;
height: auto !important; height: auto !important;
z-index: 100 !important; z-index: 100 !important;
pointer-events: auto !important; pointer-events: auto !important;
} }
.lk-focus-layout .lk-carousel .lk-participant-tile { .lk-focus-layout .lk-carousel .lk-participant-tile {
width: 280px !important; width: 240px !important;
height: 158px !important; height: 135px !important;
border-radius: 12px !important; border-radius: 12px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.10) !important;
border: 2px solid rgba(255, 255, 255, 0.2) !important;
} }
/* Скрыть стрелки карусели (они не нужны для 1 участника) */
.lk-focus-layout .lk-carousel button[aria-label*="Previous"], .lk-focus-layout .lk-carousel button[aria-label*="Previous"],
.lk-focus-layout .lk-carousel button[aria-label*="Next"] { .lk-focus-layout .lk-carousel button[aria-label*="Next"] { display: none !important; }
display: none !important;
}
/* Если используется grid layout (фоллбэк) */ /* Grid 2-person: first full, second PiP */
.lk-grid-layout {
position: relative !important;
}
/* Для 2 участников: первый на весь экран, второй в углу */
.lk-grid-layout[data-lk-participants="2"] { .lk-grid-layout[data-lk-participants="2"] {
display: block !important; display: block !important;
position: relative !important; position: relative !important;
} }
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child { .lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child {
position: absolute !important; position: absolute !important;
top: 0 !important; inset: 0 !important;
left: 0 !important;
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
border-radius: 0 !important; border-radius: 0 !important;
} }
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child { .lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
position: absolute !important; position: absolute !important;
bottom: 80px !important; bottom: 96px !important;
right: 16px !important; right: 16px !important;
width: 280px !important; width: 240px !important;
height: 158px !important; height: 135px !important;
border-radius: 12px !important; border-radius: 12px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.10) !important;
border: 2px solid rgba(255, 255, 255, 0.2) !important;
z-index: 100 !important; z-index: 100 !important;
} }
/* Адаптивность */ /* Screen share */
@media (max-width: 768px) { .lk-participant-media-video { background: #000 !important; }
.lk-control-bar {
border-radius: 12px !important;
padding: 8px 12px !important;
}
.lk-control-bar .lk-button,
.lk-button {
min-width: 44px !important;
width: auto !important;
height: 44px !important;
}
/* Уменьшаем размер локального видео на мобильных */
.lk-focus-layout .lk-carousel,
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
width: 160px !important;
height: 90px !important;
bottom: 70px !important;
right: 12px !important;
}
}
/* Качество отображения видео в контейнере LiveKit */
.lk-participant-media-video {
background: #000 !important;
}
/* Демонстрация экрана: contain чтобы не обрезать, чёткое отображение */
.lk-participant-media-video[data-lk-source="screen_share"] { .lk-participant-media-video[data-lk-source="screen_share"] {
object-fit: contain !important; object-fit: contain !important;
object-position: center !important;
image-rendering: -webkit-optimize-contrast; image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges; image-rendering: crisp-edges;
} }
/* Сетка: минимальная высота плиток для крупного видео */ .lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
.lk-grid-layout { position: absolute !important;
min-height: 0; inset: 0 !important;
border-radius: 0 !important;
z-index: 50 !important;
} }
.lk-grid-layout .lk-participant-tile {
min-height: 240px; /* ─── Device menu ───────────────────────────────────────────────────────────── */
.lk-device-menu,
.lk-media-device-select {
max-width: none !important;
width: max-content !important;
}
.lk-media-device-select {
background: rgba(18, 18, 18, 0.97) !important;
backdrop-filter: var(--vc-blur) !important;
border-radius: 14px !important;
padding: 8px !important;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6) !important;
border: 1px solid var(--vc-border) !important;
}
.lk-media-device-select button {
border-radius: 9px !important;
padding: 9px 14px !important;
transition: background 0.15s ease !important;
width: 100% !important;
text-align: left !important;
white-space: nowrap !important;
font-size: 13px !important;
}
.lk-media-device-select button:hover {
background: rgba(255, 255, 255, 0.09) !important;
}
.lk-media-device-select [data-lk-active="true"] > .lk-button {
background: rgba(25, 118, 210, 0.25) !important;
color: #60a5fa !important;
}
/* ─── Toast ─────────────────────────────────────────────────────────────────── */
.lk-toast {
background: rgba(18, 18, 18, 0.92) !important;
backdrop-filter: var(--vc-blur) !important;
border: 1px solid var(--vc-border) !important;
border-radius: 12px !important;
font-size: 13px !important;
}
/* ─── Hide LiveKit built-in chat ────────────────────────────────────────────── */
.lk-video-conference .lk-chat { display: none !important; }
/* ─── Mobile ────────────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.lk-control-bar {
border-radius: 16px !important;
padding: 8px 12px !important;
bottom: 12px !important;
}
.lk-button,
.lk-start-audio-button,
.lk-chat-toggle,
.lk-disconnect-button {
min-width: 42px !important;
height: 42px !important;
padding: 0 10px !important;
}
.lk-control-bar .lk-button[data-lk-source]::after,
.lk-control-bar .lk-chat-toggle::after,
.lk-control-bar .lk-custom-exit-button::after { display: none; }
.lk-focus-layout .lk-carousel,
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
width: 140px !important;
height: 79px !important;
bottom: 76px !important;
right: 12px !important;
}
} }

View File

@ -13,6 +13,20 @@ axiosInstance.interceptors.response.use(
export default axiosInstance; export default axiosInstance;
// ----------------------------------------------------------------------
// Resolve a relative or absolute media URL, always upgrading http → https
// to avoid mixed-content errors on HTTPS pages.
export function resolveMediaUrl(href) {
if (!href) return '';
let url = href;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
url = base + (url.startsWith('/') ? url : `/${url}`);
}
return url.replace(/^http:\/\//i, 'https://');
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const fetcher = async (args) => { export const fetcher = async (args) => {

View File

@ -1,4 +1,46 @@
import axios from 'src/utils/axios'; import axios from 'src/utils/axios';
import { CONFIG } from 'src/config-global';
// ----------------------------------------------------------------------
/**
* Build the Excalidraw iframe URL with all required params (boardId, token, apiUrl, yjsPort, isMentor).
*/
export function buildExcalidrawSrc(boardId, user) {
const token =
typeof window !== 'undefined'
? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
: '';
const serverUrl = CONFIG.site.serverUrl || '';
const apiUrl = serverUrl.replace(/\/api\/?$/, '') || '';
const isMentor = user?.role === 'mentor';
const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
const excalidrawPort = process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001';
const yjsPort = process.env.NEXT_PUBLIC_YJS_PORT || '1236';
if (excalidrawUrl && excalidrawUrl.startsWith('http')) {
const url = new URL(`${excalidrawUrl}/`);
url.searchParams.set('boardId', boardId);
url.searchParams.set('apiUrl', apiUrl);
url.searchParams.set('yjsPort', yjsPort);
if (token) url.searchParams.set('token', token);
if (isMentor) url.searchParams.set('isMentor', '1');
return url.toString();
}
const origin = excalidrawPath
? (typeof window !== 'undefined' ? window.location.origin : '')
: `${typeof window !== 'undefined' ? window.location.protocol : 'https:'}//${typeof window !== 'undefined' ? window.location.hostname : ''}:${excalidrawPort}`;
const pathname = excalidrawPath ? `/${excalidrawPath.replace(/^\//, '')}/` : '/';
const params = new URLSearchParams({ boardId, apiUrl });
params.set('yjsPort', yjsPort);
if (token) params.set('token', token);
if (isMentor) params.set('isMentor', '1');
return `${origin}${pathname}?${params.toString()}`;
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -26,13 +68,30 @@ export async function getOrCreateMentorStudentBoard(mentorId, studentId) {
return res.data; return res.data;
} }
/**
* GET /board/boards/get-or-create-group/?group=G
*/
export async function getOrCreateGroupBoard(groupId) {
const res = await axios.get(`/board/boards/get-or-create-group/?group=${groupId}`);
return res.data;
}
/** /**
* GET or create board for a lesson. * GET or create board for a lesson.
* Resolves lesson mentor/student IDs getOrCreateMentorStudentBoard * For group lessons getOrCreateGroupBoard
* For individual lessons getOrCreateMentorStudentBoard
*/ */
export async function getOrCreateLessonBoard(lessonId) { export async function getOrCreateLessonBoard(lessonId) {
const lessonRes = await axios.get(`/schedule/lessons/${lessonId}/`); const lessonRes = await axios.get(`/schedule/lessons/${lessonId}/`);
const lesson = lessonRes.data; const lesson = lessonRes.data;
// Групповое занятие
const groupId = lesson.group?.id ?? (typeof lesson.group === 'number' ? lesson.group : null);
if (groupId) {
return getOrCreateGroupBoard(groupId);
}
// Индивидуальное занятие
const mentorId = typeof lesson.mentor === 'object' ? lesson.mentor?.id : lesson.mentor; const mentorId = typeof lesson.mentor === 'object' ? lesson.mentor?.id : lesson.mentor;
const client = lesson.client; const client = lesson.client;
let studentId; let studentId;

View File

@ -3,17 +3,21 @@ import axios from 'src/utils/axios';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function normalizeChat(c) { export function normalizeChat(c) {
const isGroup = c?.chat_type === 'group';
const other = c?.other_participant ?? {}; const other = c?.other_participant ?? {};
const name = const name = isGroup
other.full_name || ? (c?.name || 'Группа')
[other.first_name, other.last_name].filter(Boolean).join(' ') || : (other.full_name ||
c?.participant_name || [other.first_name, other.last_name].filter(Boolean).join(' ') ||
'Чат'; c?.participant_name ||
'Чат');
const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || ''; const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || '';
return { return {
...c, ...c,
participant_name: name, participant_name: name,
participant_id: other.id ?? c?.other_user_id ?? c?.participant_id ?? null, participant_id: isGroup ? null : (other.id ?? c?.other_user_id ?? c?.participant_id ?? null),
// Тип роли собеседника для прямых чатов
other_role: isGroup ? null : (other.role ?? c?.other_role ?? null),
avatar_url: other.avatar_url || other.avatar || c?.avatar_url || null, avatar_url: other.avatar_url || other.avatar || c?.avatar_url || null,
last_message: lastText, last_message: lastText,
unread_count: c?.my_participant?.unread_count ?? c?.unread_count ?? 0, unread_count: c?.my_participant?.unread_count ?? c?.unread_count ?? 0,
@ -80,6 +84,13 @@ export async function markMessagesAsRead(chatUuid, messageUuids) {
); );
} }
export async function createGroupChat(groupId) {
const res = await axios.post('/chat/chats/create_group/', { group_id: groupId });
const { data } = res;
if (data && typeof data === 'object' && 'data' in data && typeof data.data === 'object') return data.data;
return data;
}
export async function searchUsers(query) { export async function searchUsers(query) {
const res = await axios.get('/users/search/', { params: { q: query } }); const res = await axios.get('/users/search/', { params: { q: query } });
const {data} = res; const {data} = res;

View File

@ -57,8 +57,11 @@ export async function getParentDashboard(options) {
/** /**
* GET /schedule/lessons/calendar/?start_date=&end_date= * GET /schedule/lessons/calendar/?start_date=&end_date=
*/ */
export async function getCalendarLessons(startDate, endDate, options) { export async function getCalendarLessons(startDate, endDate, extraParams, options) {
const params = new URLSearchParams({ start_date: startDate, end_date: endDate }); const params = new URLSearchParams({ start_date: startDate, end_date: endDate });
if (extraParams) {
Object.entries(extraParams).forEach(([k, v]) => { if (v != null) params.append(k, v); });
}
const config = options?.signal ? { signal: options.signal } : undefined; const config = options?.signal ? { signal: options.signal } : undefined;
const res = await axios.get(`/schedule/lessons/calendar/?${params}`, config); const res = await axios.get(`/schedule/lessons/calendar/?${params}`, config);
return res.data; return res.data;
@ -152,6 +155,21 @@ export async function completeLesson(id, notes, mentorGrade, schoolGrade, homewo
return res.data; return res.data;
} }
/**
* GET /schedule/lesson-files/?lesson={id}
*/
export async function getLessonFiles(lessonId) {
const res = await axios.get(`/schedule/lesson-files/?lesson=${lessonId}`);
return res.data;
}
/**
* DELETE /schedule/lesson-files/{id}/
*/
export async function deleteLessonFile(fileId) {
await axios.delete(`/schedule/lesson-files/${fileId}/`);
}
/** /**
* POST /schedule/lesson-files/ (multipart) * POST /schedule/lesson-files/ (multipart)
*/ */

View File

@ -7,6 +7,7 @@ export async function getHomework(params) {
if (params?.status) q.append('status', params.status); if (params?.status) q.append('status', params.status);
if (params?.page_size) q.append('page_size', String(params.page_size || 1000)); if (params?.page_size) q.append('page_size', String(params.page_size || 1000));
if (params?.child_id) q.append('child_id', params.child_id); if (params?.child_id) q.append('child_id', params.child_id);
if (params?.lesson_id) q.append('lesson', params.lesson_id);
const query = q.toString(); const query = q.toString();
const url = `/homework/homeworks/${query ? `?${query}` : ''}`; const url = `/homework/homeworks/${query ? `?${query}` : ''}`;
const res = await axios.get(url); const res = await axios.get(url);
@ -106,6 +107,8 @@ export async function uploadHomeworkFile(homeworkId, file) {
formData.append('homework', String(homeworkId)); formData.append('homework', String(homeworkId));
formData.append('file_type', 'assignment'); formData.append('file_type', 'assignment');
formData.append('file', file); formData.append('file', file);
formData.append('filename', file.name);
formData.append('file_size', String(file.size));
const res = await axios.post('/homework/files/', formData); const res = await axios.post('/homework/files/', formData);
return res.data; return res.data;
} }

View File

@ -26,3 +26,12 @@ export async function participantConnected({ roomName, lessonId }) {
if (lessonId != null) body.lesson_id = lessonId; if (lessonId != null) body.lesson_id = lessonId;
await axios.post('/video/livekit/participant-connected/', body); await axios.post('/video/livekit/participant-connected/', body);
} }
/**
* POST /video/livekit/terminate-room/
* Terminates the LiveKit room and disconnects all participants.
*/
export async function terminateRoom(lessonId) {
const res = await axios.post('/video/livekit/terminate-room/', { lesson_id: lessonId });
return res.data;
}

View File

@ -27,13 +27,12 @@ export async function updateProfile(data) {
const formData = new FormData(); const formData = new FormData();
if (data.first_name !== undefined) formData.append('first_name', data.first_name); if (data.first_name !== undefined) formData.append('first_name', data.first_name);
if (data.last_name !== undefined) formData.append('last_name', data.last_name); if (data.last_name !== undefined) formData.append('last_name', data.last_name);
if (data.phone !== undefined) formData.append('phone', data.phone);
if (data.bio !== undefined) formData.append('bio', data.bio); if (data.bio !== undefined) formData.append('bio', data.bio);
formData.append('avatar', data.avatar); formData.append('avatar', data.avatar);
const res = await axios.patch('/profile/me/', formData); const res = await axios.patch('/profile/update_profile/', formData);
return res.data; return res.data;
} }
const res = await axios.patch('/profile/me/', data); const res = await axios.patch('/profile/update_profile/', data);
return res.data; return res.data;
} }

View File

@ -1,7 +1,5 @@
import axios from 'src/utils/axios'; import axios from 'src/utils/axios';
// ----------------------------------------------------------------------
export async function getReferralProfile() { export async function getReferralProfile() {
try { try {
const res = await axios.get('/referrals/my_profile/'); const res = await axios.get('/referrals/my_profile/');
@ -21,10 +19,51 @@ export async function getReferralStats() {
} }
export async function getMyReferrals() { export async function getMyReferrals() {
const res = await axios.get('/referrals/my_referrals/'); try {
return res.data; const res = await axios.get('/referrals/my_referrals/');
return res.data;
} catch {
return null;
}
}
export async function getReferralLevels() {
try {
const res = await axios.get('/referrals/levels/');
return res.data;
} catch {
return [];
}
}
export async function getBonusBalance() {
try {
const res = await axios.get('/bonus/balance/');
return res.data;
} catch {
return null;
}
}
export async function getBonusTransactions() {
try {
const res = await axios.get('/bonus/transactions/');
return res.data;
} catch {
return [];
}
}
export async function getReferralEarnings() {
try {
const res = await axios.get('/bonus/earnings/');
return res.data;
} catch {
return [];
}
} }
export async function setReferrer(referralCode) { export async function setReferrer(referralCode) {
await axios.post('/referrals/set_referrer/', { referral_code: referralCode.trim() }); const res = await axios.post('/referrals/set_referrer/', { referral_code: referralCode.trim() });
return res.data;
} }

View File

@ -50,6 +50,12 @@ export async function getMyMentors() {
return res.data; return res.data;
} }
// Client: my outgoing requests to mentors
export async function getMyRequests() {
const res = await axios.get('/mentorship-requests/my-requests/');
return res.data;
}
// Client: incoming invitations from mentors // Client: incoming invitations from mentors
export async function getMyInvitations() { export async function getMyInvitations() {
const res = await axios.get('/invitation/my-invitations/'); const res = await axios.get('/invitation/my-invitations/');
@ -66,6 +72,18 @@ export async function rejectInvitationAsStudent(invitationId) {
return res.data; return res.data;
} }
// Mentor: remove student
export async function removeStudent(clientId) {
const res = await axios.delete(`/manage/clients/${clientId}/remove_client/`);
return res.data;
}
// Student: remove mentor
export async function removeMentor(mentorId) {
const res = await axios.delete(`/student/mentors/${mentorId}/remove/`);
return res.data;
}
// Public: get mentor info by invite link token (no auth required) // Public: get mentor info by invite link token (no auth required)
export async function getInviteLinkInfo(token) { export async function getInviteLinkInfo(token) {
const res = await axios.get('/invitation/info-by-token/', { params: { token } }); const res = await axios.get('/invitation/info-by-token/', { params: { token } });