feat: subscriptions, referrals, students/mentors removal, email templates, calendar fixes
Deploy to Dev / deploy-dev (push) Failing after 31s
Details
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:
parent
2877320987
commit
e49fa9e746
|
|
@ -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}'
|
||||
|
|
@ -131,14 +131,6 @@ class LessonSerializer(serializers.ModelSerializer):
|
|||
start_time = attrs.get('start_time')
|
||||
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:
|
||||
mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None
|
||||
|
|
@ -212,6 +204,10 @@ class LessonCreateSerializer(serializers.ModelSerializer):
|
|||
'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price',
|
||||
'is_recurring'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'client': {'required': False, 'allow_null': True},
|
||||
'group': {'required': False, 'allow_null': True},
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
|
|
@ -307,6 +303,12 @@ class LessonCreateSerializer(serializers.ModelSerializer):
|
|||
duration = attrs.get('duration', 60)
|
||||
mentor = attrs.get('mentor')
|
||||
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 приходят через source='subject' и source='mentor_subject'
|
||||
|
|
@ -380,16 +382,15 @@ class LessonCreateSerializer(serializers.ModelSerializer):
|
|||
attrs['mentor_subject'] = mentor_subject
|
||||
attrs['subject_name'] = mentor_subject.name
|
||||
|
||||
# Проверка: допускаем создание занятий до 30 минут в прошлом
|
||||
# Нормализуем start_time к UTC
|
||||
if start_time:
|
||||
if not django_timezone.is_aware(start_time):
|
||||
start_time = pytz.UTC.localize(start_time)
|
||||
elif start_time.tzinfo != pytz.UTC:
|
||||
start_time = start_time.astimezone(pytz.UTC)
|
||||
|
||||
now = django_timezone.now()
|
||||
tolerance = timedelta(minutes=30)
|
||||
if start_time < now - tolerance:
|
||||
# Проверяем что занятие не начинается более 30 минут назад
|
||||
if start_time < django_timezone.now() - timedelta(minutes=30):
|
||||
raise serializers.ValidationError({
|
||||
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
||||
})
|
||||
|
|
@ -648,6 +649,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
|||
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
||||
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)
|
||||
group_name = serializers.CharField(source='group.name', read_only=True, default=None)
|
||||
subject = serializers.SerializerMethodField()
|
||||
|
||||
def get_subject(self, obj):
|
||||
|
|
@ -671,7 +673,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -101,10 +101,13 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
|
|||
'fields': ('name', 'slug', 'description')
|
||||
}),
|
||||
('Стоимость', {
|
||||
'fields': ('price', 'price_per_student', 'currency', 'billing_period', 'subscription_type', 'trial_days'),
|
||||
'description': 'Для типа "За ученика" укажите price_per_student. Для ежемесячной подписки - price. '
|
||||
'Прогрессирующие скидки настраиваются ниже в разделе "Прогрессирующие скидки". '
|
||||
'Доступные периоды оплаты определяются через скидки за длительность (см. раздел ниже).'
|
||||
'fields': ('price', 'price_per_student', 'currency', 'duration_days', 'subscription_type', 'trial_days'),
|
||||
'description': 'Укажите "Период оплаты (дней)" — именно столько дней будет действовать подписка (например: 30, 60, 90, 180, 365).'
|
||||
}),
|
||||
('Устаревшие настройки', {
|
||||
'fields': ('billing_period',),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Устаревшее поле. Используйте "Период оплаты (дней)" выше.'
|
||||
}),
|
||||
('Целевая аудитория', {
|
||||
'fields': ('target_role',),
|
||||
|
|
@ -153,17 +156,11 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
|
|||
price_display.short_description = 'Цена'
|
||||
|
||||
def billing_period_display(self, obj):
|
||||
"""Отображение периода."""
|
||||
colors = {
|
||||
'monthly': '#17a2b8',
|
||||
'quarterly': '#28a745',
|
||||
'yearly': '#ffc107',
|
||||
'lifetime': '#6610f2'
|
||||
}
|
||||
"""Отображение периода в днях."""
|
||||
days = obj.get_duration_days()
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.billing_period, '#000'),
|
||||
obj.get_billing_period_display()
|
||||
'<span style="background-color: #17a2b8; color: white; padding: 3px 10px; border-radius: 3px;">{} дн.</span>',
|
||||
days
|
||||
)
|
||||
billing_period_display.short_description = 'Период'
|
||||
|
||||
|
|
|
|||
|
|
@ -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="Длительность (дней)",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -234,7 +234,8 @@ class SubscriptionPlan(models.Model):
|
|||
max_length=20,
|
||||
choices=BILLING_PERIOD_CHOICES,
|
||||
default='monthly',
|
||||
verbose_name='Период оплаты'
|
||||
verbose_name='Период оплаты (устарело)',
|
||||
help_text='Устаревшее поле. Используйте "Период оплаты (дней)" ниже.'
|
||||
)
|
||||
|
||||
subscription_type = models.CharField(
|
||||
|
|
@ -254,6 +255,14 @@ class SubscriptionPlan(models.Model):
|
|||
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(
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
|
|
@ -426,16 +435,12 @@ class SubscriptionPlan(models.Model):
|
|||
def get_duration_days(self, custom_days=None):
|
||||
"""
|
||||
Получить длительность подписки в днях.
|
||||
|
||||
Args:
|
||||
custom_days: кастомная длительность в днях (30, 90, 180, 365)
|
||||
|
||||
Returns:
|
||||
int: количество дней
|
||||
Приоритет: custom_days → duration_days (поле модели) → billing_period.
|
||||
"""
|
||||
if custom_days:
|
||||
return custom_days
|
||||
|
||||
if self.duration_days:
|
||||
return self.duration_days
|
||||
if self.billing_period == 'monthly':
|
||||
return 30
|
||||
elif self.billing_period == 'quarterly':
|
||||
|
|
@ -443,7 +448,7 @@ class SubscriptionPlan(models.Model):
|
|||
elif self.billing_period == 'yearly':
|
||||
return 365
|
||||
elif self.billing_period == 'lifetime':
|
||||
return 36500 # 100 лет
|
||||
return 36500
|
||||
return 30
|
||||
|
||||
def get_available_durations(self):
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
|
|||
|
||||
plan_id = serializers.IntegerField()
|
||||
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)
|
||||
start_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
||||
|
|
@ -219,26 +219,9 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
|
|||
return value
|
||||
|
||||
def validate_duration_days(self, value):
|
||||
"""Валидация длительности."""
|
||||
allowed_durations = [30, 90, 180, 365]
|
||||
if value not in allowed_durations:
|
||||
raise serializers.ValidationError(
|
||||
f'Длительность должна быть одним из значений: {", ".join(map(str, allowed_durations))}'
|
||||
)
|
||||
|
||||
# Проверяем доступность периода для плана
|
||||
plan_id = self.initial_data.get('plan_id')
|
||||
if plan_id:
|
||||
try:
|
||||
plan = SubscriptionPlan.objects.get(id=plan_id)
|
||||
if not plan.is_duration_available(value):
|
||||
available = plan.get_available_durations()
|
||||
raise serializers.ValidationError(
|
||||
f'Период {value} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}'
|
||||
)
|
||||
except SubscriptionPlan.DoesNotExist:
|
||||
pass
|
||||
|
||||
"""Валидация длительности — любое положительное число дней."""
|
||||
if value is not None and value < 1:
|
||||
raise serializers.ValidationError('Длительность должна быть не менее 1 дня')
|
||||
return value
|
||||
|
||||
def validate_student_count(self, value):
|
||||
|
|
@ -257,6 +240,10 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
|
|||
except SubscriptionPlan.DoesNotExist:
|
||||
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
|
||||
can_use, error_message = plan.can_be_used(user)
|
||||
|
|
|
|||
|
|
@ -326,15 +326,11 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
|||
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
|
||||
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
|
||||
if st == 'per_student' and student_count <= 0:
|
||||
student_count = 1
|
||||
available = plan.get_available_durations() if hasattr(plan, 'get_available_durations') else [30]
|
||||
if not available:
|
||||
available = [30]
|
||||
if duration_days not in available:
|
||||
duration_days = available[0]
|
||||
try:
|
||||
subscription = SubscriptionService.create_subscription(
|
||||
user=request.user,
|
||||
|
|
@ -788,9 +784,8 @@ class PaymentViewSet(viewsets.ModelViewSet):
|
|||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
# По умолчанию используем первый доступный период или 30 дней
|
||||
available_durations = plan.get_available_durations()
|
||||
duration_days = available_durations[0] if available_durations else 30
|
||||
# По умолчанию используем duration_days из тарифного плана
|
||||
duration_days = plan.get_duration_days()
|
||||
|
||||
# Определяем количество учеников для тарифов "за ученика"
|
||||
if plan.subscription_type == 'per_student':
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
|
|||
# Занятия - оптимизация: используем aggregate для всех подсчетов
|
||||
from django.db.models import Count, Sum, Q
|
||||
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']
|
||||
completed_lessons = lessons_stats['completed']
|
||||
|
||||
# Ближайшие занятия
|
||||
# Ближайшие занятия (включая начавшиеся в последние 90 мин, чтобы отображать кнопку «Подключиться»)
|
||||
upcoming_lessons = lessons.filter(
|
||||
start_time__gte=now,
|
||||
start_time__gte=now - timedelta(minutes=90),
|
||||
status__in=['scheduled', 'in_progress']
|
||||
).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,
|
||||
'first_name': lesson.client.user.first_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,
|
||||
'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': (
|
||||
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'],
|
||||
'total_income': float(item['total_income']),
|
||||
|
|
@ -649,9 +655,9 @@ class ClientDashboardViewSet(viewsets.ViewSet):
|
|||
completed_lessons = lessons_stats['completed']
|
||||
lessons_this_week = lessons_stats['this_week']
|
||||
|
||||
# Ближайшие занятия с оптимизацией
|
||||
# Ближайшие занятия с оптимизацией (включая начавшиеся в последние 90 мин)
|
||||
upcoming_lessons = lessons.filter(
|
||||
start_time__gte=now,
|
||||
start_time__gte=now - timedelta(minutes=90),
|
||||
status__in=['scheduled', 'in_progress']
|
||||
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
|
||||
|
||||
|
|
@ -707,8 +713,11 @@ class ClientDashboardViewSet(viewsets.ViewSet):
|
|||
'title': lesson.title,
|
||||
'mentor': {
|
||||
'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,
|
||||
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
||||
}
|
||||
|
|
@ -716,8 +725,6 @@ class ClientDashboardViewSet(viewsets.ViewSet):
|
|||
]
|
||||
}
|
||||
|
||||
# Сохраняем в кеш на 2 минуты (120 секунд)
|
||||
# Кеш на 30 секунд для актуальности уведомлений
|
||||
cache.set(cache_key, response_data, 30)
|
||||
|
||||
return Response(response_data)
|
||||
|
|
@ -1181,9 +1188,9 @@ class ParentDashboardViewSet(viewsets.ViewSet):
|
|||
completed_lessons = lessons_stats['completed']
|
||||
lessons_this_week = lessons_stats['this_week']
|
||||
|
||||
# Ближайшие занятия
|
||||
# Ближайшие занятия (включая начавшиеся в последние 90 мин)
|
||||
upcoming_lessons = lessons.filter(
|
||||
start_time__gte=now,
|
||||
start_time__gte=now - timedelta(minutes=90),
|
||||
status__in=['scheduled', 'in_progress']
|
||||
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
|
||||
|
||||
|
|
@ -1239,8 +1246,11 @@ class ParentDashboardViewSet(viewsets.ViewSet):
|
|||
'title': lesson.title,
|
||||
'mentor': {
|
||||
'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,
|
||||
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
||||
}
|
||||
|
|
@ -1248,7 +1258,6 @@ class ParentDashboardViewSet(viewsets.ViewSet):
|
|||
]
|
||||
}
|
||||
|
||||
# Сохраняем в кеш на 2 минуты (120 секунд)
|
||||
cache.set(cache_key, response_data, 30)
|
||||
|
||||
return Response(response_data)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from apps.board.models import Board
|
|||
|
||||
def _apply_connection(conn):
|
||||
"""После принятия связи: добавить ментора к студенту, создать доску."""
|
||||
from django.core.cache import cache
|
||||
student_user = conn.student
|
||||
mentor = conn.mentor
|
||||
try:
|
||||
|
|
@ -38,6 +39,10 @@ def _apply_connection(conn):
|
|||
if conn.status != MentorStudentConnection.STATUS_ACCEPTED:
|
||||
conn.status = MentorStudentConnection.STATUS_ACCEPTED
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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="Универсальный код",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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="Статус",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -567,6 +567,7 @@ class MentorStudentConnection(models.Model):
|
|||
STATUS_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя
|
||||
STATUS_ACCEPTED = 'accepted'
|
||||
STATUS_REJECTED = 'rejected'
|
||||
STATUS_REMOVED = 'removed'
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'),
|
||||
|
|
@ -574,6 +575,7 @@ class MentorStudentConnection(models.Model):
|
|||
(STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'),
|
||||
(STATUS_ACCEPTED, 'Принято'),
|
||||
(STATUS_REJECTED, 'Отклонено'),
|
||||
(STATUS_REMOVED, 'Удалено'),
|
||||
]
|
||||
INITIATOR_STUDENT = 'student'
|
||||
INITIATOR_MENTOR = 'mentor'
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
continue
|
||||
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):
|
||||
"""
|
||||
Поиск городов из city.csv по запросу.
|
||||
|
|
@ -921,16 +921,8 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
|||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Кеширование: кеш на 5 минут для каждого пользователя и страницы
|
||||
# Увеличено с 2 до 5 минут для ускорения повторных загрузок страницы "Студенты"
|
||||
page = int(request.query_params.get('page', 1))
|
||||
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).
|
||||
|
|
@ -1026,9 +1018,6 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
|||
for inv in pending
|
||||
]
|
||||
|
||||
# Сохраняем в кеш на 5 минут (300 секунд) для ускорения повторных загрузок
|
||||
cache.set(cache_key, response_data.data, 300)
|
||||
|
||||
return response_data
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='check-user')
|
||||
|
|
@ -1149,7 +1138,7 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
|||
defaults={
|
||||
'status': MentorStudentConnection.STATUS_PENDING_STUDENT,
|
||||
'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:
|
||||
|
|
@ -1161,6 +1150,13 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
|||
'message': 'Приглашение уже отправлено, ожидайте подтверждения',
|
||||
'invitation_id': conn.id,
|
||||
}, 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:
|
||||
conn.confirm_token = secrets.token_urlsafe(32)
|
||||
|
|
@ -1231,17 +1227,38 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
|||
future_lessons_count = future_lessons.count()
|
||||
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)
|
||||
|
||||
# Инвалидируем кеш списка клиентов для этого ментора
|
||||
# Удаляем все варианты кеша для этого пользователя (разные страницы и размеры)
|
||||
# Уведомление ученику
|
||||
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 size in [10, 20, 50, 100, 1000]:
|
||||
cache.delete(f'manage_clients_{user.id}_{page}_{size}')
|
||||
|
||||
return Response({
|
||||
'message': 'Клиент успешно удален',
|
||||
'message': 'Клиент успешно удалён',
|
||||
'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):
|
||||
"""
|
||||
Подтверждение приглашений ментор—студент.
|
||||
|
|
|
|||
|
|
@ -130,6 +130,17 @@ class RegisterSerializer(serializers.ModelSerializer):
|
|||
"""Нормализация email в нижний регистр."""
|
||||
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):
|
||||
"""Проверка совпадения паролей."""
|
||||
if attrs.get('password') != attrs.get('password_confirm'):
|
||||
|
|
|
|||
|
|
@ -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.core.cache import cache
|
||||
|
||||
from .models import MentorStudentConnection
|
||||
from .models import MentorStudentConnection, Group
|
||||
|
||||
|
||||
def _invalidate_manage_clients_cache(mentor_id):
|
||||
|
|
@ -28,3 +28,24 @@ def mentor_student_connection_changed(sender, instance, created, **kwargs):
|
|||
if 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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ def send_welcome_email_task(user_id):
|
|||
context = {
|
||||
'user_full_name': user.get_full_name() or user.email,
|
||||
'user_email': user.email,
|
||||
'login_url': f"{settings.FRONTEND_URL}/auth/jwt/sign-in",
|
||||
}
|
||||
|
||||
# Загружаем HTML и текстовые шаблоны
|
||||
|
|
@ -60,7 +61,7 @@ def send_verification_email_task(user_id, verification_token):
|
|||
user = User.objects.get(id=user_id)
|
||||
|
||||
# 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'
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ def send_password_reset_email_task(user_id, reset_token):
|
|||
user = User.objects.get(id=user_id)
|
||||
|
||||
# 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 = 'Восстановление пароля'
|
||||
|
||||
|
|
@ -144,7 +145,7 @@ def send_student_welcome_email_task(user_id, reset_token):
|
|||
user = User.objects.get(id=user_id)
|
||||
|
||||
# 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 = 'Добро пожаловать на платформу!'
|
||||
|
||||
|
|
|
|||
|
|
@ -4,61 +4,42 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{% block title %}Uchill{% endblock %}</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td {font-family: Arial, sans-serif !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<title>{% block title %}Училл{% endblock %}</title>
|
||||
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
||||
<!-- Wrapper table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
||||
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||
<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>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Main content table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header with logo -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
||||
<!-- Стилизованный текстовый логотип uchill -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span>
|
||||
<br>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content block -->
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding: 0 40px 40px 40px;">
|
||||
<td style="background:#ffffff;padding: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 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>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,167 +4,111 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Приглашение от ментора - Uchill</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td {font-family: Arial, sans-serif !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<title>Приглашение от ментора — Училл</title>
|
||||
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
||||
<!-- Wrapper table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
||||
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||
<tr><td align="center" style="padding:40px 16px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Main content table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header with logo -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
||||
<!-- Стилизованный текстовый логотип uchill -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
||||
</span>
|
||||
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:48px 40px 40px;">
|
||||
|
||||
<!-- Icon -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
|
||||
<tr><td style="background:#EEE8FF;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
|
||||
<span style="font-size:36px;line-height:72px;">🎓</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 0 40px 40px 40px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<!-- Title -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Приглашение от ментора</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Вас приглашают учиться!</h1>
|
||||
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Личное приглашение от ментора</p>
|
||||
|
||||
<!-- Greeting -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Здравствуйте!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main message -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
<strong style="color: #7444FD;">{{ mentor_name }}</strong> приглашает вас в качестве ученика на платформу Uchill.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Mentor highlight -->
|
||||
<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;">
|
||||
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ментор</p>
|
||||
<p style="margin:0;font-size:20px;font-weight:700;color:#111827;">{{ mentor_name }}</p>
|
||||
<p style="margin:4px 0 0 0;font-size:14px;color:#6B7280;">приглашает вас на платформу <strong style="color:#7444FD;">Училл</strong></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
{% if set_password_url %}
|
||||
<!-- New user flow -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Для начала работы установите пароль и подтвердите приглашение.
|
||||
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Для начала занятий установите пароль и подтвердите приглашение — это займёт меньше минуты.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||
<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="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
||||
Установить пароль и подтвердить
|
||||
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||
<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;">
|
||||
Принять приглашение
|
||||
</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;">
|
||||
{{ set_password_url }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
|
||||
<tr><td style="padding:16px;">
|
||||
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
|
||||
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ set_password_url }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% elif confirm_url %}
|
||||
<!-- Existing user flow -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Подтвердите приглашение, чтобы начать занятия с ментором.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||
<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;">
|
||||
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||
<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;">
|
||||
Подтвердить приглашение
|
||||
</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 role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
|
||||
<tr><td style="padding:16px;">
|
||||
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
|
||||
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ 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 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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,143 +4,93 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Восстановление пароля - Uchill</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td {font-family: Arial, sans-serif !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<title>Восстановление пароля — Училл</title>
|
||||
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
||||
<!-- Wrapper table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
||||
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||
<tr><td align="center" style="padding:40px 16px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Main content table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header with logo -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
||||
<!-- Стилизованный текстовый логотип uchill -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
||||
</span>
|
||||
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:48px 40px 40px;">
|
||||
|
||||
<!-- Icon -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
|
||||
<tr><td style="background:#FEF3C7;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
|
||||
<span style="font-size:36px;line-height:72px;">🔐</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 0 40px 40px 40px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<!-- Title -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Восстановление пароля</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Восстановление пароля</h1>
|
||||
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Мы получили запрос на сброс пароля</p>
|
||||
|
||||
<!-- Greeting -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
||||
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main message -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Вы запросили восстановление пароля для вашего аккаунта. Нажмите на кнопку ниже, чтобы установить новый пароль.
|
||||
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Нажмите на кнопку ниже, чтобы установить новый пароль для вашего аккаунта.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||
<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="{{ reset_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
||||
Восстановить пароль
|
||||
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||
<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;">
|
||||
Установить новый пароль
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 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;">
|
||||
{{ reset_url }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<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 style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
|
||||
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ reset_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> Ссылка действительна в течение 24 часов.
|
||||
<!-- Warning -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#FFFBEB;border-left:4px solid #F59E0B;border-radius:8px;margin-bottom:24px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
|
||||
<strong>Важно:</strong> ссылка действительна в течение 24 часов.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</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;">
|
||||
Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений.
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<p style="margin:0;font-size:13px;color:#6B7280;line-height:1.6;">
|
||||
Если вы не запрашивали восстановление пароля — просто проигнорируйте это письмо. Ваш пароль останется без изменений.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</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 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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,152 +4,92 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Добро пожаловать на Uchill</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td {font-family: Arial, sans-serif !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<title>Добро пожаловать на Училл</title>
|
||||
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
||||
<!-- Wrapper table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
||||
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||
<tr><td align="center" style="padding:40px 16px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Main content table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header with logo -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
||||
<!-- Стилизованный текстовый логотип uchill -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
||||
</span>
|
||||
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:48px 40px 40px;">
|
||||
|
||||
<!-- Icon -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
|
||||
<tr><td style="background:#ECFDF5;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
|
||||
<span style="font-size:36px;line-height:72px;">🎉</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 0 40px 40px 40px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<!-- Title -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Добро пожаловать!</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Добро пожаловать!</h1>
|
||||
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Ваш аккаунт на Училл создан</p>
|
||||
|
||||
<!-- Greeting -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
||||
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
|
||||
</p>
|
||||
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Ваш ментор добавил вас на платформу <strong style="color:#7444FD;">Училл</strong>. Для начала работы установите пароль для вашего аккаунта.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main message -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Вас добавили на Uchill. Для начала работы необходимо установить пароль для вашего аккаунта.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Info box -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
|
||||
<tr>
|
||||
<td style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
|
||||
Ваш email для входа:
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #757575;">
|
||||
{{ user_email }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Email info -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F5F0FF;border-left:4px solid #7444FD;border-radius:8px;margin-bottom:32px;">
|
||||
<tr><td style="padding:16px 20px;">
|
||||
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ваш email для входа</p>
|
||||
<p style="margin:0;font-size:15px;font-weight:600;color:#111827;">{{ user_email }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||
<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="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
||||
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||
<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;">
|
||||
Установить пароль
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 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 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 style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
|
||||
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ 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 дней.
|
||||
<!-- Warning -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#FFFBEB;border-left:4px solid #F59E0B;border-radius:8px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
|
||||
<strong>Важно:</strong> ссылка действительна в течение 7 дней.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</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 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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,128 +4,84 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Подтверждение email - Uchill</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td {font-family: Arial, sans-serif !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<title>Подтверждение email — Училл</title>
|
||||
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
||||
<!-- Wrapper table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
||||
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||
<tr><td align="center" style="padding:40px 16px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Main content table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header with logo -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
||||
<!-- Стилизованный текстовый логотип uchill -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
||||
</span>
|
||||
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:48px 40px 40px;">
|
||||
|
||||
<!-- Icon -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
|
||||
<tr><td style="background:#EEE8FF;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
|
||||
<span style="font-size:36px;line-height:72px;">✉️</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 0 40px 40px 40px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<!-- Title -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Подтверждение email</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Подтвердите ваш email</h1>
|
||||
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Осталось всего один шаг!</p>
|
||||
|
||||
<!-- Greeting -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
||||
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main message -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Спасибо за регистрацию на Uchill. Для завершения регистрации необходимо подтвердить ваш email адрес.
|
||||
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Спасибо за регистрацию на <strong style="color:#7444FD;">Училл</strong>. Нажмите на кнопку ниже, чтобы подтвердить ваш адрес электронной почты и активировать аккаунт.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||
<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="{{ verification_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
||||
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||
<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;">
|
||||
Подтвердить email
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 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;">
|
||||
{{ verification_url }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:32px;">
|
||||
<tr><td style="padding:16px;">
|
||||
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
|
||||
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ 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;">
|
||||
Если вы не регистрировались на нашей платформе, просто проигнорируйте это письмо.
|
||||
<!-- Notice -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F0FDF4;border-left:4px solid #22C55E;border-radius:8px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<p style="margin:0;font-size:13px;color:#166534;line-height:1.6;">
|
||||
Если вы не регистрировались на Училл — просто проигнорируйте это письмо.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</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 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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,128 +4,106 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Добро пожаловать на Uchill</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td {font-family: Arial, sans-serif !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<title>Добро пожаловать на Училл</title>
|
||||
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
||||
<!-- Wrapper table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
||||
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||
<tr><td align="center" style="padding:40px 16px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Main content table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header with logo -->
|
||||
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<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">
|
||||
<td style="background:#ffffff;padding:48px 40px 40px;">
|
||||
|
||||
<!-- Icon -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom:24px;">
|
||||
<tr><td style="background:#ECFDF5;border-radius:50%;width:72px;height:72px;text-align:center;vertical-align:middle;">
|
||||
<span style="font-size:36px;line-height:72px;">🚀</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Добро пожаловать на Училл!</h1>
|
||||
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Ваш аккаунт успешно создан</p>
|
||||
|
||||
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
|
||||
</p>
|
||||
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||
Вы успешно зарегистрировались на платформе <strong style="color:#7444FD;">Училл</strong>. Теперь у вас есть доступ ко всем возможностям для обучения.
|
||||
</p>
|
||||
|
||||
<!-- Email info -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F5F0FF;border-left:4px solid #7444FD;border-radius:8px;margin-bottom:32px;">
|
||||
<tr><td style="padding:16px 20px;">
|
||||
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ваш email для входа</p>
|
||||
<p style="margin:0;font-size:15px;font-weight:600;color:#111827;">{{ user_email }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Features -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom:32px;">
|
||||
<tr>
|
||||
<td style="padding:0 0 12px 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#374151;">📅 Онлайн-расписание занятий</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 0 12px 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#374151;">📹 Видеозвонки с интерактивной доской</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#374151;">📚 Домашние задания и материалы</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 0 40px 40px 40px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<!-- Title -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Добро пожаловать!</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Greeting -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main message -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Добро пожаловать на Uchill! Ваш аккаунт успешно создан.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Info box -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
|
||||
<tr>
|
||||
<td style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
|
||||
Ваш email для входа:
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #757575;">
|
||||
{{ user_email }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CTA -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 24px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
||||
Теперь вы можете войти в систему и начать пользоваться всеми возможностями платформы.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td style="padding-top: 8px; padding-bottom: 24px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="background-color: #7444FD; border-radius: 4px;">
|
||||
<a href="https://app.uchill.online/login" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
||||
Войти в систему
|
||||
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||
<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;">
|
||||
Войти в Училл
|
||||
</a>
|
||||
</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 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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from .profile_views import (
|
|||
ClientManagementViewSet,
|
||||
ParentManagementViewSet,
|
||||
InvitationViewSet,
|
||||
StudentMentorViewSet,
|
||||
)
|
||||
from .mentorship_views import MentorshipRequestViewSet
|
||||
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'manage/clients', ClientManagementViewSet, basename='manage-clients')
|
||||
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'manage/parents', ParentManagementViewSet, basename='manage-parents')
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class TelegramBotInfoView(generics.GenericAPIView):
|
|||
GET /api/auth/telegram/bot-info/
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Получение имени бота для использования в Telegram Login Widget."""
|
||||
|
|
@ -74,6 +75,7 @@ class TelegramAuthView(generics.GenericAPIView):
|
|||
"""
|
||||
serializer_class = TelegramAuthSerializer
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
throttle_classes = [BurstRateThrottle]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -155,6 +157,7 @@ class RegisterView(generics.CreateAPIView):
|
|||
queryset = User.objects.all()
|
||||
serializer_class = RegisterSerializer
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
throttle_classes = [BurstRateThrottle]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
|
|
@ -199,6 +202,7 @@ class LoginView(generics.GenericAPIView):
|
|||
"""
|
||||
serializer_class = LoginSerializer
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
throttle_classes = [BurstRateThrottle]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -244,6 +248,7 @@ class LoginByTokenView(generics.GenericAPIView):
|
|||
POST /api/auth/login-by-token/
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
throttle_classes = [BurstRateThrottle]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -353,6 +358,7 @@ class PasswordResetRequestView(generics.GenericAPIView):
|
|||
"""
|
||||
serializer_class = PasswordResetRequestSerializer
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
throttle_classes = [BurstRateThrottle]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -390,6 +396,7 @@ class PasswordResetConfirmView(generics.GenericAPIView):
|
|||
"""
|
||||
serializer_class = PasswordResetConfirmSerializer
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Подтверждение восстановления пароля."""
|
||||
|
|
@ -426,6 +433,7 @@ class EmailVerificationView(generics.GenericAPIView):
|
|||
"""
|
||||
serializer_class = EmailVerificationSerializer
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Подтверждение email пользователя."""
|
||||
|
|
@ -464,6 +472,7 @@ class ResendVerificationEmailView(generics.GenericAPIView):
|
|||
Можно использовать с авторизацией или без (передавая email)
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Повторная отправка письма подтверждения email."""
|
||||
|
|
@ -806,8 +815,8 @@ class GroupViewSet(viewsets.ModelViewSet):
|
|||
distinct=True
|
||||
)
|
||||
).only(
|
||||
'id', 'name', 'description', 'mentor_id', 'max_students',
|
||||
'is_active', 'created_at', 'updated_at'
|
||||
'id', 'name', 'description', 'mentor_id',
|
||||
'created_at'
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
|
|
|||
|
|
@ -159,6 +159,16 @@ app.conf.beat_schedule = {
|
|||
'task': 'apps.materials.tasks.cleanup_old_unused_materials',
|
||||
'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)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Platform</title>
|
||||
<title>Училл</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -169,14 +169,11 @@ export function useGetGroups() {
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function revalidateCalendar(date) {
|
||||
const d = date || new Date();
|
||||
const start = format(startOfMonth(subMonths(d, 1)), 'yyyy-MM-dd');
|
||||
const end = format(endOfMonth(addMonths(d, 1)), 'yyyy-MM-dd');
|
||||
mutate(['calendar', start, end]);
|
||||
function revalidateCalendar() {
|
||||
mutate((key) => Array.isArray(key) && key[0] === 'calendar', undefined, { revalidate: true });
|
||||
}
|
||||
|
||||
export async function createEvent(eventData, currentDate) {
|
||||
export async function createEvent(eventData) {
|
||||
const isGroup = !!eventData.group;
|
||||
const payload = {
|
||||
title: eventData.title || 'Занятие',
|
||||
|
|
@ -190,7 +187,7 @@ export async function createEvent(eventData, currentDate) {
|
|||
};
|
||||
|
||||
const res = await createCalendarLesson(payload);
|
||||
revalidateCalendar(currentDate);
|
||||
revalidateCalendar();
|
||||
return res;
|
||||
}
|
||||
|
||||
|
|
@ -205,11 +202,11 @@ export async function updateEvent(eventData, currentDate) {
|
|||
if (data.status) updatePayload.status = data.status;
|
||||
|
||||
const res = await updateCalendarLesson(String(id), updatePayload);
|
||||
revalidateCalendar(currentDate);
|
||||
revalidateCalendar();
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function deleteEvent(eventId, deleteAllFuture = false, currentDate) {
|
||||
export async function deleteEvent(eventId, deleteAllFuture = false) {
|
||||
await deleteCalendarLesson(String(eventId), deleteAllFuture);
|
||||
revalidateCalendar(currentDate);
|
||||
revalidateCalendar();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,19 +42,10 @@ export const signUp = async ({ email, password, passwordConfirm, firstName, last
|
|||
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;
|
||||
const accessToken = data?.tokens?.access;
|
||||
const refreshToken = data?.tokens?.refresh;
|
||||
|
||||
if (!accessToken) {
|
||||
// Регистрация прошла, но токен не выдан (требуется верификация email)
|
||||
// Всегда требуем подтверждение email перед входом
|
||||
return { requiresVerification: true };
|
||||
}
|
||||
|
||||
await setSession(accessToken, refreshToken);
|
||||
return { requiresVerification: false };
|
||||
} catch (error) {
|
||||
console.error('Error during sign up:', error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -67,8 +67,9 @@ export function useChart(options) {
|
|||
animations: {
|
||||
enabled: true,
|
||||
speed: 360,
|
||||
easing: 'easeinout',
|
||||
animateGradually: { enabled: true, delay: 120 },
|
||||
dynamicAnimation: { enabled: true, speed: 360 },
|
||||
dynamicAnimation: { enabled: true, speed: 400, easing: 'easeinout' },
|
||||
...options?.chart?.animations,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function CustomPopover({ open, onClose, children, anchorEl, slotProps, ..
|
|||
open={!!open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
disableScrollLock
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
slotProps={{
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@
|
|||
import { forwardRef } from 'react';
|
||||
|
||||
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 { RouterLink } from 'src/routes/components';
|
||||
|
||||
import { logoClasses } from './classes';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
|
@ -16,7 +13,7 @@ import { logoClasses } from './classes';
|
|||
* mini=true → icon only (favicon.png)
|
||||
*/
|
||||
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 defaultHeight = mini ? 40 : 40;
|
||||
|
|
@ -40,37 +37,34 @@ export const Logo = forwardRef(
|
|||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<NoSsr
|
||||
fallback={
|
||||
<Box
|
||||
width={w}
|
||||
height={h}
|
||||
className={logoClasses.root.concat(className ? ` ${className}` : '')}
|
||||
sx={{ flexShrink: 0, display: 'inline-flex', verticalAlign: 'middle', ...sx }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
ref={ref}
|
||||
component={RouterLink}
|
||||
href={href}
|
||||
width={w}
|
||||
height={h}
|
||||
className={logoClasses.root.concat(className ? ` ${className}` : '')}
|
||||
aria-label="logo"
|
||||
sx={{
|
||||
const style = {
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
verticalAlign: 'middle',
|
||||
width: w,
|
||||
height: h,
|
||||
...(disableLink && { pointerEvents: 'none' }),
|
||||
...sx,
|
||||
}}
|
||||
};
|
||||
|
||||
if (disableLink) {
|
||||
return (
|
||||
<Box ref={ref} className={className || ''} sx={{ ...style, ...sx }} {...other}>
|
||||
{logo}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={href}
|
||||
className={className || ''}
|
||||
aria-label="logo"
|
||||
style={{ textDecoration: 'none', ...style }}
|
||||
{...other}
|
||||
>
|
||||
{logo}
|
||||
</Box>
|
||||
</NoSsr>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function NavList({ data, render, depth, slotProps, enabledRootRedirect })
|
|||
disabled={data.disabled}
|
||||
hasChild={!!data.children}
|
||||
open={data.children && openMenu}
|
||||
externalLink={isExternalLink(data.path)}
|
||||
externalLink={data.externalLink || isExternalLink(data.path)}
|
||||
enabledRootRedirect={enabledRootRedirect}
|
||||
// styles
|
||||
slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
*************************************** */
|
||||
html {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
body,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -98,9 +98,6 @@ export function HeaderBase({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* -- Logo -- */}
|
||||
<Logo data-slot="logo" />
|
||||
|
||||
{/* -- Divider -- */}
|
||||
<StyledDivider data-slot="divider" />
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import 'dayjs/locale/zh-cn';
|
|||
import 'dayjs/locale/ar-sa';
|
||||
import 'dayjs/locale/ru'; // Добавил русский
|
||||
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { LocalizationProvider as Provider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
|
||||
import { useTranslate } from './use-locales';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function LocalizationProvider({ children }) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
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 { GuestGuard } from 'src/auth/guard/guest-guard';
|
||||
import { SubscriptionGuard } from 'src/auth/guard/subscription-guard';
|
||||
|
||||
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||
import { DashboardLayout } from 'src/layouts/dashboard';
|
||||
|
|
@ -132,9 +135,11 @@ function S({ children }) {
|
|||
function DashboardLayoutWrapper() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SubscriptionGuard>
|
||||
<DashboardLayout>
|
||||
<Outlet />
|
||||
</DashboardLayout>
|
||||
</SubscriptionGuard>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
|
@ -167,6 +172,7 @@ function HomeworkDetailWrapper() {
|
|||
// ----------------------------------------------------------------------
|
||||
|
||||
export function Router() {
|
||||
usePageTitle();
|
||||
return useRoutes([
|
||||
// Root redirect
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
|
|
|
|||
|
|
@ -144,12 +144,20 @@ function IncomeTab() {
|
|||
key={i}
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
py={1}
|
||||
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
{i + 1}. {item.lesson_title || item.target_name || 'Занятие'}
|
||||
{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">
|
||||
{formatCurrency(item.total_income)}
|
||||
</Typography>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import { signUp } from 'src/auth/context/jwt';
|
|||
import { parseApiError } from 'src/utils/parse-api-error';
|
||||
import { useAuthContext } from 'src/auth/hooks';
|
||||
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() {
|
||||
const { checkUserSession } = useAuthContext();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const password = useBoolean();
|
||||
const passwordConfirm = useBoolean();
|
||||
|
|
@ -129,6 +132,7 @@ export function JwtSignUpView() {
|
|||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [refCode, setRefCode] = useState(() => searchParams.get('ref') || '');
|
||||
|
||||
const defaultValues = {
|
||||
firstName: '',
|
||||
|
|
@ -168,6 +172,11 @@ export function JwtSignUpView() {
|
|||
city: data.city,
|
||||
});
|
||||
|
||||
// Применяем реферальный код если указан (после регистрации, до редиректа)
|
||||
if (refCode.trim()) {
|
||||
try { await setReferrer(refCode.trim()); } catch { /* игнорируем — не блокируем регистрацию */ }
|
||||
}
|
||||
|
||||
if (result?.requiresVerification) {
|
||||
setSuccessMsg(
|
||||
`Письмо с подтверждением отправлено на ${data.email}. Пройдите по ссылке в письме для активации аккаунта.`
|
||||
|
|
@ -201,15 +210,23 @@ export function JwtSignUpView() {
|
|||
|
||||
if (successMsg) {
|
||||
return (
|
||||
<>
|
||||
{renderHead}
|
||||
<Alert severity="success">{successMsg}</Alert>
|
||||
<Stack sx={{ mt: 3 }}>
|
||||
<Stack spacing={3} alignItems="center" sx={{ textAlign: 'center', py: 2 }}>
|
||||
<Iconify icon="solar:letter-bold" width={64} sx={{ color: 'primary.main' }} />
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Подтвердите ваш email</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Мы отправили письмо с ссылкой для активации аккаунта на адрес:
|
||||
</Typography>
|
||||
<Typography variant="subtitle2">{methods.getValues('email')}</Typography>
|
||||
</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
|
||||
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />}
|
||||
label={
|
||||
|
|
|
|||
|
|
@ -16,50 +16,12 @@ import CardContent from '@mui/material/CardContent';
|
|||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import { CONFIG } from 'src/config-global';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
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';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
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()}`;
|
||||
}
|
||||
import { getGroups } from 'src/utils/groups-api';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
|
@ -128,6 +90,7 @@ function getUserInitials(u) {
|
|||
|
||||
function BoardCard({ board, currentUser, onClick }) {
|
||||
const isMentor = currentUser?.role === 'mentor';
|
||||
const isGroup = board.access_type === 'group';
|
||||
const otherPerson = isMentor ? board.student : board.mentor;
|
||||
const otherName = getUserName(otherPerson);
|
||||
const otherInitials = getUserInitials(otherPerson);
|
||||
|
|
@ -138,30 +101,23 @@ function BoardCard({ board, currentUser, onClick }) {
|
|||
return (
|
||||
<Card sx={{ borderRadius: 2, height: '100%' }}>
|
||||
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
|
||||
{/* Preview area */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 140,
|
||||
bgcolor: 'primary.lighter',
|
||||
bgcolor: isGroup ? 'info.lighter' : 'primary.lighter',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'background.paper', borderRadius: 1, px: 1, py: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{board.elements_count} элем.
|
||||
</Typography>
|
||||
|
|
@ -174,6 +130,14 @@ function BoardCard({ board, currentUser, onClick }) {
|
|||
{board.title || 'Без названия'}
|
||||
</Typography>
|
||||
|
||||
{isGroup ? (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Iconify icon="solar:users-group-rounded-bold" width={16} sx={{ color: 'info.main' }} />
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
Групповая доска
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Avatar sx={{ width: 24, height: 24, fontSize: 11, bgcolor: 'primary.main' }}>
|
||||
{otherInitials}
|
||||
|
|
@ -182,6 +146,7 @@ function BoardCard({ board, currentUser, onClick }) {
|
|||
{isMentor ? 'Ученик: ' : 'Ментор: '}{otherName}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{lastEdited && (
|
||||
<Typography variant="caption" color="text.disabled" display="block" mt={0.5}>
|
||||
|
|
@ -203,6 +168,7 @@ function BoardListView({ onOpen }) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [students, setStudents] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -220,7 +186,7 @@ function BoardListView({ onOpen }) {
|
|||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Ментор может создать доску с учеником
|
||||
// Ментор: загружаем учеников и группы
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'mentor') return;
|
||||
getMentorStudents()
|
||||
|
|
@ -229,6 +195,7 @@ function BoardListView({ onOpen }) {
|
|||
setStudents(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
getGroups().then(setGroups).catch(() => {});
|
||||
}, [user?.role]);
|
||||
|
||||
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 = !!(
|
||||
process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
|
||||
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}>
|
||||
<Typography variant="h4">Доски</Typography>
|
||||
|
||||
{user?.role === 'mentor' && students.length > 0 && (
|
||||
{user?.role === 'mentor' && (
|
||||
<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 initials = getUserInitials(s.user || s);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// eslint-disable-next-line perfectionist/sort-imports
|
||||
import 'dayjs/locale/ru';
|
||||
import dayjs from 'dayjs';
|
||||
import { z as zod } from 'zod';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
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 Box from '@mui/material/Box';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
|
|
@ -23,14 +21,15 @@ import DialogTitle from '@mui/material/DialogTitle';
|
|||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
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 ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
import { createLiveKitRoom } from 'src/utils/livekit-api';
|
||||
|
||||
import { useGetStudents, useGetSubjects } from 'src/actions/calendar';
|
||||
|
||||
import { getLesson } from 'src/utils/dashboard-api';
|
||||
import { useGetStudents, useGetSubjects, useGetGroups } from 'src/actions/calendar';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Form, Field } from 'src/components/hook-form';
|
||||
|
||||
|
|
@ -56,34 +55,50 @@ export function CalendarForm({
|
|||
onCreateEvent,
|
||||
onUpdateEvent,
|
||||
onDeleteEvent,
|
||||
onCompleteLesson,
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { students } = useGetStudents();
|
||||
const { subjects } = useGetSubjects();
|
||||
const { groups } = useGetGroups();
|
||||
const [joiningVideo, setJoiningVideo] = useState(false);
|
||||
const [fullLesson, setFullLesson] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
|
||||
const EventSchema = zod.object({
|
||||
client: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'),
|
||||
subject: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите предмет'),
|
||||
// individual | group
|
||||
const [lessonType, setLessonType] = useState('individual');
|
||||
|
||||
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(),
|
||||
start_time: zod.any().refine((val) => val !== null, 'Выберите начало занятия'),
|
||||
duration: zod.number().min(1, 'Выберите длительность'),
|
||||
price: zod.coerce.number().min(0, 'Цена не может быть отрицательной'),
|
||||
is_recurring: zod.boolean().optional(),
|
||||
});
|
||||
}), [isGroup]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const start = currentEvent?.start || range?.start || new Date();
|
||||
return {
|
||||
client: currentEvent?.extendedProps?.client?.id || '',
|
||||
subject: currentEvent?.extendedProps?.mentor_subject?.id || '',
|
||||
description: currentEvent?.description || '',
|
||||
start_time: dayjs(start),
|
||||
const defaultValues = useMemo(() => ({
|
||||
client: '',
|
||||
group: '',
|
||||
subject: '',
|
||||
subjectText: '',
|
||||
description: '',
|
||||
start_time: dayjs(range?.start || new Date()),
|
||||
duration: 60,
|
||||
price: 1500,
|
||||
is_recurring: false,
|
||||
};
|
||||
}, [currentEvent, range]);
|
||||
}), [range]);
|
||||
|
||||
const methods = useForm({
|
||||
resolver: zodResolver(EventSchema),
|
||||
|
|
@ -98,36 +113,141 @@ export function CalendarForm({
|
|||
formState: { isSubmitting },
|
||||
} = 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 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) => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const startTime = dayjs(data.start_time);
|
||||
const endTime = startTime.add(data.duration, 'minute');
|
||||
|
||||
// Формируем динамический title: Предмет - Преподаватель - Ученик #ID
|
||||
const selectedSubject = subjects.find(s => s.id === data.subject);
|
||||
const selectedStudent = students.find(s => s.id === data.client);
|
||||
|
||||
const subjectName = selectedSubject?.name || selectedSubject?.subject_name || 'Урок';
|
||||
|
||||
const studentFullName = selectedStudent?.user ? `${selectedStudent.user.last_name} ${selectedStudent.user.first_name}` : (selectedStudent?.full_name || 'Ученик');
|
||||
|
||||
const mentorFullName = currentEvent?.extendedProps?.mentor ? `${currentEvent.extendedProps.mentor.last_name} ${currentEvent.extendedProps.mentor.first_name}` : 'Ментор';
|
||||
const subjectName = data.subject || 'Урок';
|
||||
|
||||
let displayTitle;
|
||||
if (isGroup) {
|
||||
const selectedGroup = groups.find(g => g.id === data.group || g.id === Number(data.group));
|
||||
const groupName = selectedGroup?.name || `Группа ${data.group}`;
|
||||
const lessonNumber = currentEvent?.id ? ` №${currentEvent.id}` : '';
|
||||
displayTitle = `${subjectName} — ${groupName}${lessonNumber}`.trim();
|
||||
} else {
|
||||
const selectedStudent = students.find(s => s.id === data.client);
|
||||
const studentFullName = selectedStudent?.user
|
||||
? `${selectedStudent.user.last_name} ${selectedStudent.user.first_name}`
|
||||
: (selectedStudent?.full_name || 'Ученик');
|
||||
const lessonNumber = currentEvent?.id ? ` №${currentEvent.id}` : '';
|
||||
displayTitle = `${subjectName} — ${studentFullName}${lessonNumber}`.trim();
|
||||
}
|
||||
|
||||
const displayTitle = `${subjectName} ${mentorFullName} - ${studentFullName}${lessonNumber}`.trim();
|
||||
// Находим id предмета по названию если есть
|
||||
const foundSubject = subjects.find(
|
||||
(s) => (s.name || s.subject_name || '').toLowerCase() === subjectName.toLowerCase()
|
||||
);
|
||||
|
||||
const payload = {
|
||||
title: displayTitle,
|
||||
client: data.client,
|
||||
subject: data.subject,
|
||||
subject: foundSubject?.id ?? null,
|
||||
subject_name: subjectName,
|
||||
description: data.description,
|
||||
start_time: startTime.toISOString(),
|
||||
duration: data.duration,
|
||||
price: data.price,
|
||||
is_recurring: data.is_recurring,
|
||||
...(isGroup ? { group: data.group } : { client: data.client }),
|
||||
};
|
||||
|
||||
if (currentEvent?.id) {
|
||||
|
|
@ -138,7 +258,18 @@ export function CalendarForm({
|
|||
onClose();
|
||||
reset();
|
||||
} 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 {
|
||||
const room = await createLiveKitRoom(currentEvent.id);
|
||||
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();
|
||||
} catch (e) {
|
||||
console.error('LiveKit join error', e);
|
||||
|
|
@ -177,7 +308,48 @@ export function CalendarForm({
|
|||
<DialogContent sx={{ p: 3, pb: 0, overflow: 'unset' }}>
|
||||
<Stack spacing={3}>
|
||||
|
||||
<Field.Select name="client" label="Ученик *">
|
||||
{/* Тип занятия — только при создании */}
|
||||
{!isEditing && (
|
||||
<ToggleButtonGroup
|
||||
value={lessonType}
|
||||
exclusive
|
||||
onChange={(_, val) => { if (val) setLessonType(val); }}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
<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>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Field.Select>
|
||||
) : (
|
||||
<Field.Select name="client" label="Ученик *" disabled={isEditing}>
|
||||
<MenuItem value="" disabled>Выберите ученика</MenuItem>
|
||||
{students.map((student) => {
|
||||
const studentName = student.user?.full_name ||
|
||||
|
|
@ -185,17 +357,12 @@ export function CalendarForm({
|
|||
(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 }}
|
||||
>
|
||||
<Avatar src={studentAvatar} sx={{ width: 24, height: 24, fontSize: 12 }}>
|
||||
{letter}
|
||||
</Avatar>
|
||||
<Box component="span">{studentName}</Box>
|
||||
|
|
@ -204,11 +371,12 @@ export function CalendarForm({
|
|||
);
|
||||
})}
|
||||
</Field.Select>
|
||||
)}
|
||||
|
||||
<Field.Select name="subject" label="Предмет *">
|
||||
<MenuItem value="" disabled>Выберите предмет</MenuItem>
|
||||
{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}`}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
|
@ -228,12 +396,8 @@ export function CalendarForm({
|
|||
format="DD.MM.YYYY HH:mm"
|
||||
ampm={false}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
hidden: false,
|
||||
},
|
||||
actionBar: {
|
||||
actions: ['clear', 'cancel', 'accept'],
|
||||
},
|
||||
toolbar: { hidden: false },
|
||||
actionBar: { actions: ['clear', 'cancel', 'accept'] },
|
||||
}}
|
||||
localeText={{
|
||||
cancelButtonLabel: 'Отмена',
|
||||
|
|
@ -268,6 +432,13 @@ export function CalendarForm({
|
|||
/>
|
||||
</Stack>
|
||||
|
||||
{currentEvent?.id ? (
|
||||
values.is_recurring && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Постоянное занятие (повторяется еженедельно)
|
||||
</Typography>
|
||||
)
|
||||
) : (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Controller
|
||||
|
|
@ -279,13 +450,20 @@ export function CalendarForm({
|
|||
label="Постоянное занятие (повторяется еженедельно)"
|
||||
sx={{ ml: 0 }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
{submitError && (
|
||||
<Alert severity="error" onClose={() => setSubmitError(null)} sx={{ mx: 3, mb: 1 }}>
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogActions sx={{ p: 3 }}>
|
||||
{currentEvent?.id && (
|
||||
<Tooltip title="Удалить">
|
||||
<IconButton onClick={onDelete} color="error">
|
||||
<IconButton onClick={() => setConfirmDelete(true)} color="error">
|
||||
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
|
@ -293,15 +471,28 @@ export function CalendarForm({
|
|||
|
||||
<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
|
||||
variant="soft"
|
||||
color="success"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
loading={joiningVideo}
|
||||
onClick={onJoinVideo}
|
||||
startIcon={<Iconify icon="solar:videocamera-record-bold" />}
|
||||
>
|
||||
Войти
|
||||
Подключиться
|
||||
</LoadingButton>
|
||||
)}
|
||||
|
||||
|
|
@ -310,10 +501,27 @@ export function CalendarForm({
|
|||
</Button>
|
||||
|
||||
<LoadingButton type="submit" variant="contained" loading={isSubmitting}>
|
||||
{currentEvent?.id ? 'Сохранить изменения' : 'Создать'}
|
||||
{currentEvent?.id ? 'Сохранить' : 'Создать'}
|
||||
</LoadingButton>
|
||||
</DialogActions>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -396,8 +401,10 @@ function UpcomingLessonsBar({ events, isMentor, timezone }) {
|
|||
const canJoin = diffMin < 11 && diffMin > -90;
|
||||
const isJoining = joiningId === ev.id;
|
||||
|
||||
// Mentor sees student name; student sees mentor name
|
||||
const personName = isMentor
|
||||
// Групповое занятие — показываем название группы, индивидуальное — имя ученика/ментора
|
||||
const personName = ep.group_name
|
||||
? ep.group_name
|
||||
: isMentor
|
||||
? (ep.student || ep.client_name || '')
|
||||
: (ep.mentor_name || ep.mentor || '');
|
||||
const subject = ep.subject_name || ep.subject || ev.title || 'Занятие';
|
||||
|
|
@ -604,7 +611,7 @@ export function CalendarView() {
|
|||
}}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]}
|
||||
locale={ruLocale}
|
||||
timeZone={user?.timezone || 'local'}
|
||||
timeZone={isValidTimezone(user?.timezone) ? user.timezone : 'local'}
|
||||
slotLabelFormat={{ 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
|
|
@ -20,14 +20,13 @@ export function View403() {
|
|||
<Container component={MotionContainer}>
|
||||
<m.div variants={varBounce().in}>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
No permission
|
||||
Доступ запрещён
|
||||
</Typography>
|
||||
</m.div>
|
||||
|
||||
<m.div variants={varBounce().in}>
|
||||
<Typography sx={{ color: 'text.secondary' }}>
|
||||
The page you’re trying to access has restricted access. Please refer to your system
|
||||
administrator.
|
||||
<Typography sx={{ color: ‘text.secondary’ }}>
|
||||
У вас нет прав для просмотра этой страницы.
|
||||
</Typography>
|
||||
</m.div>
|
||||
|
||||
|
|
@ -36,7 +35,7 @@ export function View403() {
|
|||
</m.div>
|
||||
|
||||
<Button component={RouterLink} href="/" size="large" variant="contained">
|
||||
Go to home
|
||||
На главную
|
||||
</Button>
|
||||
</Container>
|
||||
</SimpleLayout>
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ export function View500() {
|
|||
<Container component={MotionContainer}>
|
||||
<m.div variants={varBounce().in}>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
500 Internal server error
|
||||
500 — Ошибка сервера
|
||||
</Typography>
|
||||
</m.div>
|
||||
|
||||
<m.div variants={varBounce().in}>
|
||||
<Typography sx={{ color: 'text.secondary' }}>
|
||||
There was an error, please try again later.
|
||||
Что-то пошло не так. Пожалуйста, попробуйте позже.
|
||||
</Typography>
|
||||
</m.div>
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ export function View500() {
|
|||
</m.div>
|
||||
|
||||
<Button component={RouterLink} href="/" size="large" variant="contained">
|
||||
Go to home
|
||||
На главную
|
||||
</Button>
|
||||
</Container>
|
||||
</SimpleLayout>
|
||||
|
|
|
|||
|
|
@ -20,14 +20,13 @@ export function NotFoundView() {
|
|||
<Container component={MotionContainer}>
|
||||
<m.div variants={varBounce().in}>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Sorry, page not found!
|
||||
Страница не найдена
|
||||
</Typography>
|
||||
</m.div>
|
||||
|
||||
<m.div variants={varBounce().in}>
|
||||
<Typography sx={{ color: 'text.secondary' }}>
|
||||
Sorry, we couldn’t find the page you’re looking for. Perhaps you’ve mistyped the URL? Be
|
||||
sure to check your spelling.
|
||||
<Typography sx={{ color: ‘text.secondary’ }}>
|
||||
Такой страницы не существует. Возможно, вы ошиблись в адресе или страница была удалена.
|
||||
</Typography>
|
||||
</m.div>
|
||||
|
||||
|
|
@ -36,7 +35,7 @@ export function NotFoundView() {
|
|||
</m.div>
|
||||
|
||||
<Button component={RouterLink} href="/" size="large" variant="contained">
|
||||
Go to home
|
||||
На главную
|
||||
</Button>
|
||||
</Container>
|
||||
</SimpleLayout>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import Typography from '@mui/material/Typography';
|
|||
import IconButton from '@mui/material/IconButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import { resolveMediaUrl } from 'src/utils/axios';
|
||||
import {
|
||||
getMySubmission,
|
||||
gradeSubmission,
|
||||
|
|
@ -26,18 +27,11 @@ import {
|
|||
returnSubmissionForRevision,
|
||||
} from 'src/utils/homework-api';
|
||||
|
||||
import { CONFIG } from 'src/config-global';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function fileUrl(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}`);
|
||||
}
|
||||
const fileUrl = (href) => resolveMediaUrl(href);
|
||||
|
||||
function formatDateTime(s) {
|
||||
if (!s) return '—';
|
||||
|
|
@ -465,6 +459,11 @@ export function HomeworkDetailsDrawer({ open, homework, userRole, childId, onClo
|
|||
>
|
||||
<Box sx={{ flex: 1, pr: 2 }}>
|
||||
<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 && (
|
||||
<Typography variant="caption" color={homework.is_overdue ? 'error' : 'text.secondary'}>
|
||||
Дедлайн: {formatDateTime(homework.deadline)}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,14 @@ import Typography from '@mui/material/Typography';
|
|||
import IconButton from '@mui/material/IconButton';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
|
||||
import { resolveMediaUrl } from 'src/utils/axios';
|
||||
import { submitHomework, getHomeworkById, validateHomeworkFiles } from 'src/utils/homework-api';
|
||||
|
||||
import { CONFIG } from 'src/config-global';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function fileUrl(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}`);
|
||||
}
|
||||
const fileUrl = (href) => resolveMediaUrl(href);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './homework-view';
|
||||
export * from './homework-detail-view';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -140,11 +140,32 @@ export function NotificationsView() {
|
|||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
|
||||
<Tab value="unread" label={
|
||||
<Badge badgeContent={unreadCount} color="error">
|
||||
<Box sx={{ pr: unreadCount > 0 ? 1.5 : 0 }}>Непрочитанные</Box>
|
||||
</Badge>
|
||||
} />
|
||||
<Tab
|
||||
value="unread"
|
||||
label={
|
||||
<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="Все" />
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useTheme } from '@mui/material/styles';
|
|||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { CONFIG } from 'src/config-global';
|
||||
import { varAlpha } from 'src/theme/styles';
|
||||
|
|
@ -58,7 +59,7 @@ function UpcomingLessonCard({ lesson }) {
|
|||
setJoining(true);
|
||||
const res = await createLiveKitRoom(lesson.id);
|
||||
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) {
|
||||
console.error('Join error:', err);
|
||||
setJoining(false);
|
||||
|
|
@ -230,6 +231,13 @@ export function OverviewCourseView() {
|
|||
fetchData(period);
|
||||
}, [period, fetchData]);
|
||||
|
||||
// Периодическое обновление ближайших занятий (каждые 60 сек) — чтобы кнопка «Подключиться»
|
||||
// появлялась автоматически, когда до занятия остаётся ≤ 10 минут
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => fetchData(period), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [period, fetchData]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<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={{ 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)' } }}>
|
||||
<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={Number(stats?.summary?.pending_submissions || 0)} color="error" icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-completed.svg`} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gap: 3, display: 'grid', gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' } }}>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ gap: 3, display: 'grid', gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' }, overflow: 'hidden' }}>
|
||||
<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`} />
|
||||
{incomeChart && <CourseHoursSpent title="Статистика дохода" value={period} chart={{ ...incomeChart, colors: [theme.palette.success.main], options: { tooltip: { y: { formatter: (val) => `${val} ₽` } } } }} onValueChange={(val) => setPeriod(val)} />}
|
||||
</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`} />
|
||||
{lessonsChart && <CourseHoursSpent title="Статистика занятий" value={period} chart={{ ...lessonsChart, colors: [theme.palette.warning.main], options: { tooltip: { y: { formatter: (val) => `${val} занятий` } } } }} onValueChange={(val) => setPeriod(val)} />}
|
||||
</Stack>
|
||||
|
|
@ -316,7 +324,7 @@ export function OverviewCourseView() {
|
|||
</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 direction="row" alignItems="center" justifyContent="space-between">
|
||||
|
|
|
|||
|
|
@ -5,18 +5,26 @@ import { useState, useEffect, useCallback } from 'react';
|
|||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Stack from '@mui/material/Stack';
|
||||
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 Typography from '@mui/material/Typography';
|
||||
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 { paths } from 'src/routes/paths';
|
||||
|
||||
import axios from 'src/utils/axios';
|
||||
|
||||
import { varAlpha } from 'src/theme/styles';
|
||||
import { AvatarShape } from 'src/assets/illustrations';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
|
@ -24,53 +32,580 @@ import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
async function getPaymentInfo() {
|
||||
const res = await axios.get('/payments/subscriptions/');
|
||||
const {data} = res;
|
||||
async function getPlans() {
|
||||
const res = await axios.get('/subscriptions/plans/');
|
||||
const { data } = res;
|
||||
if (Array.isArray(data)) return data;
|
||||
return data?.results ?? [];
|
||||
}
|
||||
|
||||
async function getPaymentHistory() {
|
||||
const res = await axios.get('/payments/history/');
|
||||
const {data} = res;
|
||||
if (Array.isArray(data)) return data;
|
||||
return data?.results ?? [];
|
||||
async function getActiveSubscription() {
|
||||
const res = await axios.get('/subscriptions/subscriptions/active/');
|
||||
return res.data ?? null;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!ts) return '—';
|
||||
try {
|
||||
return new Date(ts).toLocaleDateString('ru-RU');
|
||||
return new Date(ts).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAmount(amount, currency) {
|
||||
if (amount == null) return '—';
|
||||
return `${amount} ${currency || '₽'}`;
|
||||
function formatStorage(mb) {
|
||||
if (!mb) return '—';
|
||||
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() {
|
||||
const [subscriptions, setSubscriptions] = useState([]);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [activeSub, setActiveSub] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [subs, hist] = await Promise.all([
|
||||
getPaymentInfo().catch(() => []),
|
||||
getPaymentHistory().catch(() => []),
|
||||
setError(null);
|
||||
const [plansRes, subRes] = await Promise.allSettled([
|
||||
getPlans(),
|
||||
getActiveSubscription(),
|
||||
]);
|
||||
setSubscriptions(subs);
|
||||
setHistory(hist);
|
||||
if (plansRes.status === 'fulfilled') setPlans(plansRes.value);
|
||||
if (subRes.status === 'fulfilled') setActiveSub(subRes.value);
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||||
} finally {
|
||||
|
|
@ -78,9 +613,46 @@ export function PaymentPlatformView() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => { 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 (
|
||||
<DashboardContent>
|
||||
|
|
@ -90,116 +662,66 @@ export function PaymentPlatformView() {
|
|||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{/* Subscriptions */}
|
||||
{subscriptions.length > 0 ? (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||
Активные подписки
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{subscriptions.map((sub, idx) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Box key={idx}>
|
||||
{idx > 0 && <Divider sx={{ mb: 1.5 }} />}
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">
|
||||
{sub.plan_name || sub.name || 'Подписка'}
|
||||
</Typography>
|
||||
{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'}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gap: 3,
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: `repeat(${totalCols}, 1fr)`,
|
||||
},
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Active subscription — always 1 column */}
|
||||
{activeSub ? (
|
||||
<ActiveSubscriptionCard
|
||||
subscription={activeSub}
|
||||
onCancel={handleCancel}
|
||||
cancelling={cancelling}
|
||||
/>
|
||||
)}
|
||||
{sub.price != null && (
|
||||
<Typography variant="subtitle2">
|
||||
{formatAmount(sub.price, sub.currency)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack alignItems="center" spacing={2} sx={{ py: 3 }}>
|
||||
<Iconify icon="eva:credit-card-outline" width={48} color="text.disabled" />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Нет активных подписок
|
||||
<Card sx={{ textAlign: 'center' }}>
|
||||
<CardContent sx={{ py: 5 }}>
|
||||
<Iconify icon="eva:credit-card-outline" width={48} color="text.disabled" sx={{ mb: 1 }} />
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Нет подписки</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Выберите тариф справа
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<Iconify icon="eva:plus-fill" />}>
|
||||
Подключить подписку
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Payment history */}
|
||||
{history.length > 0 && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||
История платежей
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{history.map((item, idx) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 0.75 }}>
|
||||
<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'}
|
||||
{/* Plan cards */}
|
||||
{plans.map((plan, index) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
index={index}
|
||||
isCurrent={activeSub?.plan?.id === plan.id}
|
||||
onSubscribe={handleSubscribeClick}
|
||||
subscribing={subscribing && subscribingPlan?.id === plan.id}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ConfirmSubscribeDialog
|
||||
open={confirmOpen}
|
||||
plan={subscribingPlan}
|
||||
onConfirm={handleSubscribeConfirm}
|
||||
onClose={() => { setConfirmOpen(false); setSubscribingPlan(null); }}
|
||||
loading={subscribing}
|
||||
/>
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,40 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
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 Divider from '@mui/material/Divider';
|
||||
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 Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
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';
|
||||
|
||||
|
|
@ -28,113 +44,112 @@ import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function StatCard({ label, value, icon, color }) {
|
||||
function StatCard({ label, value, icon, color = 'primary' }) {
|
||||
return (
|
||||
<Card variant="outlined" sx={{ flex: 1, minWidth: 140 }}>
|
||||
<Card sx={{ flex: 1, minWidth: 150 }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{
|
||||
width: 44, height: 44, borderRadius: 1.5,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: `${color}.lighter`,
|
||||
}}
|
||||
>
|
||||
<Iconify icon={icon} width={22} color={`${color}.main`} />
|
||||
}}>
|
||||
<Iconify icon={icon} width={24} color={`${color}.main`} />
|
||||
</Box>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{value ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight={700}>{value ?? '—'}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferralTable({ title, items }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
function CopyField({ label, value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{items.map((item, idx) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1, bgcolor: 'background.neutral', borderRadius: 1 }}>
|
||||
<Typography variant="body2">{item.email}</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip label={item.level} size="small" variant="outlined" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.total_points} pts
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<TextField
|
||||
label={label}
|
||||
value={value}
|
||||
fullWidth
|
||||
size="small"
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
|
||||
<IconButton size="small" onClick={handleCopy}>
|
||||
<Iconify icon={copied ? 'eva:checkmark-circle-2-fill' : 'eva:copy-outline'}
|
||||
color={copied ? 'success.main' : 'inherit'} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function ReferralsView() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [levels, setLevels] = useState([]);
|
||||
const [referrals, setReferrals] = useState(null);
|
||||
const [bonusBalance, setBonusBalance] = useState(null);
|
||||
const [bonusTxns, setBonusTxns] = useState([]);
|
||||
const [earnings, setEarnings] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [p, s, r] = await Promise.all([
|
||||
getReferralProfile(),
|
||||
const [s, l, r, b, bt, e] = await Promise.all([
|
||||
getReferralStats(),
|
||||
getMyReferrals().catch(() => null),
|
||||
getReferralLevels(),
|
||||
getMyReferrals(),
|
||||
getBonusBalance(),
|
||||
getBonusTransactions(),
|
||||
getReferralEarnings(),
|
||||
]);
|
||||
setProfile(p);
|
||||
setStats(s);
|
||||
setLevels(Array.isArray(l) ? l : []);
|
||||
setReferrals(r);
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||||
setBonusBalance(b);
|
||||
setBonusTxns(Array.isArray(bt) ? bt : []);
|
||||
setEarnings(Array.isArray(e) ? e : []);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Ошибка загрузки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const link = profile?.referral_link || stats?.referral_code || '';
|
||||
if (!link) return;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
const referralCode = stats?.referral_code || '';
|
||||
const referralLink = referralCode
|
||||
? `${window.location.origin}/auth/jwt/sign-up?ref=${referralCode}`
|
||||
: '';
|
||||
|
||||
const handleCopyCode = () => {
|
||||
const code = profile?.referral_code || stats?.referral_code || '';
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
const currentLevel = stats?.current_level;
|
||||
const nextLevelObj = levels.find((l) => l.level === (currentLevel?.level ?? 0) + 1);
|
||||
const progressPct = nextLevelObj
|
||||
? Math.min(Math.round(((stats?.total_points ?? 0) / nextLevelObj.points_required) * 100), 100)
|
||||
: 100;
|
||||
|
||||
const referralCode = profile?.referral_code || stats?.referral_code || '';
|
||||
const referralLink = profile?.referral_link || '';
|
||||
if (loading) return (
|
||||
<DashboardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>
|
||||
</DashboardContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
|
|
@ -144,155 +159,290 @@ export function ReferralsView() {
|
|||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
||||
|
||||
{loading ? (
|
||||
<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?.current_level && (
|
||||
<Card variant="outlined">
|
||||
{/* Stats */}
|
||||
<Stack direction="row" flexWrap="wrap" gap={2}>
|
||||
<StatCard label="Прямые рефералы" value={stats?.referrals?.direct ?? 0}
|
||||
icon="solar:users-group-rounded-bold" color="primary" />
|
||||
<StatCard label="Непрямые рефералы" value={stats?.referrals?.indirect ?? 0}
|
||||
icon="solar:user-plus-bold" color="info" />
|
||||
<StatCard label="Всего очков" value={stats?.total_points ?? 0}
|
||||
icon="solar:star-bold" color="warning" />
|
||||
<StatCard label="Заработано" value={stats?.earnings?.total != null ? `${stats.earnings.total} ₽` : '0 ₽'}
|
||||
icon="solar:card-bold" color="success" />
|
||||
<StatCard label="Бонусный баланс" value={bonusBalance?.balance != null ? `${bonusBalance.balance} ₽` : '0 ₽'}
|
||||
icon="solar:gift-bold" color="error" />
|
||||
</Stack>
|
||||
|
||||
{/* Level progress */}
|
||||
{currentLevel && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Iconify icon="eva:award-outline" width={32} color="warning.main" />
|
||||
<Box>
|
||||
<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">
|
||||
Уровень {stats.current_level.level} — {stats.current_level.name}
|
||||
Уровень {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 variant="outlined">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||
Ваш реферальный код
|
||||
</Typography>
|
||||
<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>
|
||||
<CopyField label="Реферальный код" value={referralCode} />
|
||||
<CopyField label="Реферальная ссылка" value={referralLink} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Поделитесь ссылкой — когда новый пользователь зарегистрируется по ней,
|
||||
вы получите бонусные очки и процент с его платежей.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Referrals list */}
|
||||
{referrals && (referrals.direct?.length > 0 || referrals.indirect?.length > 0) && (
|
||||
<Card variant="outlined">
|
||||
{/* 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>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||
Мои рефералы
|
||||
{(!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}>
|
||||
<ReferralTable title="Прямые" items={referrals.direct} />
|
||||
{referrals.direct?.length > 0 && referrals.indirect?.length > 0 && <Divider />}
|
||||
<ReferralTable title="Непрямые" items={referrals.indirect} />
|
||||
{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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!referralCode && !loading && (
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Реферальная программа недоступна
|
||||
{/* Уровни */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Box from '@mui/material/Box';
|
||||
|
|
@ -37,6 +37,7 @@ import { resolveMediaUrl } from 'src/utils/axios';
|
|||
import {
|
||||
getStudents,
|
||||
getMyMentors,
|
||||
getMyRequests,
|
||||
getMyInvitations,
|
||||
addStudentInvitation,
|
||||
generateInvitationLink,
|
||||
|
|
@ -46,6 +47,8 @@ import {
|
|||
rejectInvitationAsStudent,
|
||||
confirmInvitationAsStudent,
|
||||
getMentorshipRequestsPending,
|
||||
removeStudent,
|
||||
removeMentor,
|
||||
} from 'src/utils/students-api';
|
||||
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
|
|
@ -63,6 +66,53 @@ function initials(firstName, lastName) {
|
|||
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
|
||||
|
||||
|
|
@ -72,6 +122,23 @@ function MentorStudentList({ onRefresh }) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
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 () => {
|
||||
try {
|
||||
|
|
@ -129,12 +196,11 @@ function MentorStudentList({ onRefresh }) {
|
|||
return (
|
||||
<Grid item key={s.id} xs={12} sm={6} md={4} lg={3}>
|
||||
<Card
|
||||
onClick={() => router.push(paths.dashboard.studentDetail(s.id))}
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
transition: 'box-shadow 0.2s, transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
|
|
@ -142,47 +208,49 @@ function MentorStudentList({ onRefresh }) {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Удалить ученика">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={(e) => { e.stopPropagation(); setRemoveTarget({ id: s.id, name }); }}
|
||||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||
>
|
||||
<Iconify icon="solar:trash-bin-trash-bold" width={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box onClick={() => router.push(paths.dashboard.studentDetail(s.id))} sx={{ cursor: 'pointer' }}>
|
||||
<Avatar
|
||||
src={avatarUrl(u.avatar_url || u.avatar)}
|
||||
sx={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
mx: 'auto',
|
||||
mb: 2,
|
||||
fontSize: 26,
|
||||
bgcolor: 'primary.main',
|
||||
}}
|
||||
sx={{ width: 72, height: 72, mx: 'auto', mb: 2, fontSize: 26, bgcolor: 'primary.main' }}
|
||||
>
|
||||
{initials(u.first_name, u.last_name)}
|
||||
</Avatar>
|
||||
|
||||
<Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{ mb: 2 }}>
|
||||
{u.email || ''}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}>{name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{ mb: 2 }}>{u.email || ''}</Typography>
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
|
||||
{s.total_lessons != null && (
|
||||
<Chip
|
||||
label={`${s.total_lessons} уроков`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon={<Iconify icon="solar:calendar-bold" width={14} />}
|
||||
/>
|
||||
)}
|
||||
{s.subject && (
|
||||
<Chip label={s.subject} size="small" variant="outlined" />
|
||||
<Chip label={`${s.total_lessons} уроков`} size="small" color="primary" variant="soft"
|
||||
icon={<Iconify icon="solar:calendar-bold" width={14} />} />
|
||||
)}
|
||||
{s.subject && <Chip label={s.subject} size="small" variant="outlined" />}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<RemoveConnectionDialog
|
||||
open={!!removeTarget}
|
||||
onClose={() => setRemoveTarget(null)}
|
||||
onConfirm={handleRemove}
|
||||
name={removeTarget?.name || ''}
|
||||
type="student"
|
||||
loading={removing}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -279,56 +347,30 @@ function MentorRequests({ onRefresh }) {
|
|||
|
||||
// 8 отдельных полей для ввода кода
|
||||
function CodeInput({ value, onChange, disabled }) {
|
||||
const refs = useRef([]);
|
||||
const chars = (value || '').padEnd(8, '').split('').slice(0, 8);
|
||||
|
||||
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();
|
||||
const handleChange = (e) => {
|
||||
const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
|
||||
onChange(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} justifyContent="center">
|
||||
{chars.map((ch, i) => (
|
||||
<TextField
|
||||
key={i}
|
||||
inputRef={(el) => { refs.current[i] = el; }}
|
||||
value={ch}
|
||||
onChange={(e) => handleChange(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
onPaste={i === 0 ? handlePaste : undefined}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
placeholder="ABCD1234"
|
||||
autoComplete="off"
|
||||
inputProps={{
|
||||
maxLength: 1,
|
||||
maxLength: 8,
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
letterSpacing: 0,
|
||||
padding: '10px 0',
|
||||
width: 32,
|
||||
fontSize: 22,
|
||||
letterSpacing: 6,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}}
|
||||
sx={{ width: 40 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -508,14 +550,32 @@ function InviteDialog({ open, onClose, onSuccess }) {
|
|||
function ClientMentorList() {
|
||||
const [mentors, setMentors] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [removeTarget, setRemoveTarget] = useState(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMyMentors()
|
||||
.then((list) => setMentors(Array.isArray(list) ? list : []))
|
||||
.catch(() => setMentors([]))
|
||||
.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 (mentors.length === 0) {
|
||||
|
|
@ -528,6 +588,7 @@ function ClientMentorList() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
{mentors.map((m) => {
|
||||
const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
|
||||
|
|
@ -538,6 +599,7 @@ function ClientMentorList() {
|
|||
p: 3,
|
||||
textAlign: 'center',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
transition: 'box-shadow 0.2s, transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
|
|
@ -545,30 +607,40 @@ function ClientMentorList() {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Удалить ментора">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setRemoveTarget({ id: m.id, name })}
|
||||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||
>
|
||||
<Iconify icon="solar:trash-bin-trash-bold" width={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Avatar
|
||||
src={avatarUrl(m.avatar_url)}
|
||||
sx={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
mx: 'auto',
|
||||
mb: 2,
|
||||
fontSize: 26,
|
||||
bgcolor: 'secondary.main',
|
||||
}}
|
||||
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>
|
||||
<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 {
|
||||
setLoading(true);
|
||||
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 {
|
||||
setInvitations([]);
|
||||
} finally {
|
||||
|
|
@ -606,8 +678,14 @@ function ClientInvitations({ onRefresh }) {
|
|||
}
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
if (invitations.length === 0) return null;
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
|
||||
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 (
|
||||
<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() {
|
||||
|
|
@ -742,8 +877,14 @@ export function StudentsView() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClientInvitations key={refreshKey} onRefresh={refresh} />
|
||||
<ClientMentorList key={refreshKey} />
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,19 @@ import {
|
|||
ConnectionStateToast,
|
||||
} from '@livekit/components-react';
|
||||
|
||||
import { getLesson } from 'src/utils/dashboard-api';
|
||||
import { getOrCreateLessonBoard } from 'src/utils/board-api';
|
||||
import { getLiveKitConfig, participantConnected } from 'src/utils/livekit-api';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { getLesson, completeLesson } from 'src/utils/dashboard-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';
|
||||
|
||||
|
|
@ -151,12 +159,17 @@ function StartAudioOverlay() {
|
|||
};
|
||||
|
||||
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 }}>
|
||||
<p style={{ color: '#fff', fontSize: 18, textAlign: 'center', margin: 0 }}>Чтобы слышать собеседника, разрешите воспроизведение звука</p>
|
||||
<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={{ 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 }}>
|
||||
<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' }}>
|
||||
<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' }} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +180,7 @@ function RemoteParticipantPiP({ chatOpen }) {
|
|||
const remoteRef = tracks.find((ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal);
|
||||
if (!remoteRef || !isTrackReference(remoteRef)) return null;
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -175,16 +188,20 @@ function RemoteParticipantPiP({ chatOpen }) {
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function WhiteboardIframe({ boardId, showingBoard }) {
|
||||
const excalidrawUrl = process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '';
|
||||
function WhiteboardIframe({ boardId, showingBoard, user }) {
|
||||
const iframeRef = useRef(null);
|
||||
|
||||
const excalidrawConfigured = !!(
|
||||
process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
|
||||
process.env.NEXT_PUBLIC_EXCALIDRAW_PATH
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawUrl || !boardId) return undefined;
|
||||
if (!excalidrawConfigured || !boardId) return undefined;
|
||||
const container = iframeRef.current;
|
||||
if (!container) return undefined;
|
||||
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.allow = 'camera; microphone; fullscreen';
|
||||
container.innerHTML = '';
|
||||
|
|
@ -192,9 +209,9 @@ function WhiteboardIframe({ boardId, showingBoard }) {
|
|||
return () => {
|
||||
container.innerHTML = '';
|
||||
};
|
||||
}, [boardId, excalidrawUrl]);
|
||||
}, [boardId, excalidrawConfigured, user]);
|
||||
|
||||
if (!excalidrawUrl) {
|
||||
if (!excalidrawConfigured) {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', background: '#111' }}>
|
||||
Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
|
||||
|
|
@ -227,7 +244,7 @@ function PreJoinScreen({ onJoin, onCancel }) {
|
|||
if (!videoEnabled) return undefined;
|
||||
let stream = null;
|
||||
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) => {
|
||||
stream = s;
|
||||
if (videoRef.current) videoRef.current.srcObject = s;
|
||||
|
|
@ -246,42 +263,64 @@ function PreJoinScreen({ onJoin, onCancel }) {
|
|||
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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%)' }}>
|
||||
<div style={{ background: '#fff', borderRadius: 20, maxWidth: 520, width: '100%', overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.1)' }}>
|
||||
<div style={{ background: 'linear-gradient(135deg, #1976d2 0%, #7c4dff 100%)', padding: 24, color: '#fff' }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>Настройки перед входом</h1>
|
||||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>Настройте камеру и микрофон</p>
|
||||
<div style={S.page}>
|
||||
<div style={S.card}>
|
||||
<div style={S.header}>
|
||||
<p style={S.title}>Настройки перед входом</p>
|
||||
<p style={S.sub}>Проверьте камеру и микрофон</p>
|
||||
</div>
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, background: '#000', borderRadius: 12, aspectRatio: '16/9', overflow: 'hidden' }}>
|
||||
{videoEnabled ? (
|
||||
<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 }}>
|
||||
<span style={{ fontSize: 48 }}>📵</span>
|
||||
<p style={{ margin: 8 }}>Камера выключена</p>
|
||||
<div style={S.body}>
|
||||
<div style={S.preview}>
|
||||
{videoEnabled
|
||||
? <video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: (
|
||||
<div style={S.previewOff}>
|
||||
<Iconify icon="solar:camera-slash-bold" width={40} />
|
||||
<span style={{ fontSize: 13 }}>Камера выключена</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
|
||||
{[
|
||||
{ label: 'Микрофон', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) },
|
||||
{ label: 'Камера', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) },
|
||||
].map(({ label, enabled, toggle }) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 16, background: '#f5f5f5', borderRadius: 12 }}>
|
||||
<span>{label}</span>
|
||||
<button onClick={toggle} type="button" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: enabled ? '#1976d2' : '#888', color: '#fff', cursor: 'pointer' }}>
|
||||
{enabled ? 'Выключить' : 'Включить'}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{devices.map(({ key, label, icon, enabled, toggle }) => (
|
||||
<div key={key} style={S.toggleRow}>
|
||||
<span style={S.toggleLabel}>
|
||||
<Iconify icon={icon} width={20} style={{ color: enabled ? '#60a5fa' : 'rgba(255,255,255,0.35)' }} />
|
||||
{label}
|
||||
</span>
|
||||
<button type="button" onClick={toggle} style={S.toggleBtn(enabled)}>
|
||||
{enabled ? 'Вкл' : 'Выкл'}
|
||||
</button>
|
||||
</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>
|
||||
<button type="button" onClick={handleJoin} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: 'none', background: '#1976d2', color: '#fff', fontWeight: 600, cursor: 'pointer' }}>
|
||||
📹 Войти в конференцию
|
||||
</button>
|
||||
|
||||
<div style={S.actions}>
|
||||
<button type="button" onClick={onCancel} style={S.cancelBtn}>Отмена</button>
|
||||
<button type="button" onClick={handleJoin} style={S.joinBtn}>Войти в конференцию</button>
|
||||
</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 router = useRouter();
|
||||
const { user } = useAuthContext();
|
||||
const [showPlatformChat, setShowPlatformChat] = useState(false);
|
||||
const [showExitModal, setShowExitModal] = useState(false);
|
||||
const [showExitMenu, setShowExitMenu] = 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(() => {
|
||||
const onConnected = () => {
|
||||
|
|
@ -335,7 +474,7 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
|
|||
btn.className = 'lk-button lk-custom-exit-button';
|
||||
btn.title = 'Выйти';
|
||||
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);
|
||||
}
|
||||
}, 800);
|
||||
|
|
@ -349,16 +488,61 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (user?.role === 'mentor') {
|
||||
setShowExitModal(true);
|
||||
} else {
|
||||
room.disconnect();
|
||||
}
|
||||
// Control bar button click: capture its rect for popup positioning
|
||||
const handler = (e) => {
|
||||
const btn = e?.detail?.target ?? document.querySelector('.lk-custom-exit-button');
|
||||
if (btn) setExitBtnRect(btn.getBoundingClientRect());
|
||||
setShowExitMenu(true);
|
||||
};
|
||||
window.addEventListener('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
|
||||
useEffect(() => {
|
||||
|
|
@ -401,103 +585,159 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
|
|||
};
|
||||
}, [room]);
|
||||
|
||||
const sidebarStyle = {
|
||||
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 sidebarRight = showPlatformChat ? CHAT_PANEL_WIDTH + 12 : 12;
|
||||
|
||||
const iconBtn = (active, disabled, title, icon, onClick) => (
|
||||
const SideBtn = ({ active, disabled: dis, title, icon, onClick: handleClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
disabled={dis}
|
||||
title={title}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: active ? '#1976d2' : 'rgba(255,255,255,0.2)',
|
||||
color: '#fff',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
border: active ? '1px solid rgba(25,118,210,0.5)' : '1px solid rgba(255,255,255,0.08)',
|
||||
background: active ? 'rgba(25,118,210,0.25)' : 'rgba(255,255,255,0.06)',
|
||||
color: active ? '#60a5fa' : dis ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.7)',
|
||||
cursor: dis ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Iconify icon={icon} width={20} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', position: 'relative', display: 'flex' }}>
|
||||
<div style={{ height: '100vh', width: '100%', position: 'relative', overflow: 'hidden', background: '#0d0d0d' }}>
|
||||
<StartAudioOverlay />
|
||||
<div style={{ flex: 1, position: 'relative', background: '#000' }}>
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: showBoard ? 0 : 1 }}>
|
||||
<LiveKitLayoutErrorBoundary>
|
||||
<VideoConference chatMessageFormatter={(message) => message} />
|
||||
</LiveKitLayoutErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<>
|
||||
{showBoard && <RemoteParticipantPiP chatOpen={showPlatformChat} />}
|
||||
|
||||
{/* Board burger button */}
|
||||
{showBoard && (
|
||||
<button
|
||||
type="button"
|
||||
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="Меню"
|
||||
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>
|
||||
)}
|
||||
{showNavMenu && (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
|
||||
{/* Nav menu — existing NavMobile, all links open in new tab */}
|
||||
<NavMobile
|
||||
open={showNavMenu}
|
||||
onClose={() => setShowNavMenu(false)}
|
||||
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 }}
|
||||
>
|
||||
<Iconify icon={showExitMenu ? 'solar:close-bold' : 'solar:exit-bold'} width={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Exit menu popup — anchored to the exit button rect */}
|
||||
{showExitMenu && exitBtnRect && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={() => setShowNavMenu(false)}
|
||||
onKeyDown={(e) => e.key === 'Escape' && 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' }}
|
||||
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' }}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 16, padding: 24, minWidth: 240 }}>
|
||||
<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 }}>
|
||||
🏠 На главную
|
||||
<Iconify icon="solar:exit-bold" width={18} style={{ color: '#94a3b8', flexShrink: 0 }} />
|
||||
Выйти
|
||||
</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 }}>
|
||||
✕ Закрыть
|
||||
{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>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showPlatformChat && lesson && (
|
||||
<VideoCallChatPanel
|
||||
lesson={lesson}
|
||||
currentUser={user}
|
||||
onClose={() => setShowPlatformChat(false)}
|
||||
/>
|
||||
)}
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<ExitLessonModal
|
||||
isOpen={showExitModal}
|
||||
lessonId={lessonId}
|
||||
onClose={() => setShowExitModal(false)}
|
||||
onExit={() => room.disconnect()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -518,10 +758,23 @@ export function VideoCallView() {
|
|||
const [avReady, setAvReady] = useState(false);
|
||||
const [lessonCompleted, setLessonCompleted] = useState(false);
|
||||
const [effectiveLessonId, setEffectiveLessonId] = useState(null);
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [boardId, setBoardId] = useState(null);
|
||||
const [boardLoading, setBoardLoading] = useState(false);
|
||||
const [showBoard, setShowBoard] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -553,6 +806,7 @@ export function VideoCallView() {
|
|||
if (lessonIdParam) {
|
||||
try {
|
||||
const l = await getLesson(lessonIdParam);
|
||||
setLesson(l);
|
||||
if (l.status === 'completed') {
|
||||
const now = new Date();
|
||||
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"
|
||||
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>
|
||||
)}
|
||||
<LiveKitRoom
|
||||
|
|
@ -677,7 +931,7 @@ export function VideoCallView() {
|
|||
connect
|
||||
audio={audioEnabled}
|
||||
video={videoEnabled}
|
||||
onDisconnected={() => router.push('/dashboard')}
|
||||
onDisconnected={() => router.push(postDisconnectRef.current)}
|
||||
style={{ height: '100vh' }}
|
||||
data-lk-theme="default"
|
||||
options={{
|
||||
|
|
@ -697,10 +951,12 @@ export function VideoCallView() {
|
|||
>
|
||||
<RoomContent
|
||||
lessonId={effectiveLessonId}
|
||||
lesson={lesson}
|
||||
boardId={boardId}
|
||||
boardLoading={boardLoading}
|
||||
showBoard={showBoard}
|
||||
setShowBoard={setShowBoard}
|
||||
postDisconnectRef={postDisconnectRef}
|
||||
/>
|
||||
<RoomAudioRenderer />
|
||||
<ConnectionStateToast />
|
||||
|
|
|
|||
|
|
@ -1,429 +1,369 @@
|
|||
/**
|
||||
* Кастомизация LiveKit через CSS переменные.
|
||||
* Все стили и скрипты LiveKit отдаются с нашего сервера (бандл + этот файл).
|
||||
* LiveKit custom theme — platform edition
|
||||
*/
|
||||
|
||||
@keyframes lk-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* ─── No scroll on video-call page (applied via useEffect in VideoCallView) ── */
|
||||
|
||||
/* ─── CSS Variables ─────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Цвета фона */
|
||||
--lk-bg: #1a1a1a;
|
||||
--lk-bg2: #2a2a2a;
|
||||
--lk-bg3: #3a3a3a;
|
||||
--vc-bg: #0d0d0d;
|
||||
--vc-surface: rgba(255, 255, 255, 0.06);
|
||||
--vc-surface-hover: rgba(255, 255, 255, 0.12);
|
||||
--vc-border: rgba(255, 255, 255, 0.10);
|
||||
--vc-accent: #1976d2;
|
||||
--vc-accent-hover: #1565c0;
|
||||
--vc-danger: #ef5350;
|
||||
--vc-text: #ffffff;
|
||||
--vc-text-dim: rgba(255, 255, 255, 0.55);
|
||||
--vc-radius: 14px;
|
||||
--vc-blur: blur(18px);
|
||||
|
||||
/* Цвета текста */
|
||||
/* LiveKit CSS vars */
|
||||
--lk-bg: #0d0d0d;
|
||||
--lk-bg2: #1a1a1a;
|
||||
--lk-bg3: #262626;
|
||||
--lk-fg: #ffffff;
|
||||
--lk-fg2: rgba(255, 255, 255, 0.7);
|
||||
|
||||
/* Основные цвета */
|
||||
--lk-control-bg: var(--md-sys-color-primary);
|
||||
--lk-control-hover-bg: var(--md-sys-color-primary-container);
|
||||
--lk-button-bg: rgba(255, 255, 255, 0.15);
|
||||
--lk-button-hover-bg: rgba(255, 255, 255, 0.25);
|
||||
|
||||
/* Границы */
|
||||
--lk-border-color: rgba(255, 255, 255, 0.1);
|
||||
--lk-control-bg: rgba(255, 255, 255, 0.08);
|
||||
--lk-control-hover-bg: rgba(255, 255, 255, 0.16);
|
||||
--lk-button-bg: rgba(255, 255, 255, 0.08);
|
||||
--lk-border-color: rgba(255, 255, 255, 0.10);
|
||||
--lk-border-radius: 12px;
|
||||
|
||||
/* Фокус */
|
||||
--lk-focus-ring: var(--md-sys-color-primary);
|
||||
|
||||
/* Ошибки */
|
||||
--lk-danger: var(--md-sys-color-error);
|
||||
|
||||
/* Размеры */
|
||||
--lk-control-bar-height: 80px;
|
||||
--lk-participant-tile-gap: 12px;
|
||||
--lk-danger: #ef5350;
|
||||
--lk-accent-bg: #1976d2;
|
||||
--lk-accent-fg: #ffffff;
|
||||
--lk-control-bar-height: 76px;
|
||||
--lk-grid-gap: 10px;
|
||||
}
|
||||
|
||||
/* Панель управления — без ограничения по ширине */
|
||||
/* ─── Room root ─────────────────────────────────────────────────────────────── */
|
||||
.lk-room-container {
|
||||
background: var(--vc-bg) !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ─── Control bar ───────────────────────────────────────────────────────────── */
|
||||
.lk-control-bar {
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
backdrop-filter: blur(20px) !important;
|
||||
border-radius: 16px !important;
|
||||
padding: 12px 16px !important;
|
||||
margin: 16px !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||
max-width: none !important;
|
||||
position: fixed !important;
|
||||
bottom: 20px !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !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-menu {
|
||||
max-width: none !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* Кнопки управления — ширина по контенту, без жёсткого ограничения */
|
||||
.lk-control-bar .lk-button {
|
||||
min-width: 48px !important;
|
||||
width: auto !important;
|
||||
height: 48px !important;
|
||||
/* ─── All buttons ───────────────────────────────────────────────────────────── */
|
||||
.lk-button,
|
||||
.lk-start-audio-button,
|
||||
.lk-chat-toggle,
|
||||
.lk-disconnect-button {
|
||||
min-width: 46px !important;
|
||||
height: 46px !important;
|
||||
border-radius: 12px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 12px !important;
|
||||
transition: background 0.18s ease, transform 0.12s ease !important;
|
||||
padding: 0 14px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
letter-spacing: 0.01em !important;
|
||||
gap: 7px !important;
|
||||
}
|
||||
|
||||
/* Русские подписи: скрываем английский текст, показываем свой */
|
||||
.lk-control-bar .lk-button[data-lk-source="microphone"],
|
||||
.lk-control-bar .lk-button[data-lk-source="camera"],
|
||||
.lk-control-bar .lk-button[data-lk-source="screen_share"],
|
||||
.lk-button:not(:disabled):hover {
|
||||
transform: translateY(-1px) !important;
|
||||
background: var(--vc-surface-hover) !important;
|
||||
}
|
||||
|
||||
.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-disconnect-button,
|
||||
.lk-control-bar .lk-start-audio-button {
|
||||
font-size: 0 !important;
|
||||
}
|
||||
|
||||
.lk-control-bar .lk-button[data-lk-source="microphone"] > 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-button > svg,
|
||||
.lk-control-bar .lk-chat-toggle > svg,
|
||||
.lk-control-bar .lk-disconnect-button > svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.lk-control-bar .lk-button[data-lk-source="microphone"]::after {
|
||||
content: "Микрофон";
|
||||
font-size: 1rem;
|
||||
}
|
||||
.lk-control-bar .lk-button[data-lk-source="microphone"]::after { content: "Микрофон"; font-size: 13px; }
|
||||
.lk-control-bar .lk-button[data-lk-source="camera"]::after { content: "Камера"; font-size: 13px; }
|
||||
.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 {
|
||||
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 */
|
||||
/* ─── Burger & custom exit (injected via JS) ────────────────────────────────── */
|
||||
.lk-burger-button {
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
color: #fff !important;
|
||||
background: var(--vc-surface) !important;
|
||||
color: var(--vc-text) !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
.lk-burger-button:hover {
|
||||
background: var(--vc-surface-hover) !important;
|
||||
}
|
||||
|
||||
/* Скрываем стандартную кнопку «Выйти» — используем свою внутри панели (модалка: Выйти / Выйти и завершить занятие) */
|
||||
.lk-control-bar .lk-disconnect-button {
|
||||
display: none !important;
|
||||
}
|
||||
.lk-control-bar .lk-disconnect-button::after {
|
||||
content: "Выйти";
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* Hide default disconnect — we use our own */
|
||||
.lk-control-bar .lk-disconnect-button { display: none !important; }
|
||||
|
||||
/* Наша кнопка «Выйти» — внутри панели, рядом с «Поделиться экраном» */
|
||||
/* Our exit button */
|
||||
.lk-control-bar .lk-custom-exit-button {
|
||||
font-size: 0 !important;
|
||||
background: var(--md-sys-color-error) !important;
|
||||
color: #fff !important;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: rgba(239, 83, 80, 0.18) !important;
|
||||
color: #fc8181 !important;
|
||||
border: 1px solid rgba(239, 83, 80, 0.30) !important;
|
||||
border-radius: 12px !important;
|
||||
cursor: pointer !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center !important;
|
||||
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 {
|
||||
content: "Выйти";
|
||||
font-size: 1rem;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lk-control-bar .lk-custom-exit-button > .material-symbols-outlined {
|
||||
color: #fff !important;
|
||||
.lk-control-bar .lk-custom-exit-button:hover {
|
||||
background: rgba(239, 83, 80, 0.30) !important;
|
||||
}
|
||||
|
||||
/* Скрываем кнопку «Начать видео» — у нас свой StartAudioOverlay */
|
||||
.lk-control-bar .lk-start-audio-button {
|
||||
display: none !important;
|
||||
}
|
||||
/* Hide audio start button (we handle it ourselves) */
|
||||
.lk-control-bar .lk-start-audio-button { display: none !important; }
|
||||
/* Hide LiveKit chat toggle (we use our own) */
|
||||
.lk-control-bar .lk-chat-toggle { display: none !important; }
|
||||
|
||||
/* Кнопки без текста (только иконка) — минимальный размер */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Плитки участников */
|
||||
/* ─── Participant tiles ──────────────────────────────────────────────────────── */
|
||||
.lk-participant-tile {
|
||||
border-radius: 12px !important;
|
||||
border-radius: 14px !important;
|
||||
overflow: hidden !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
|
||||
background: #111 !important;
|
||||
}
|
||||
|
||||
/* Плейсхолдер без камеры: скрываем дефолтную SVG, показываем аватар из API */
|
||||
.lk-participant-tile .lk-participant-placeholder svg {
|
||||
display: none !important;
|
||||
.lk-participant-tile[data-lk-speaking="true"]:not([data-lk-source="screen_share"])::after {
|
||||
border-width: 2px !important;
|
||||
border-color: #60a5fa !important;
|
||||
transition-delay: 0s !important;
|
||||
transition-duration: 0.15s !important;
|
||||
}
|
||||
|
||||
/* Контейнер для аватара — нужен для container queries */
|
||||
.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 {
|
||||
/* Квадрат: меньшая сторона контейнера, максимум 400px */
|
||||
--avatar-size: min(min(80cqw, 80cqh), 400px);
|
||||
--avatar-size: min(min(70cqw, 70cqh), 360px);
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
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) {
|
||||
.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 180px; height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Имя участника — белый текст (Камера, PiP) */
|
||||
.lk-participant-name {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
/* Participant name badge */
|
||||
.lk-participant-metadata { bottom: 10px !important; left: 10px !important; right: 10px !important; }
|
||||
.lk-participant-metadata-item {
|
||||
background: rgba(0, 0, 0, 0.55) !important;
|
||||
backdrop-filter: blur(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;
|
||||
color: #fff !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
/* Чат LiveKit скрыт — используем чат сервиса (платформы) */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Сетка участников */
|
||||
/* ─── Video layouts ──────────────────────────────────────────────────────────── */
|
||||
.lk-grid-layout {
|
||||
gap: 12px !important;
|
||||
padding: 12px !important;
|
||||
gap: var(--lk-grid-gap) !important;
|
||||
padding: var(--lk-grid-gap) !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.lk-grid-layout .lk-participant-tile { min-height: 200px; }
|
||||
|
||||
/* Меню выбора устройств — без ограничения по ширине */
|
||||
.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 (расширяется). */
|
||||
/* Focus layout: remote fills screen, local in PiP */
|
||||
.lk-focus-layout {
|
||||
position: relative !important;
|
||||
grid-template-columns: 5fr 1fr !important;
|
||||
}
|
||||
|
||||
/* Основное видео (собеседник) на весь экран */
|
||||
.lk-focus-layout .lk-focus-layout-wrapper {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Демонстрация экрана — на весь экран только в режиме фокуса (после клика на раскрытие) */
|
||||
/* Структура: .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;
|
||||
}
|
||||
|
||||
/* Карусель с локальным видео (своя камера) */
|
||||
/* Carousel (local camera) — bottom-right PiP */
|
||||
.lk-focus-layout .lk-carousel {
|
||||
position: absolute !important;
|
||||
bottom: 80px !important;
|
||||
bottom: 96px !important;
|
||||
right: 16px !important;
|
||||
width: 280px !important;
|
||||
width: 240px !important;
|
||||
height: auto !important;
|
||||
z-index: 100 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.lk-focus-layout .lk-carousel .lk-participant-tile {
|
||||
width: 280px !important;
|
||||
height: 158px !important;
|
||||
width: 240px !important;
|
||||
height: 135px !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.10) !important;
|
||||
}
|
||||
|
||||
/* Скрыть стрелки карусели (они не нужны для 1 участника) */
|
||||
.lk-focus-layout .lk-carousel button[aria-label*="Previous"],
|
||||
.lk-focus-layout .lk-carousel button[aria-label*="Next"] {
|
||||
display: none !important;
|
||||
}
|
||||
.lk-focus-layout .lk-carousel button[aria-label*="Next"] { display: none !important; }
|
||||
|
||||
/* Если используется grid layout (фоллбэк) */
|
||||
.lk-grid-layout {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
/* Для 2 участников: первый на весь экран, второй в углу */
|
||||
/* Grid 2-person: first full, second PiP */
|
||||
.lk-grid-layout[data-lk-participants="2"] {
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
|
||||
position: absolute !important;
|
||||
bottom: 80px !important;
|
||||
bottom: 96px !important;
|
||||
right: 16px !important;
|
||||
width: 280px !important;
|
||||
height: 158px !important;
|
||||
width: 240px !important;
|
||||
height: 135px !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.10) !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.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 чтобы не обрезать, чёткое отображение */
|
||||
/* Screen share */
|
||||
.lk-participant-media-video { background: #000 !important; }
|
||||
.lk-participant-media-video[data-lk-source="screen_share"] {
|
||||
object-fit: contain !important;
|
||||
object-position: center !important;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
/* Сетка: минимальная высота плиток для крупного видео */
|
||||
.lk-grid-layout {
|
||||
min-height: 0;
|
||||
.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
|
||||
position: absolute !important;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@ axiosInstance.interceptors.response.use(
|
|||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,46 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolves lesson → mentor/student IDs → getOrCreateMentorStudentBoard
|
||||
* For group lessons → getOrCreateGroupBoard
|
||||
* For individual lessons → getOrCreateMentorStudentBoard
|
||||
*/
|
||||
export async function getOrCreateLessonBoard(lessonId) {
|
||||
const lessonRes = await axios.get(`/schedule/lessons/${lessonId}/`);
|
||||
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 client = lesson.client;
|
||||
let studentId;
|
||||
|
|
|
|||
|
|
@ -3,17 +3,21 @@ import axios from 'src/utils/axios';
|
|||
// ----------------------------------------------------------------------
|
||||
|
||||
export function normalizeChat(c) {
|
||||
const isGroup = c?.chat_type === 'group';
|
||||
const other = c?.other_participant ?? {};
|
||||
const name =
|
||||
other.full_name ||
|
||||
const name = isGroup
|
||||
? (c?.name || 'Группа')
|
||||
: (other.full_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 || '';
|
||||
return {
|
||||
...c,
|
||||
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,
|
||||
last_message: lastText,
|
||||
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) {
|
||||
const res = await axios.get('/users/search/', { params: { q: query } });
|
||||
const {data} = res;
|
||||
|
|
|
|||
|
|
@ -57,8 +57,11 @@ export async function getParentDashboard(options) {
|
|||
/**
|
||||
* 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 });
|
||||
if (extraParams) {
|
||||
Object.entries(extraParams).forEach(([k, v]) => { if (v != null) params.append(k, v); });
|
||||
}
|
||||
const config = options?.signal ? { signal: options.signal } : undefined;
|
||||
const res = await axios.get(`/schedule/lessons/calendar/?${params}`, config);
|
||||
return res.data;
|
||||
|
|
@ -152,6 +155,21 @@ export async function completeLesson(id, notes, mentorGrade, schoolGrade, homewo
|
|||
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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export async function getHomework(params) {
|
|||
if (params?.status) q.append('status', params.status);
|
||||
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?.lesson_id) q.append('lesson', params.lesson_id);
|
||||
const query = q.toString();
|
||||
const url = `/homework/homeworks/${query ? `?${query}` : ''}`;
|
||||
const res = await axios.get(url);
|
||||
|
|
@ -106,6 +107,8 @@ export async function uploadHomeworkFile(homeworkId, file) {
|
|||
formData.append('homework', String(homeworkId));
|
||||
formData.append('file_type', 'assignment');
|
||||
formData.append('file', file);
|
||||
formData.append('filename', file.name);
|
||||
formData.append('file_size', String(file.size));
|
||||
const res = await axios.post('/homework/files/', formData);
|
||||
return res.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,3 +26,12 @@ export async function participantConnected({ roomName, lessonId }) {
|
|||
if (lessonId != null) body.lesson_id = lessonId;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,12 @@ export async function updateProfile(data) {
|
|||
const formData = new FormData();
|
||||
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.phone !== undefined) formData.append('phone', data.phone);
|
||||
if (data.bio !== undefined) formData.append('bio', data.bio);
|
||||
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;
|
||||
}
|
||||
const res = await axios.patch('/profile/me/', data);
|
||||
const res = await axios.patch('/profile/update_profile/', data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import axios from 'src/utils/axios';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function getReferralProfile() {
|
||||
try {
|
||||
const res = await axios.get('/referrals/my_profile/');
|
||||
|
|
@ -21,10 +19,51 @@ export async function getReferralStats() {
|
|||
}
|
||||
|
||||
export async function getMyReferrals() {
|
||||
try {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ export async function getMyMentors() {
|
|||
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
|
||||
export async function getMyInvitations() {
|
||||
const res = await axios.get('/invitation/my-invitations/');
|
||||
|
|
@ -66,6 +72,18 @@ export async function rejectInvitationAsStudent(invitationId) {
|
|||
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)
|
||||
export async function getInviteLinkInfo(token) {
|
||||
const res = await axios.get('/invitation/info-by-token/', { params: { token } });
|
||||
|
|
|
|||
Loading…
Reference in New Issue