Compare commits
16 Commits
17bed2b321
...
e49fa9e746
| Author | SHA1 | Date |
|---|---|---|
|
|
e49fa9e746 | |
|
|
2877320987 | |
|
|
6aa98de721 | |
|
|
d5ebd2898a | |
|
|
87f52da0eb | |
|
|
71958eadce | |
|
|
a39a76f7a5 | |
|
|
75d6072309 | |
|
|
f6caa7df6b | |
|
|
7cf7a78326 | |
|
|
1b06404d64 | |
|
|
da3736e131 | |
|
|
a62f53fd96 | |
|
|
b55c8dc602 | |
|
|
f679f0c0f4 | |
|
|
d4ec417ebf |
|
|
@ -37,14 +37,19 @@ class ChatService:
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Ищем существующий чат между пользователями
|
# Ищем существующий чат между пользователями
|
||||||
# Используем select_for_update для блокировки найденных записей
|
# select_for_update() несовместим с distinct() в PostgreSQL,
|
||||||
existing_chat = Chat.objects.select_for_update().filter(
|
# поэтому сначала ищем без блокировки, затем лочим найденный объект
|
||||||
|
existing_chat = Chat.objects.filter(
|
||||||
chat_type='direct',
|
chat_type='direct',
|
||||||
participants__user=users[0]
|
participants__user=users[0]
|
||||||
).filter(
|
).filter(
|
||||||
participants__user=users[1]
|
participants__user=users[1]
|
||||||
).distinct().first()
|
).distinct().first()
|
||||||
|
|
||||||
|
if existing_chat:
|
||||||
|
# Лочим конкретную запись для безопасного возврата
|
||||||
|
existing_chat = Chat.objects.select_for_update().get(pk=existing_chat.pk)
|
||||||
|
|
||||||
if existing_chat:
|
if existing_chat:
|
||||||
return existing_chat, False
|
return existing_chat, False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
start_time = attrs.get('start_time')
|
||||||
duration = attrs.get('duration', 60)
|
duration = attrs.get('duration', 60)
|
||||||
|
|
||||||
# Проверка: допускаем создание занятий до 30 минут в прошлом
|
|
||||||
now = timezone.now()
|
|
||||||
tolerance = timedelta(minutes=30)
|
|
||||||
if start_time and start_time < now - tolerance:
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Проверка конфликтов (только при создании или изменении времени)
|
# Проверка конфликтов (только при создании или изменении времени)
|
||||||
if self.instance is None or 'start_time' in attrs:
|
if self.instance is None or 'start_time' in attrs:
|
||||||
mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None
|
mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None
|
||||||
|
|
@ -212,6 +204,10 @@ class LessonCreateSerializer(serializers.ModelSerializer):
|
||||||
'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price',
|
'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price',
|
||||||
'is_recurring'
|
'is_recurring'
|
||||||
]
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'client': {'required': False, 'allow_null': True},
|
||||||
|
'group': {'required': False, 'allow_null': True},
|
||||||
|
}
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
"""
|
"""
|
||||||
|
|
@ -307,6 +303,12 @@ class LessonCreateSerializer(serializers.ModelSerializer):
|
||||||
duration = attrs.get('duration', 60)
|
duration = attrs.get('duration', 60)
|
||||||
mentor = attrs.get('mentor')
|
mentor = attrs.get('mentor')
|
||||||
client = attrs.get('client')
|
client = attrs.get('client')
|
||||||
|
group = attrs.get('group')
|
||||||
|
|
||||||
|
if not client and not group:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'client': 'Необходимо указать ученика или группу.'
|
||||||
|
})
|
||||||
|
|
||||||
# Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name
|
# Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name
|
||||||
# subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject'
|
# subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject'
|
||||||
|
|
@ -380,16 +382,15 @@ class LessonCreateSerializer(serializers.ModelSerializer):
|
||||||
attrs['mentor_subject'] = mentor_subject
|
attrs['mentor_subject'] = mentor_subject
|
||||||
attrs['subject_name'] = mentor_subject.name
|
attrs['subject_name'] = mentor_subject.name
|
||||||
|
|
||||||
# Проверка: допускаем создание занятий до 30 минут в прошлом
|
# Нормализуем start_time к UTC
|
||||||
if start_time:
|
if start_time:
|
||||||
if not django_timezone.is_aware(start_time):
|
if not django_timezone.is_aware(start_time):
|
||||||
start_time = pytz.UTC.localize(start_time)
|
start_time = pytz.UTC.localize(start_time)
|
||||||
elif start_time.tzinfo != pytz.UTC:
|
elif start_time.tzinfo != pytz.UTC:
|
||||||
start_time = start_time.astimezone(pytz.UTC)
|
start_time = start_time.astimezone(pytz.UTC)
|
||||||
|
|
||||||
now = django_timezone.now()
|
# Проверяем что занятие не начинается более 30 минут назад
|
||||||
tolerance = timedelta(minutes=30)
|
if start_time < django_timezone.now() - timedelta(minutes=30):
|
||||||
if start_time < now - tolerance:
|
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
||||||
})
|
})
|
||||||
|
|
@ -648,6 +649,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||||||
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
||||||
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
||||||
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||||||
|
group_name = serializers.CharField(source='group.name', read_only=True, default=None)
|
||||||
subject = serializers.SerializerMethodField()
|
subject = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_subject(self, obj):
|
def get_subject(self, obj):
|
||||||
|
|
@ -671,7 +673,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Lesson
|
model = Lesson
|
||||||
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name']
|
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'group', 'group_name', 'subject', 'subject_name']
|
||||||
|
|
||||||
|
|
||||||
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
|
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,13 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
|
||||||
'fields': ('name', 'slug', 'description')
|
'fields': ('name', 'slug', 'description')
|
||||||
}),
|
}),
|
||||||
('Стоимость', {
|
('Стоимость', {
|
||||||
'fields': ('price', 'price_per_student', 'currency', 'billing_period', 'subscription_type', 'trial_days'),
|
'fields': ('price', 'price_per_student', 'currency', 'duration_days', 'subscription_type', 'trial_days'),
|
||||||
'description': 'Для типа "За ученика" укажите price_per_student. Для ежемесячной подписки - price. '
|
'description': 'Укажите "Период оплаты (дней)" — именно столько дней будет действовать подписка (например: 30, 60, 90, 180, 365).'
|
||||||
'Прогрессирующие скидки настраиваются ниже в разделе "Прогрессирующие скидки". '
|
}),
|
||||||
'Доступные периоды оплаты определяются через скидки за длительность (см. раздел ниже).'
|
('Устаревшие настройки', {
|
||||||
|
'fields': ('billing_period',),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
'description': 'Устаревшее поле. Используйте "Период оплаты (дней)" выше.'
|
||||||
}),
|
}),
|
||||||
('Целевая аудитория', {
|
('Целевая аудитория', {
|
||||||
'fields': ('target_role',),
|
'fields': ('target_role',),
|
||||||
|
|
@ -153,17 +156,11 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
|
||||||
price_display.short_description = 'Цена'
|
price_display.short_description = 'Цена'
|
||||||
|
|
||||||
def billing_period_display(self, obj):
|
def billing_period_display(self, obj):
|
||||||
"""Отображение периода."""
|
"""Отображение периода в днях."""
|
||||||
colors = {
|
days = obj.get_duration_days()
|
||||||
'monthly': '#17a2b8',
|
|
||||||
'quarterly': '#28a745',
|
|
||||||
'yearly': '#ffc107',
|
|
||||||
'lifetime': '#6610f2'
|
|
||||||
}
|
|
||||||
return format_html(
|
return format_html(
|
||||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
'<span style="background-color: #17a2b8; color: white; padding: 3px 10px; border-radius: 3px;">{} дн.</span>',
|
||||||
colors.get(obj.billing_period, '#000'),
|
days
|
||||||
obj.get_billing_period_display()
|
|
||||||
)
|
)
|
||||||
billing_period_display.short_description = 'Период'
|
billing_period_display.short_description = 'Период'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
max_length=20,
|
||||||
choices=BILLING_PERIOD_CHOICES,
|
choices=BILLING_PERIOD_CHOICES,
|
||||||
default='monthly',
|
default='monthly',
|
||||||
verbose_name='Период оплаты'
|
verbose_name='Период оплаты (устарело)',
|
||||||
|
help_text='Устаревшее поле. Используйте "Период оплаты (дней)" ниже.'
|
||||||
)
|
)
|
||||||
|
|
||||||
subscription_type = models.CharField(
|
subscription_type = models.CharField(
|
||||||
|
|
@ -254,6 +255,14 @@ class SubscriptionPlan(models.Model):
|
||||||
help_text='Используется для типа "За ученика"'
|
help_text='Используется для типа "За ученика"'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
duration_days = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
verbose_name='Период оплаты (дней)',
|
||||||
|
help_text='Количество дней действия подписки, например: 30, 60, 90, 180, 365.'
|
||||||
|
)
|
||||||
|
|
||||||
trial_days = models.IntegerField(
|
trial_days = models.IntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
validators=[MinValueValidator(0)],
|
validators=[MinValueValidator(0)],
|
||||||
|
|
@ -426,16 +435,12 @@ class SubscriptionPlan(models.Model):
|
||||||
def get_duration_days(self, custom_days=None):
|
def get_duration_days(self, custom_days=None):
|
||||||
"""
|
"""
|
||||||
Получить длительность подписки в днях.
|
Получить длительность подписки в днях.
|
||||||
|
Приоритет: custom_days → duration_days (поле модели) → billing_period.
|
||||||
Args:
|
|
||||||
custom_days: кастомная длительность в днях (30, 90, 180, 365)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: количество дней
|
|
||||||
"""
|
"""
|
||||||
if custom_days:
|
if custom_days:
|
||||||
return custom_days
|
return custom_days
|
||||||
|
if self.duration_days:
|
||||||
|
return self.duration_days
|
||||||
if self.billing_period == 'monthly':
|
if self.billing_period == 'monthly':
|
||||||
return 30
|
return 30
|
||||||
elif self.billing_period == 'quarterly':
|
elif self.billing_period == 'quarterly':
|
||||||
|
|
@ -443,7 +448,7 @@ class SubscriptionPlan(models.Model):
|
||||||
elif self.billing_period == 'yearly':
|
elif self.billing_period == 'yearly':
|
||||||
return 365
|
return 365
|
||||||
elif self.billing_period == 'lifetime':
|
elif self.billing_period == 'lifetime':
|
||||||
return 36500 # 100 лет
|
return 36500
|
||||||
return 30
|
return 30
|
||||||
|
|
||||||
def get_available_durations(self):
|
def get_available_durations(self):
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
plan_id = serializers.IntegerField()
|
plan_id = serializers.IntegerField()
|
||||||
student_count = serializers.IntegerField(default=0, min_value=0)
|
student_count = serializers.IntegerField(default=0, min_value=0)
|
||||||
duration_days = serializers.IntegerField(default=30, min_value=1)
|
duration_days = serializers.IntegerField(required=False, allow_null=True, min_value=1)
|
||||||
promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||||
start_date = serializers.DateTimeField(required=False, allow_null=True)
|
start_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
@ -219,26 +219,9 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_duration_days(self, value):
|
def validate_duration_days(self, value):
|
||||||
"""Валидация длительности."""
|
"""Валидация длительности — любое положительное число дней."""
|
||||||
allowed_durations = [30, 90, 180, 365]
|
if value is not None and value < 1:
|
||||||
if value not in allowed_durations:
|
raise serializers.ValidationError('Длительность должна быть не менее 1 дня')
|
||||||
raise serializers.ValidationError(
|
|
||||||
f'Длительность должна быть одним из значений: {", ".join(map(str, allowed_durations))}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверяем доступность периода для плана
|
|
||||||
plan_id = self.initial_data.get('plan_id')
|
|
||||||
if plan_id:
|
|
||||||
try:
|
|
||||||
plan = SubscriptionPlan.objects.get(id=plan_id)
|
|
||||||
if not plan.is_duration_available(value):
|
|
||||||
available = plan.get_available_durations()
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
f'Период {value} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}'
|
|
||||||
)
|
|
||||||
except SubscriptionPlan.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_student_count(self, value):
|
def validate_student_count(self, value):
|
||||||
|
|
@ -257,6 +240,10 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
|
||||||
except SubscriptionPlan.DoesNotExist:
|
except SubscriptionPlan.DoesNotExist:
|
||||||
raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'})
|
raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'})
|
||||||
|
|
||||||
|
# Если duration_days не передан — берём из плана
|
||||||
|
if not attrs.get('duration_days'):
|
||||||
|
attrs['duration_days'] = plan.get_duration_days()
|
||||||
|
|
||||||
# Проверяем доступность тарифа (акция)
|
# Проверяем доступность тарифа (акция)
|
||||||
user = self.context.get('request').user if self.context.get('request') else None
|
user = self.context.get('request').user if self.context.get('request') else None
|
||||||
can_use, error_message = plan.can_be_used(user)
|
can_use, error_message = plan.can_be_used(user)
|
||||||
|
|
|
||||||
|
|
@ -326,15 +326,11 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
||||||
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
|
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
duration_days = int(request.data.get('duration_days', 30))
|
requested_days = request.data.get('duration_days')
|
||||||
|
duration_days = int(requested_days) if requested_days else plan.get_duration_days()
|
||||||
student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0
|
student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0
|
||||||
if st == 'per_student' and student_count <= 0:
|
if st == 'per_student' and student_count <= 0:
|
||||||
student_count = 1
|
student_count = 1
|
||||||
available = plan.get_available_durations() if hasattr(plan, 'get_available_durations') else [30]
|
|
||||||
if not available:
|
|
||||||
available = [30]
|
|
||||||
if duration_days not in available:
|
|
||||||
duration_days = available[0]
|
|
||||||
try:
|
try:
|
||||||
subscription = SubscriptionService.create_subscription(
|
subscription = SubscriptionService.create_subscription(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
|
|
@ -788,9 +784,8 @@ class PaymentViewSet(viewsets.ModelViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# По умолчанию используем первый доступный период или 30 дней
|
# По умолчанию используем duration_days из тарифного плана
|
||||||
available_durations = plan.get_available_durations()
|
duration_days = plan.get_duration_days()
|
||||||
duration_days = available_durations[0] if available_durations else 30
|
|
||||||
|
|
||||||
# Определяем количество учеников для тарифов "за ученика"
|
# Определяем количество учеников для тарифов "за ученика"
|
||||||
if plan.subscription_type == 'per_student':
|
if plan.subscription_type == 'per_student':
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
|
||||||
# Занятия - оптимизация: используем aggregate для всех подсчетов
|
# Занятия - оптимизация: используем aggregate для всех подсчетов
|
||||||
from django.db.models import Count, Sum, Q
|
from django.db.models import Count, Sum, Q
|
||||||
lessons = Lesson.objects.filter(mentor=user.id).select_related(
|
lessons = Lesson.objects.filter(mentor=user.id).select_related(
|
||||||
'mentor', 'client', 'client__user', 'subject', 'mentor_subject'
|
'mentor', 'client', 'client__user', 'subject', 'mentor_subject', 'group'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Один запрос для всех подсчетов занятий
|
# Один запрос для всех подсчетов занятий
|
||||||
|
|
@ -89,9 +89,9 @@ class MentorDashboardViewSet(viewsets.ViewSet):
|
||||||
lessons_this_month = lessons_stats['this_month']
|
lessons_this_month = lessons_stats['this_month']
|
||||||
completed_lessons = lessons_stats['completed']
|
completed_lessons = lessons_stats['completed']
|
||||||
|
|
||||||
# Ближайшие занятия
|
# Ближайшие занятия (включая начавшиеся в последние 90 мин, чтобы отображать кнопку «Подключиться»)
|
||||||
upcoming_lessons = lessons.filter(
|
upcoming_lessons = lessons.filter(
|
||||||
start_time__gte=now,
|
start_time__gte=now - timedelta(minutes=90),
|
||||||
status__in=['scheduled', 'in_progress']
|
status__in=['scheduled', 'in_progress']
|
||||||
).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5]
|
).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5]
|
||||||
|
|
||||||
|
|
@ -163,6 +163,12 @@ class MentorDashboardViewSet(viewsets.ViewSet):
|
||||||
'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None,
|
'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None,
|
||||||
'first_name': lesson.client.user.first_name if lesson.client.user else '',
|
'first_name': lesson.client.user.first_name if lesson.client.user else '',
|
||||||
'last_name': lesson.client.user.last_name if lesson.client.user else ''
|
'last_name': lesson.client.user.last_name if lesson.client.user else ''
|
||||||
|
} if lesson.client_id else {
|
||||||
|
'id': None,
|
||||||
|
'name': lesson.group.name if lesson.group_id else 'Группа',
|
||||||
|
'avatar': None,
|
||||||
|
'first_name': '',
|
||||||
|
'last_name': ''
|
||||||
},
|
},
|
||||||
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
|
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
|
||||||
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
||||||
|
|
@ -589,7 +595,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
|
||||||
'target_name': (
|
'target_name': (
|
||||||
item['group__name']
|
item['group__name']
|
||||||
if item['group__name']
|
if item['group__name']
|
||||||
else f"{item['client__user__first_name']} {item['client__user__last_name']}".strip() or 'Ученик'
|
else f"{item['client__user__last_name']} {item['client__user__first_name']}".strip() or 'Ученик'
|
||||||
),
|
),
|
||||||
'lessons_count': item['lessons_count'],
|
'lessons_count': item['lessons_count'],
|
||||||
'total_income': float(item['total_income']),
|
'total_income': float(item['total_income']),
|
||||||
|
|
@ -649,9 +655,9 @@ class ClientDashboardViewSet(viewsets.ViewSet):
|
||||||
completed_lessons = lessons_stats['completed']
|
completed_lessons = lessons_stats['completed']
|
||||||
lessons_this_week = lessons_stats['this_week']
|
lessons_this_week = lessons_stats['this_week']
|
||||||
|
|
||||||
# Ближайшие занятия с оптимизацией
|
# Ближайшие занятия с оптимизацией (включая начавшиеся в последние 90 мин)
|
||||||
upcoming_lessons = lessons.filter(
|
upcoming_lessons = lessons.filter(
|
||||||
start_time__gte=now,
|
start_time__gte=now - timedelta(minutes=90),
|
||||||
status__in=['scheduled', 'in_progress']
|
status__in=['scheduled', 'in_progress']
|
||||||
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
|
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
|
||||||
|
|
||||||
|
|
@ -707,8 +713,11 @@ class ClientDashboardViewSet(viewsets.ViewSet):
|
||||||
'title': lesson.title,
|
'title': lesson.title,
|
||||||
'mentor': {
|
'mentor': {
|
||||||
'id': lesson.mentor.id,
|
'id': lesson.mentor.id,
|
||||||
'name': lesson.mentor.get_full_name()
|
'first_name': lesson.mentor.first_name,
|
||||||
},
|
'last_name': lesson.mentor.last_name,
|
||||||
|
'name': lesson.mentor.get_full_name(),
|
||||||
|
'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
|
||||||
|
} if lesson.mentor_id else None,
|
||||||
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
|
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
|
||||||
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
||||||
}
|
}
|
||||||
|
|
@ -716,8 +725,6 @@ class ClientDashboardViewSet(viewsets.ViewSet):
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Сохраняем в кеш на 2 минуты (120 секунд)
|
|
||||||
# Кеш на 30 секунд для актуальности уведомлений
|
|
||||||
cache.set(cache_key, response_data, 30)
|
cache.set(cache_key, response_data, 30)
|
||||||
|
|
||||||
return Response(response_data)
|
return Response(response_data)
|
||||||
|
|
@ -1181,9 +1188,9 @@ class ParentDashboardViewSet(viewsets.ViewSet):
|
||||||
completed_lessons = lessons_stats['completed']
|
completed_lessons = lessons_stats['completed']
|
||||||
lessons_this_week = lessons_stats['this_week']
|
lessons_this_week = lessons_stats['this_week']
|
||||||
|
|
||||||
# Ближайшие занятия
|
# Ближайшие занятия (включая начавшиеся в последние 90 мин)
|
||||||
upcoming_lessons = lessons.filter(
|
upcoming_lessons = lessons.filter(
|
||||||
start_time__gte=now,
|
start_time__gte=now - timedelta(minutes=90),
|
||||||
status__in=['scheduled', 'in_progress']
|
status__in=['scheduled', 'in_progress']
|
||||||
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
|
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
|
||||||
|
|
||||||
|
|
@ -1239,8 +1246,11 @@ class ParentDashboardViewSet(viewsets.ViewSet):
|
||||||
'title': lesson.title,
|
'title': lesson.title,
|
||||||
'mentor': {
|
'mentor': {
|
||||||
'id': lesson.mentor.id,
|
'id': lesson.mentor.id,
|
||||||
'name': lesson.mentor.get_full_name()
|
'first_name': lesson.mentor.first_name,
|
||||||
},
|
'last_name': lesson.mentor.last_name,
|
||||||
|
'name': lesson.mentor.get_full_name(),
|
||||||
|
'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
|
||||||
|
} if lesson.mentor_id else None,
|
||||||
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
|
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
|
||||||
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
|
||||||
}
|
}
|
||||||
|
|
@ -1248,7 +1258,6 @@ class ParentDashboardViewSet(viewsets.ViewSet):
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Сохраняем в кеш на 2 минуты (120 секунд)
|
|
||||||
cache.set(cache_key, response_data, 30)
|
cache.set(cache_key, response_data, 30)
|
||||||
|
|
||||||
return Response(response_data)
|
return Response(response_data)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from apps.board.models import Board
|
||||||
|
|
||||||
def _apply_connection(conn):
|
def _apply_connection(conn):
|
||||||
"""После принятия связи: добавить ментора к студенту, создать доску."""
|
"""После принятия связи: добавить ментора к студенту, создать доску."""
|
||||||
|
from django.core.cache import cache
|
||||||
student_user = conn.student
|
student_user = conn.student
|
||||||
mentor = conn.mentor
|
mentor = conn.mentor
|
||||||
try:
|
try:
|
||||||
|
|
@ -38,6 +39,10 @@ def _apply_connection(conn):
|
||||||
if conn.status != MentorStudentConnection.STATUS_ACCEPTED:
|
if conn.status != MentorStudentConnection.STATUS_ACCEPTED:
|
||||||
conn.status = MentorStudentConnection.STATUS_ACCEPTED
|
conn.status = MentorStudentConnection.STATUS_ACCEPTED
|
||||||
conn.save(update_fields=['status', 'updated_at'])
|
conn.save(update_fields=['status', 'updated_at'])
|
||||||
|
# Инвалидируем кэш списка студентов ментора
|
||||||
|
for page in range(1, 6):
|
||||||
|
for page_size in [10, 20, 50]:
|
||||||
|
cache.delete(f'manage_clients_{mentor.id}_{page}_{page_size}')
|
||||||
|
|
||||||
|
|
||||||
class MentorshipRequestViewSet(viewsets.ViewSet):
|
class MentorshipRequestViewSet(viewsets.ViewSet):
|
||||||
|
|
|
||||||
|
|
@ -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,68 @@
|
||||||
|
# Generated by Django 4.2.7 on 2026-03-12 14:06
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("users", "0012_add_group_to_board"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="InvitationLink",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"token",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=64, unique=True, verbose_name="Токен"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_banned",
|
||||||
|
models.BooleanField(default=False, verbose_name="Забанена"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mentor",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="invitation_links",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Ментор",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"used_by",
|
||||||
|
models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="registered_via_link",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Использована пользователем",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Ссылка-приглашение",
|
||||||
|
"verbose_name_plural": "Ссылки-приглашения",
|
||||||
|
"db_table": "invitation_links",
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя
|
||||||
STATUS_ACCEPTED = 'accepted'
|
STATUS_ACCEPTED = 'accepted'
|
||||||
STATUS_REJECTED = 'rejected'
|
STATUS_REJECTED = 'rejected'
|
||||||
|
STATUS_REMOVED = 'removed'
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
(STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'),
|
(STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'),
|
||||||
|
|
@ -574,6 +575,7 @@ class MentorStudentConnection(models.Model):
|
||||||
(STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'),
|
(STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'),
|
||||||
(STATUS_ACCEPTED, 'Принято'),
|
(STATUS_ACCEPTED, 'Принято'),
|
||||||
(STATUS_REJECTED, 'Отклонено'),
|
(STATUS_REJECTED, 'Отклонено'),
|
||||||
|
(STATUS_REMOVED, 'Удалено'),
|
||||||
]
|
]
|
||||||
INITIATOR_STUDENT = 'student'
|
INITIATOR_STUDENT = 'student'
|
||||||
INITIATOR_MENTOR = 'mentor'
|
INITIATOR_MENTOR = 'mentor'
|
||||||
|
|
@ -692,3 +694,49 @@ class Group(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} (ментор: {self.mentor.get_full_name()})"
|
return f"{self.name} (ментор: {self.mentor.get_full_name()})"
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationLink(models.Model):
|
||||||
|
"""
|
||||||
|
Ссылка-приглашение от ментора для регистрации ученика.
|
||||||
|
- Каждая ссылка действует 12 часов с момента создания
|
||||||
|
- Одна ссылка — один ученик (used_by)
|
||||||
|
- Несколько ссылок могут быть активны одновременно
|
||||||
|
- По истечении 12 часов ссылка помечается is_banned=True и не может быть использована
|
||||||
|
"""
|
||||||
|
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='Токен')
|
||||||
|
mentor = models.ForeignKey(
|
||||||
|
'User',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='invitation_links',
|
||||||
|
verbose_name='Ментор',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создана')
|
||||||
|
used_by = models.OneToOneField(
|
||||||
|
'User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='registered_via_link',
|
||||||
|
verbose_name='Использована пользователем',
|
||||||
|
)
|
||||||
|
is_banned = models.BooleanField(default=False, verbose_name='Забанена')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'invitation_links'
|
||||||
|
verbose_name = 'Ссылка-приглашение'
|
||||||
|
verbose_name_plural = 'Ссылки-приглашения'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"InvitationLink({self.mentor.email}, {self.token[:8]}...)"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from datetime import timedelta
|
||||||
|
return tz.now() > self.created_at + timedelta(hours=12)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.is_banned and not self.is_expired and self.used_by_id is None
|
||||||
|
|
|
||||||
|
|
@ -608,7 +608,7 @@ class ProfileViewSet(viewsets.ViewSet):
|
||||||
continue
|
continue
|
||||||
return Response(timezones_data)
|
return Response(timezones_data)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny])
|
@action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny], authentication_classes=[])
|
||||||
def search_cities_from_csv(self, request):
|
def search_cities_from_csv(self, request):
|
||||||
"""
|
"""
|
||||||
Поиск городов из city.csv по запросу.
|
Поиск городов из city.csv по запросу.
|
||||||
|
|
@ -921,16 +921,8 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
# Кеширование: кеш на 5 минут для каждого пользователя и страницы
|
|
||||||
# Увеличено с 2 до 5 минут для ускорения повторных загрузок страницы "Студенты"
|
|
||||||
page = int(request.query_params.get('page', 1))
|
page = int(request.query_params.get('page', 1))
|
||||||
page_size = int(request.query_params.get('page_size', 20))
|
page_size = int(request.query_params.get('page_size', 20))
|
||||||
cache_key = f'manage_clients_{user.id}_{page}_{page_size}'
|
|
||||||
|
|
||||||
cached_data = cache.get(cache_key)
|
|
||||||
|
|
||||||
if cached_data is not None:
|
|
||||||
return Response(cached_data)
|
|
||||||
|
|
||||||
# ВАЖНО: оптимизация страницы "Студенты"
|
# ВАЖНО: оптимизация страницы "Студенты"
|
||||||
# Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1).
|
# Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1).
|
||||||
|
|
@ -1026,9 +1018,6 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
||||||
for inv in pending
|
for inv in pending
|
||||||
]
|
]
|
||||||
|
|
||||||
# Сохраняем в кеш на 5 минут (300 секунд) для ускорения повторных загрузок
|
|
||||||
cache.set(cache_key, response_data.data, 300)
|
|
||||||
|
|
||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='check-user')
|
@action(detail=False, methods=['get'], url_path='check-user')
|
||||||
|
|
@ -1149,7 +1138,7 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
||||||
defaults={
|
defaults={
|
||||||
'status': MentorStudentConnection.STATUS_PENDING_STUDENT,
|
'status': MentorStudentConnection.STATUS_PENDING_STUDENT,
|
||||||
'initiator': MentorStudentConnection.INITIATOR_MENTOR,
|
'initiator': MentorStudentConnection.INITIATOR_MENTOR,
|
||||||
'confirm_token': secrets.token_urlsafe(32) if is_new_user or True else None,
|
'confirm_token': secrets.token_urlsafe(32),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not created:
|
if not created:
|
||||||
|
|
@ -1161,6 +1150,13 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
||||||
'message': 'Приглашение уже отправлено, ожидайте подтверждения',
|
'message': 'Приглашение уже отправлено, ожидайте подтверждения',
|
||||||
'invitation_id': conn.id,
|
'invitation_id': conn.id,
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
# Связь была удалена — повторно отправляем приглашение
|
||||||
|
if conn.status == MentorStudentConnection.STATUS_REMOVED:
|
||||||
|
conn.status = MentorStudentConnection.STATUS_PENDING_STUDENT
|
||||||
|
conn.initiator = MentorStudentConnection.INITIATOR_MENTOR
|
||||||
|
conn.confirm_token = secrets.token_urlsafe(32)
|
||||||
|
conn.student_confirmed_at = None
|
||||||
|
conn.save(update_fields=['status', 'initiator', 'confirm_token', 'student_confirmed_at', 'updated_at'])
|
||||||
|
|
||||||
if not conn.confirm_token:
|
if not conn.confirm_token:
|
||||||
conn.confirm_token = secrets.token_urlsafe(32)
|
conn.confirm_token = secrets.token_urlsafe(32)
|
||||||
|
|
@ -1231,17 +1227,38 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
||||||
future_lessons_count = future_lessons.count()
|
future_lessons_count = future_lessons.count()
|
||||||
future_lessons.delete()
|
future_lessons.delete()
|
||||||
|
|
||||||
# Удаляем ментора (это автоматически запретит доступ ко всем материалам)
|
# Убираем доступ к доскам (не удаляем доски, только убираем участника)
|
||||||
|
from apps.board.models import Board
|
||||||
|
boards = Board.objects.filter(mentor=user, student=client.user)
|
||||||
|
for board in boards:
|
||||||
|
board.participants.remove(client.user)
|
||||||
|
|
||||||
|
# Закрываем активную связь
|
||||||
|
MentorStudentConnection.objects.filter(
|
||||||
|
mentor=user,
|
||||||
|
student=client.user,
|
||||||
|
status=MentorStudentConnection.STATUS_ACCEPTED
|
||||||
|
).update(status='removed')
|
||||||
|
|
||||||
|
# Удаляем ментора (убирает доступ к материалам)
|
||||||
client.mentors.remove(user)
|
client.mentors.remove(user)
|
||||||
|
|
||||||
# Инвалидируем кеш списка клиентов для этого ментора
|
# Уведомление ученику
|
||||||
# Удаляем все варианты кеша для этого пользователя (разные страницы и размеры)
|
NotificationService.create_notification_with_telegram(
|
||||||
|
recipient=client.user,
|
||||||
|
notification_type='system',
|
||||||
|
title='Ментор завершил сотрудничество',
|
||||||
|
message=f'Ментор {user.get_full_name()} удалил вас из своего списка учеников. '
|
||||||
|
f'Будущие занятия отменены. Доступ к доскам приостановлен (при восстановлении связи — вернётся).',
|
||||||
|
data={'mentor_id': user.id}
|
||||||
|
)
|
||||||
|
|
||||||
for page in range(1, 10):
|
for page in range(1, 10):
|
||||||
for size in [10, 20, 50, 100, 1000]:
|
for size in [10, 20, 50, 100, 1000]:
|
||||||
cache.delete(f'manage_clients_{user.id}_{page}_{size}')
|
cache.delete(f'manage_clients_{user.id}_{page}_{size}')
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'message': 'Клиент успешно удален',
|
'message': 'Клиент успешно удалён',
|
||||||
'future_lessons_deleted': future_lessons_count
|
'future_lessons_deleted': future_lessons_count
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1253,23 +1270,114 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
||||||
@action(detail=False, methods=['post'], url_path='generate-invitation-link')
|
@action(detail=False, methods=['post'], url_path='generate-invitation-link')
|
||||||
def generate_invitation_link(self, request):
|
def generate_invitation_link(self, request):
|
||||||
"""
|
"""
|
||||||
Сгенерировать или обновить токен ссылки-приглашения.
|
Создать новую ссылку-приглашение (12 часов, 1 использование).
|
||||||
POST /api/users/manage/clients/generate-invitation-link/
|
Старые ссылки остаются действительными.
|
||||||
|
POST /api/manage/clients/generate-invitation-link/
|
||||||
"""
|
"""
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import InvitationLink
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.role != 'mentor':
|
if user.role != 'mentor':
|
||||||
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
|
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
user.invitation_link_token = secrets.token_urlsafe(32)
|
# Истекаем просроченные ссылки этого ментора
|
||||||
user.save(update_fields=['invitation_link_token'])
|
expire_before = tz.now() - timedelta(hours=12)
|
||||||
|
InvitationLink.objects.filter(
|
||||||
|
mentor=user,
|
||||||
|
is_banned=False,
|
||||||
|
used_by__isnull=True,
|
||||||
|
created_at__lt=expire_before,
|
||||||
|
).update(is_banned=True)
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
inv = InvitationLink.objects.create(mentor=user, token=token)
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
|
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
|
||||||
link = f"{frontend_url}/invite/{user.invitation_link_token}"
|
link = f"{frontend_url}/invite/{token}"
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'invitation_link_token': user.invitation_link_token,
|
'invitation_link_token': token,
|
||||||
'invitation_link': link
|
'invitation_link': link,
|
||||||
|
'expires_at': (inv.created_at + timedelta(hours=12)).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class StudentMentorViewSet(viewsets.ViewSet):
|
||||||
|
"""
|
||||||
|
Действия студента в отношении своих менторов.
|
||||||
|
remove_mentor: студент удаляет ментора из своего списка.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=True, methods=['delete'], url_path='remove')
|
||||||
|
def remove_mentor(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Студент удаляет ментора.
|
||||||
|
DELETE /api/student/mentors/{mentor_id}/remove/
|
||||||
|
"""
|
||||||
|
from django.utils import timezone
|
||||||
|
from apps.schedule.models import Lesson
|
||||||
|
from apps.board.models import Board
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
if user.role not in ('client', 'parent'):
|
||||||
|
return Response({'error': 'Только для учеников'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mentor = User.objects.get(id=pk, role='mentor')
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({'error': 'Ментор не найден'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = user.client_profile
|
||||||
|
except Client.DoesNotExist:
|
||||||
|
return Response({'error': 'Профиль ученика не найден'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if mentor not in client.mentors.all():
|
||||||
|
return Response({'error': 'Ментор не связан с вами'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Удаляем будущие занятия
|
||||||
|
now = timezone.now()
|
||||||
|
future_lessons = Lesson.objects.filter(
|
||||||
|
mentor=mentor,
|
||||||
|
client=client,
|
||||||
|
start_time__gt=now,
|
||||||
|
status='scheduled'
|
||||||
|
)
|
||||||
|
future_lessons_count = future_lessons.count()
|
||||||
|
future_lessons.delete()
|
||||||
|
|
||||||
|
# Убираем доступ к доскам
|
||||||
|
boards = Board.objects.filter(mentor=mentor, student=user)
|
||||||
|
for board in boards:
|
||||||
|
board.participants.remove(user)
|
||||||
|
|
||||||
|
# Закрываем активную связь
|
||||||
|
MentorStudentConnection.objects.filter(
|
||||||
|
mentor=mentor,
|
||||||
|
student=user,
|
||||||
|
status=MentorStudentConnection.STATUS_ACCEPTED
|
||||||
|
).update(status='removed')
|
||||||
|
|
||||||
|
# Удаляем ментора из профиля
|
||||||
|
client.mentors.remove(mentor)
|
||||||
|
|
||||||
|
# Уведомление ментору
|
||||||
|
NotificationService.create_notification_with_telegram(
|
||||||
|
recipient=mentor,
|
||||||
|
notification_type='system',
|
||||||
|
title='Ученик завершил сотрудничество',
|
||||||
|
message=f'Ученик {user.get_full_name()} удалил вас из своего списка менторов. '
|
||||||
|
f'Будущие занятия отменены. Доступ к доскам приостановлен.',
|
||||||
|
data={'student_id': user.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Ментор успешно удалён',
|
||||||
|
'future_lessons_deleted': future_lessons_count
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1453,19 +1561,36 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
Получить информацию о менторе по токену ссылки-приглашения.
|
Получить информацию о менторе по токену ссылки-приглашения.
|
||||||
GET /api/invitation/info-by-token/?token=...
|
GET /api/invitation/info-by-token/?token=...
|
||||||
"""
|
"""
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import InvitationLink
|
||||||
|
|
||||||
token = request.query_params.get('token')
|
token = request.query_params.get('token')
|
||||||
if not token:
|
if not token:
|
||||||
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mentor = User.objects.get(invitation_link_token=token, role='mentor')
|
inv = InvitationLink.objects.select_related('mentor').get(token=token)
|
||||||
|
except InvitationLink.DoesNotExist:
|
||||||
|
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if inv.is_banned:
|
||||||
|
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.is_expired:
|
||||||
|
inv.is_banned = True
|
||||||
|
inv.save(update_fields=['is_banned'])
|
||||||
|
return Response({'error': 'Ссылка истекла'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.used_by_id is not None:
|
||||||
|
return Response({'error': 'Ссылка уже использована'}, status=status.HTTP_410_GONE)
|
||||||
|
|
||||||
|
mentor = inv.mentor
|
||||||
|
expires_at = inv.created_at + timedelta(hours=12)
|
||||||
return Response({
|
return Response({
|
||||||
'mentor_name': mentor.get_full_name(),
|
'mentor_name': mentor.get_full_name(),
|
||||||
'mentor_id': mentor.id,
|
'mentor_id': mentor.id,
|
||||||
'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
|
'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
|
||||||
|
'expires_at': expires_at.isoformat(),
|
||||||
})
|
})
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny])
|
@action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny])
|
||||||
def register_by_link(self, request):
|
def register_by_link(self, request):
|
||||||
|
|
@ -1482,6 +1607,8 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
"city": "..."
|
"city": "..."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
from .models import InvitationLink
|
||||||
|
|
||||||
token = request.data.get('token')
|
token = request.data.get('token')
|
||||||
first_name = request.data.get('first_name')
|
first_name = request.data.get('first_name')
|
||||||
last_name = request.data.get('last_name')
|
last_name = request.data.get('last_name')
|
||||||
|
|
@ -1494,10 +1621,21 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mentor = User.objects.get(invitation_link_token=token, role='mentor')
|
inv = InvitationLink.objects.select_related('mentor').get(token=token)
|
||||||
except User.DoesNotExist:
|
except InvitationLink.DoesNotExist:
|
||||||
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if inv.is_banned:
|
||||||
|
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.is_expired:
|
||||||
|
inv.is_banned = True
|
||||||
|
inv.save(update_fields=['is_banned'])
|
||||||
|
return Response({'error': 'Срок действия ссылки истёк'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.used_by_id is not None:
|
||||||
|
return Response({'error': 'Ссылка уже была использована'}, status=status.HTTP_410_GONE)
|
||||||
|
|
||||||
|
mentor = inv.mentor
|
||||||
|
|
||||||
# Если email указан, проверяем его уникальность
|
# Если email указан, проверяем его уникальность
|
||||||
if email:
|
if email:
|
||||||
if User.objects.filter(email=email).exists():
|
if User.objects.filter(email=email).exists():
|
||||||
|
|
@ -1523,6 +1661,10 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
student_user.login_token = secrets.token_urlsafe(32)
|
student_user.login_token = secrets.token_urlsafe(32)
|
||||||
student_user.save(update_fields=['login_token'])
|
student_user.save(update_fields=['login_token'])
|
||||||
|
|
||||||
|
# Помечаем ссылку как использованную
|
||||||
|
inv.used_by = student_user
|
||||||
|
inv.save(update_fields=['used_by'])
|
||||||
|
|
||||||
# Создаем профиль клиента
|
# Создаем профиль клиента
|
||||||
client = Client.objects.create(user=student_user)
|
client = Client.objects.create(user=student_user)
|
||||||
|
|
||||||
|
|
@ -1601,34 +1743,55 @@ class ParentManagementViewSet(viewsets.ViewSet):
|
||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
# Поддержка старого формата (child_email) и нового (email + данные)
|
# Поддержка: universal_code (8 симв.) / child_email / email
|
||||||
|
universal_code = (request.data.get('universal_code') or '').strip().upper()
|
||||||
child_email = request.data.get('child_email') or request.data.get('email')
|
child_email = request.data.get('child_email') or request.data.get('email')
|
||||||
|
|
||||||
if not child_email:
|
if not universal_code and not child_email:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Необходимо указать email ребенка'},
|
{'error': 'Необходимо указать 8-значный код ребенка или его email'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Нормализуем email
|
|
||||||
child_email = child_email.lower().strip()
|
|
||||||
|
|
||||||
# Получаем или создаем профиль родителя
|
# Получаем или создаем профиль родителя
|
||||||
parent, created = Parent.objects.get_or_create(user=user)
|
parent, _ = Parent.objects.get_or_create(user=user)
|
||||||
|
|
||||||
# Ищем пользователя
|
|
||||||
created = False
|
created = False
|
||||||
|
|
||||||
|
if universal_code:
|
||||||
|
# --- Поиск по universal_code ---
|
||||||
|
allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||||
|
valid_8 = len(universal_code) == 8 and all(c in allowed for c in universal_code)
|
||||||
|
valid_6_legacy = len(universal_code) == 6 and universal_code.isdigit()
|
||||||
|
if not (valid_8 or valid_6_legacy):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Код должен содержать 8 символов (буквы и цифры)'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
child_user = User.objects.get(email=child_email)
|
child_user = User.objects.get(universal_code=universal_code)
|
||||||
# Если пользователь существует, проверяем что это клиент
|
except User.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Пользователь с таким кодом не найден'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
if child_user.role != 'client':
|
if child_user.role != 'client':
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Пользователь с таким email уже существует, но не является клиентом'},
|
{'error': 'Пользователь с этим кодом не является учеником (client)'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# --- Поиск / создание по email ---
|
||||||
|
child_email = child_email.lower().strip()
|
||||||
|
try:
|
||||||
|
child_user = User.objects.get(email=child_email)
|
||||||
|
if child_user.role != 'client':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Пользователь с таким email не является учеником (client)'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
created = True
|
created = True
|
||||||
# Создаем нового пользователя-клиента
|
|
||||||
first_name = request.data.get('first_name', '').strip()
|
first_name = request.data.get('first_name', '').strip()
|
||||||
last_name = request.data.get('last_name', '').strip()
|
last_name = request.data.get('last_name', '').strip()
|
||||||
phone = request.data.get('phone', '').strip()
|
phone = request.data.get('phone', '').strip()
|
||||||
|
|
@ -1639,9 +1802,7 @@ class ParentManagementViewSet(viewsets.ViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаем пользователя с временным паролем
|
|
||||||
temp_password = secrets.token_urlsafe(12)
|
temp_password = secrets.token_urlsafe(12)
|
||||||
|
|
||||||
child_user = User.objects.create_user(
|
child_user = User.objects.create_user(
|
||||||
email=child_email,
|
email=child_email,
|
||||||
password=temp_password,
|
password=temp_password,
|
||||||
|
|
@ -1649,15 +1810,11 @@ class ParentManagementViewSet(viewsets.ViewSet):
|
||||||
last_name=last_name,
|
last_name=last_name,
|
||||||
phone=normalize_phone(phone) if phone else '',
|
phone=normalize_phone(phone) if phone else '',
|
||||||
role='client',
|
role='client',
|
||||||
email_verified=True, # Email автоматически подтвержден при добавлении родителем
|
email_verified=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Генерируем токен для установки пароля
|
|
||||||
reset_token = secrets.token_urlsafe(32)
|
reset_token = secrets.token_urlsafe(32)
|
||||||
child_user.email_verification_token = reset_token
|
child_user.email_verification_token = reset_token
|
||||||
child_user.save()
|
child_user.save()
|
||||||
|
|
||||||
# Отправляем приветственное письмо со ссылкой на установку пароля
|
|
||||||
send_student_welcome_email_task.delay(child_user.id, reset_token)
|
send_student_welcome_email_task.delay(child_user.id, reset_token)
|
||||||
|
|
||||||
# Получаем или создаем профиль клиента
|
# Получаем или создаем профиль клиента
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,17 @@ class RegisterSerializer(serializers.ModelSerializer):
|
||||||
"""Нормализация email в нижний регистр."""
|
"""Нормализация email в нижний регистр."""
|
||||||
return value.lower().strip() if value else value
|
return value.lower().strip() if value else value
|
||||||
|
|
||||||
|
def validate_timezone(self, value):
|
||||||
|
"""Проверяем что timezone — валидный IANA идентификатор."""
|
||||||
|
if not value:
|
||||||
|
return 'Europe/Moscow'
|
||||||
|
import zoneinfo
|
||||||
|
try:
|
||||||
|
zoneinfo.ZoneInfo(value)
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
return 'Europe/Moscow'
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Проверка совпадения паролей."""
|
"""Проверка совпадения паролей."""
|
||||||
if attrs.get('password') != attrs.get('password_confirm'):
|
if attrs.get('password') != attrs.get('password_confirm'):
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""
|
"""
|
||||||
Сигналы для пользователей.
|
Сигналы для пользователей.
|
||||||
"""
|
"""
|
||||||
from django.db.models.signals import post_save, post_delete
|
from django.db.models.signals import post_save, post_delete, m2m_changed
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from .models import MentorStudentConnection
|
from .models import MentorStudentConnection, Group
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_manage_clients_cache(mentor_id):
|
def _invalidate_manage_clients_cache(mentor_id):
|
||||||
|
|
@ -28,3 +28,24 @@ def mentor_student_connection_changed(sender, instance, created, **kwargs):
|
||||||
if instance.mentor_id:
|
if instance.mentor_id:
|
||||||
_invalidate_manage_clients_cache(instance.mentor_id)
|
_invalidate_manage_clients_cache(instance.mentor_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Group)
|
||||||
|
def group_saved(sender, instance, created, **kwargs):
|
||||||
|
"""При создании/обновлении группы — синхронизировать групповой чат."""
|
||||||
|
try:
|
||||||
|
from apps.chat.services import ChatService
|
||||||
|
ChatService.get_or_create_group_chat(instance)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(m2m_changed, sender=Group.students.through)
|
||||||
|
def group_students_changed(sender, instance, action, **kwargs):
|
||||||
|
"""При изменении участников группы — синхронизировать участников чата."""
|
||||||
|
if action in ('post_add', 'post_remove', 'post_clear'):
|
||||||
|
try:
|
||||||
|
from apps.chat.services import ChatService
|
||||||
|
ChatService.get_or_create_group_chat(instance)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ def send_welcome_email_task(user_id):
|
||||||
context = {
|
context = {
|
||||||
'user_full_name': user.get_full_name() or user.email,
|
'user_full_name': user.get_full_name() or user.email,
|
||||||
'user_email': user.email,
|
'user_email': user.email,
|
||||||
|
'login_url': f"{settings.FRONTEND_URL}/auth/jwt/sign-in",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Загружаем HTML и текстовые шаблоны
|
# Загружаем HTML и текстовые шаблоны
|
||||||
|
|
@ -60,7 +61,7 @@ def send_verification_email_task(user_id, verification_token):
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
|
|
||||||
# URL для подтверждения
|
# URL для подтверждения
|
||||||
verification_url = f"{settings.FRONTEND_URL}/verify-email?token={verification_token}"
|
verification_url = f"{settings.FRONTEND_URL}/auth/jwt/verify-email?token={verification_token}"
|
||||||
|
|
||||||
subject = 'Подтвердите ваш email'
|
subject = 'Подтвердите ваш email'
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ def send_password_reset_email_task(user_id, reset_token):
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
|
|
||||||
# URL для сброса пароля
|
# URL для сброса пароля
|
||||||
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
|
reset_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
|
||||||
|
|
||||||
subject = 'Восстановление пароля'
|
subject = 'Восстановление пароля'
|
||||||
|
|
||||||
|
|
@ -144,7 +145,7 @@ def send_student_welcome_email_task(user_id, reset_token):
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
|
|
||||||
# URL для установки пароля
|
# URL для установки пароля
|
||||||
set_password_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
|
set_password_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
|
||||||
|
|
||||||
subject = 'Добро пожаловать на платформу!'
|
subject = 'Добро пожаловать на платформу!'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,61 +4,42 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>{% block title %}Uchill{% endblock %}</title>
|
<title>{% block title %}Училл{% endblock %}</title>
|
||||||
<!--[if mso]>
|
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||||
<style type="text/css">
|
|
||||||
body, table, td {font-family: Arial, sans-serif !important;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||||
<!-- Wrapper table -->
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
<tr><td align="center" style="padding:40px 16px;">
|
||||||
|
|
||||||
|
<!-- Card -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px 20px;">
|
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||||
<!-- Main content table -->
|
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
<br>
|
||||||
<!-- Header with logo -->
|
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
|
||||||
<tr>
|
|
||||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
|
||||||
<!-- Стилизованный текстовый логотип uchill -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
|
||||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Content block -->
|
<!-- Body -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 0 40px 40px 40px;">
|
<td style="background:#ffffff;padding:40px;">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
|
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
|
||||||
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
|
|
||||||
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
|
|
||||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
|
|
||||||
© {% now "Y" %} Uchill. Все права защищены.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,167 +4,111 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>Приглашение от ментора - Uchill</title>
|
<title>Приглашение от ментора — Училл</title>
|
||||||
<!--[if mso]>
|
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||||
<style type="text/css">
|
|
||||||
body, table, td {font-family: Arial, sans-serif !important;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||||
<!-- Wrapper table -->
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
<tr><td align="center" style="padding:40px 16px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px 20px;">
|
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||||
<!-- Main content table -->
|
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
|
||||||
<!-- Header with logo -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
|
||||||
<!-- Стилизованный текстовый логотип uchill -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
|
||||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Вас приглашают учиться!</h1>
|
||||||
<tr>
|
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Личное приглашение от ментора</p>
|
||||||
<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 -->
|
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Здравствуйте!
|
Здравствуйте!
|
||||||
</p>
|
</p>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Main message -->
|
<!-- Mentor highlight -->
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:linear-gradient(135deg,#F5F0FF,#EEE8FF);border:1px solid #DDD6FE;border-radius:12px;margin-bottom:24px;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:20px 24px;">
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ментор</p>
|
||||||
<strong style="color: #7444FD;">{{ mentor_name }}</strong> приглашает вас в качестве ученика на платформу Uchill.
|
<p style="margin:0;font-size:20px;font-weight:700;color:#111827;">{{ mentor_name }}</p>
|
||||||
</p>
|
<p style="margin:4px 0 0 0;font-size:14px;color:#6B7280;">приглашает вас на платформу <strong style="color:#7444FD;">Училл</strong></p>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
</table>
|
||||||
|
|
||||||
{% if set_password_url %}
|
{% if set_password_url %}
|
||||||
<!-- New user flow -->
|
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
<tr>
|
Для начала занятий установите пароль и подтвердите приглашение — это займёт меньше минуты.
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Для начала работы установите пароль и подтвердите приглашение.
|
|
||||||
</p>
|
</p>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
|
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
<a href="{{ set_password_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
|
||||||
<tr>
|
Принять приглашение
|
||||||
<td style="background-color: #7444FD; border-radius: 4px;">
|
|
||||||
<a href="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
|
||||||
Установить пароль и подтвердить
|
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Link fallback -->
|
<!-- Link fallback -->
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:16px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
|
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ set_password_url }}</p>
|
||||||
<td style="padding: 12px;">
|
</td></tr>
|
||||||
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
|
|
||||||
{{ set_password_url }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{% elif confirm_url %}
|
{% elif confirm_url %}
|
||||||
<!-- Existing user flow -->
|
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Подтвердите приглашение, чтобы начать занятия с ментором.
|
Подтвердите приглашение, чтобы начать занятия с ментором.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
|
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
<a href="{{ confirm_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
|
||||||
<tr>
|
|
||||||
<td style="background-color: #7444FD; border-radius: 4px;">
|
|
||||||
<a href="{{ confirm_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
|
||||||
Подтвердить приглашение
|
Подтвердить приглашение
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Link fallback -->
|
<!-- Link fallback -->
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:16px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
|
<p style="margin:0 0 6px 0;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px;">Или скопируйте ссылку:</p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;word-break:break-all;line-height:1.6;">{{ confirm_url }}</p>
|
||||||
<td style="padding: 12px;">
|
</td></tr>
|
||||||
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
|
|
||||||
{{ confirm_url }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
|
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
|
||||||
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
|
|
||||||
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
|
|
||||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
|
|
||||||
© 2026 Uchill. Все права защищены.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,143 +4,93 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>Восстановление пароля - Uchill</title>
|
<title>Восстановление пароля — Училл</title>
|
||||||
<!--[if mso]>
|
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||||
<style type="text/css">
|
|
||||||
body, table, td {font-family: Arial, sans-serif !important;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||||
<!-- Wrapper table -->
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
<tr><td align="center" style="padding:40px 16px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px 20px;">
|
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||||
<!-- Main content table -->
|
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
|
||||||
<!-- Header with logo -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
|
||||||
<!-- Стилизованный текстовый логотип uchill -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
|
||||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Восстановление пароля</h1>
|
||||||
<tr>
|
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Мы получили запрос на сброс пароля</p>
|
||||||
<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 -->
|
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
<tr>
|
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
|
||||||
</p>
|
</p>
|
||||||
</td>
|
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
</tr>
|
Нажмите на кнопку ниже, чтобы установить новый пароль для вашего аккаунта.
|
||||||
|
|
||||||
<!-- Main message -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Вы запросили восстановление пароля для вашего аккаунта. Нажмите на кнопку ниже, чтобы установить новый пароль.
|
|
||||||
</p>
|
</p>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
|
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
<a href="{{ reset_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
|
||||||
<tr>
|
Установить новый пароль
|
||||||
<td style="background-color: #7444FD; border-radius: 4px;">
|
|
||||||
<a href="{{ reset_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
|
||||||
Восстановить пароль
|
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Link fallback -->
|
<!-- Link fallback -->
|
||||||
<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;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:16px;">
|
||||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;">
|
<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>
|
||||||
</p>
|
</td></tr>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
|
|
||||||
{{ reset_url }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Warning box -->
|
<!-- Warning -->
|
||||||
<tr>
|
<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;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:14px 16px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FFF3E0; border-left: 4px solid #FF9800; border-radius: 4px;">
|
<p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
|
||||||
<tr>
|
<strong>Важно:</strong> ссылка действительна в течение 24 часов.
|
||||||
<td style="padding: 16px;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #E65100;">
|
|
||||||
<strong>⚠️ Важно:</strong> Ссылка действительна в течение 24 часов.
|
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Security notice -->
|
<!-- Security notice -->
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;">
|
||||||
<td style="padding-top: 24px; border-top: 1px solid #E0E0E0;">
|
<tr><td style="padding:14px 16px;">
|
||||||
<p style="margin: 0; font-size: 12px; color: #9E9E9E; line-height: 1.6;">
|
<p style="margin:0;font-size:13px;color:#6B7280;line-height:1.6;">
|
||||||
Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений.
|
Если вы не запрашивали восстановление пароля — просто проигнорируйте это письмо. Ваш пароль останется без изменений.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
|
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
|
||||||
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
|
|
||||||
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
|
|
||||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
|
|
||||||
© 2026 Uchill. Все права защищены.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,152 +4,92 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>Добро пожаловать на Uchill</title>
|
<title>Добро пожаловать на Училл</title>
|
||||||
<!--[if mso]>
|
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||||
<style type="text/css">
|
|
||||||
body, table, td {font-family: Arial, sans-serif !important;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||||
<!-- Wrapper table -->
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
<tr><td align="center" style="padding:40px 16px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px 20px;">
|
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||||
<!-- Main content table -->
|
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
|
||||||
<!-- Header with logo -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
|
||||||
<!-- Стилизованный текстовый логотип uchill -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
|
||||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Добро пожаловать!</h1>
|
||||||
<tr>
|
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Ваш аккаунт на Училл создан</p>
|
||||||
<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 -->
|
<p style="margin:0 0 24px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
<tr>
|
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
|
||||||
<td style="padding-bottom: 24px;">
|
</p>
|
||||||
<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;">
|
||||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
Ваш ментор добавил вас на платформу <strong style="color:#7444FD;">Училл</strong>. Для начала работы установите пароль для вашего аккаунта.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Main message -->
|
<!-- Email info -->
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F5F0FF;border-left:4px solid #7444FD;border-radius:8px;margin-bottom:32px;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:16px 20px;">
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
<p style="margin:0 0 4px 0;font-size:12px;font-weight:600;color:#7444FD;text-transform:uppercase;letter-spacing:0.5px;">Ваш email для входа</p>
|
||||||
Вас добавили на Uchill. Для начала работы необходимо установить пароль для вашего аккаунта.
|
<p style="margin:0;font-size:15px;font-weight:600;color:#111827;">{{ user_email }}</p>
|
||||||
</p>
|
</td></tr>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Info box -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
|
|
||||||
Ваш email для входа:
|
|
||||||
</p>
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #757575;">
|
|
||||||
{{ user_email }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
|
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
<a href="{{ set_password_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
|
||||||
<tr>
|
|
||||||
<td style="background-color: #7444FD; border-radius: 4px;">
|
|
||||||
<a href="{{ set_password_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
|
||||||
Установить пароль
|
Установить пароль
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Link fallback -->
|
<!-- Link fallback -->
|
||||||
<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;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:16px;">
|
||||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;">
|
<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>
|
||||||
</p>
|
</td></tr>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
|
|
||||||
{{ set_password_url }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Warning box -->
|
<!-- Warning -->
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#FFFBEB;border-left:4px solid #F59E0B;border-radius:8px;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:14px 16px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FFF3E0; border-left: 4px solid #FF9800; border-radius: 4px;">
|
<p style="margin:0;font-size:13px;color:#92400E;line-height:1.6;">
|
||||||
<tr>
|
<strong>Важно:</strong> ссылка действительна в течение 7 дней.
|
||||||
<td style="padding: 16px;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #E65100;">
|
|
||||||
<strong>⚠️ Важно:</strong> Ссылка действительна в течение 7 дней.
|
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
|
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
|
||||||
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
|
|
||||||
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
|
|
||||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
|
|
||||||
© 2026 Uchill. Все права защищены.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,128 +4,84 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>Подтверждение email - Uchill</title>
|
<title>Подтверждение email — Училл</title>
|
||||||
<!--[if mso]>
|
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||||
<style type="text/css">
|
|
||||||
body, table, td {font-family: Arial, sans-serif !important;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||||
<!-- Wrapper table -->
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
<tr><td align="center" style="padding:40px 16px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px 20px;">
|
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||||
<!-- Main content table -->
|
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
|
||||||
<!-- Header with logo -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
|
||||||
<!-- Стилизованный текстовый логотип uchill -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
|
||||||
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;color:#111827;text-align:center;">Подтвердите ваш email</h1>
|
||||||
<tr>
|
<p style="margin:0 0 32px 0;font-size:15px;color:#6B7280;text-align:center;line-height:1.6;">Осталось всего один шаг!</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Greeting -->
|
<p style="margin:0 0 16px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
<tr>
|
Здравствуйте, <strong style="color:#111827;">{{ user_full_name }}</strong>!
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
|
||||||
</p>
|
</p>
|
||||||
</td>
|
<p style="margin:0 0 32px 0;font-size:16px;color:#374151;line-height:1.7;">
|
||||||
</tr>
|
Спасибо за регистрацию на <strong style="color:#7444FD;">Училл</strong>. Нажмите на кнопку ниже, чтобы подтвердить ваш адрес электронной почты и активировать аккаунт.
|
||||||
|
|
||||||
<!-- Main message -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Спасибо за регистрацию на Uchill. Для завершения регистрации необходимо подтвердить ваш email адрес.
|
|
||||||
</p>
|
</p>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 32px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
|
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
<a href="{{ verification_url }}" style="display:inline-block;padding:16px 40px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;letter-spacing:0.2px;">
|
||||||
<tr>
|
|
||||||
<td style="background-color: #7444FD; border-radius: 4px;">
|
|
||||||
<a href="{{ verification_url }}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
|
||||||
Подтвердить email
|
Подтвердить email
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Link fallback -->
|
<!-- Link fallback -->
|
||||||
<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;">
|
||||||
<td style="padding-bottom: 24px;">
|
<tr><td style="padding:16px;">
|
||||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #757575;">
|
<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>
|
||||||
</p>
|
</td></tr>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-radius: 4px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
<p style="margin: 0; font-size: 12px; color: #9E9E9E; word-break: break-all; line-height: 1.5;">
|
|
||||||
{{ verification_url }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Security notice -->
|
<!-- Notice -->
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F0FDF4;border-left:4px solid #22C55E;border-radius:8px;">
|
||||||
<td style="padding-top: 24px; border-top: 1px solid #E0E0E0;">
|
<tr><td style="padding:14px 16px;">
|
||||||
<p style="margin: 0; font-size: 12px; color: #9E9E9E; line-height: 1.6;">
|
<p style="margin:0;font-size:13px;color:#166534;line-height:1.6;">
|
||||||
Если вы не регистрировались на нашей платформе, просто проигнорируйте это письмо.
|
Если вы не регистрировались на Училл — просто проигнорируйте это письмо.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
|
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
|
||||||
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
|
|
||||||
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
|
|
||||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
|
|
||||||
© 2026 Uchill. Все права защищены.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,128 +4,106 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>Добро пожаловать на Uchill</title>
|
<title>Добро пожаловать на Училл</title>
|
||||||
<!--[if mso]>
|
<!--[if mso]><style>body,table,td{font-family:Arial,sans-serif!important;}</style><![endif]-->
|
||||||
<style type="text/css">
|
|
||||||
body, table, td {font-family: Arial, sans-serif !important;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
|
<body style="margin:0;padding:0;background-color:#F4F6F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;">
|
||||||
<!-- Wrapper table -->
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#F4F6F8;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
|
<tr><td align="center" style="padding:40px 16px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="580" style="max-width:580px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px 20px;">
|
<td style="background:linear-gradient(135deg,#7444FD 0%,#9B6FFF 100%);border-radius:16px 16px 0 0;padding:40px;text-align:center;">
|
||||||
<!-- Main content table -->
|
<span style="font-size:32px;font-weight:800;color:#ffffff;letter-spacing:-0.5px;font-family:Arial,sans-serif;">Училл</span><br>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
<span style="font-size:13px;color:rgba(255,255,255,0.75);letter-spacing:1px;text-transform:uppercase;margin-top:4px;display:inline-block;">Платформа для обучения</span>
|
||||||
<!-- Header with logo -->
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
|
<td style="background:#ffffff;padding:48px 40px 40px;">
|
||||||
<!-- Стилизованный текстовый логотип uchill -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
<!-- 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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F9FAFB;border-radius:8px;">
|
||||||
<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>
|
<tr>
|
||||||
</span>
|
<td style="padding:14px 16px;font-size:14px;color:#374151;">📚 Домашние задания и материалы</td>
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 0 40px 40px 40px;">
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
||||||
<!-- Title -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">Добро пожаловать!</h1>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Greeting -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Здравствуйте, <strong style="color: #212121;">{{ user_full_name }}</strong>!
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Main message -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Добро пожаловать на Uchill! Ваш аккаунт успешно создан.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Info box -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F5F5F5; border-left: 4px solid #7444FD; border-radius: 4px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 16px;">
|
|
||||||
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500; color: #212121;">
|
|
||||||
Ваш email для входа:
|
|
||||||
</p>
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #757575;">
|
|
||||||
{{ user_email }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 24px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">
|
|
||||||
Теперь вы можете войти в систему и начать пользоваться всеми возможностями платформы.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 8px; padding-bottom: 24px;">
|
<td style="background:linear-gradient(135deg,#7444FD,#9B6FFF);border-radius:10px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
<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;">
|
||||||
<tr>
|
Войти в Училл
|
||||||
<td style="background-color: #7444FD; border-radius: 4px;">
|
|
||||||
<a href="https://app.uchill.online/login" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
|
|
||||||
Войти в систему
|
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
|
<td style="background:#F8F9FA;border-radius:0 0 16px 16px;padding:28px 40px;border-top:1px solid #EAECF0;text-align:center;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="margin:0 0 8px 0;font-size:14px;color:#6B7280;">С уважением, <strong style="color:#7444FD;">Команда Училл</strong></p>
|
||||||
<tr>
|
<p style="margin:0;font-size:12px;color:#9CA3AF;">© {% now "Y" %} Училл. Все права защищены.</p>
|
||||||
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
|
|
||||||
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
|
|
||||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
|
|
||||||
© 2026 Uchill. Все права защищены.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from .profile_views import (
|
||||||
ClientManagementViewSet,
|
ClientManagementViewSet,
|
||||||
ParentManagementViewSet,
|
ParentManagementViewSet,
|
||||||
InvitationViewSet,
|
InvitationViewSet,
|
||||||
|
StudentMentorViewSet,
|
||||||
)
|
)
|
||||||
from .mentorship_views import MentorshipRequestViewSet
|
from .mentorship_views import MentorshipRequestViewSet
|
||||||
from .student_progress_views import StudentProgressViewSet
|
from .student_progress_views import StudentProgressViewSet
|
||||||
|
|
@ -53,6 +54,7 @@ router.register(r'parent', ParentDashboardViewSet, basename='parent-dashboard')
|
||||||
router.register(r'profile', ProfileViewSet, basename='profile')
|
router.register(r'profile', ProfileViewSet, basename='profile')
|
||||||
router.register(r'manage/clients', ClientManagementViewSet, basename='manage-clients')
|
router.register(r'manage/clients', ClientManagementViewSet, basename='manage-clients')
|
||||||
router.register(r'invitation', InvitationViewSet, basename='invitation')
|
router.register(r'invitation', InvitationViewSet, basename='invitation')
|
||||||
|
router.register(r'student/mentors', StudentMentorViewSet, basename='student-mentors')
|
||||||
router.register(r'mentorship-requests', MentorshipRequestViewSet, basename='mentorship-request')
|
router.register(r'mentorship-requests', MentorshipRequestViewSet, basename='mentorship-request')
|
||||||
router.register(r'manage/parents', ParentManagementViewSet, basename='manage-parents')
|
router.register(r'manage/parents', ParentManagementViewSet, basename='manage-parents')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class TelegramBotInfoView(generics.GenericAPIView):
|
||||||
GET /api/auth/telegram/bot-info/
|
GET /api/auth/telegram/bot-info/
|
||||||
"""
|
"""
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Получение имени бота для использования в Telegram Login Widget."""
|
"""Получение имени бота для использования в Telegram Login Widget."""
|
||||||
|
|
@ -74,6 +75,7 @@ class TelegramAuthView(generics.GenericAPIView):
|
||||||
"""
|
"""
|
||||||
serializer_class = TelegramAuthSerializer
|
serializer_class = TelegramAuthSerializer
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
throttle_classes = [BurstRateThrottle]
|
throttle_classes = [BurstRateThrottle]
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
@ -155,6 +157,7 @@ class RegisterView(generics.CreateAPIView):
|
||||||
queryset = User.objects.all()
|
queryset = User.objects.all()
|
||||||
serializer_class = RegisterSerializer
|
serializer_class = RegisterSerializer
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
throttle_classes = [BurstRateThrottle]
|
throttle_classes = [BurstRateThrottle]
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
|
@ -199,6 +202,7 @@ class LoginView(generics.GenericAPIView):
|
||||||
"""
|
"""
|
||||||
serializer_class = LoginSerializer
|
serializer_class = LoginSerializer
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
throttle_classes = [BurstRateThrottle]
|
throttle_classes = [BurstRateThrottle]
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
@ -244,6 +248,7 @@ class LoginByTokenView(generics.GenericAPIView):
|
||||||
POST /api/auth/login-by-token/
|
POST /api/auth/login-by-token/
|
||||||
"""
|
"""
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
throttle_classes = [BurstRateThrottle]
|
throttle_classes = [BurstRateThrottle]
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
@ -353,6 +358,7 @@ class PasswordResetRequestView(generics.GenericAPIView):
|
||||||
"""
|
"""
|
||||||
serializer_class = PasswordResetRequestSerializer
|
serializer_class = PasswordResetRequestSerializer
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
throttle_classes = [BurstRateThrottle]
|
throttle_classes = [BurstRateThrottle]
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
@ -390,6 +396,7 @@ class PasswordResetConfirmView(generics.GenericAPIView):
|
||||||
"""
|
"""
|
||||||
serializer_class = PasswordResetConfirmSerializer
|
serializer_class = PasswordResetConfirmSerializer
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Подтверждение восстановления пароля."""
|
"""Подтверждение восстановления пароля."""
|
||||||
|
|
@ -426,6 +433,7 @@ class EmailVerificationView(generics.GenericAPIView):
|
||||||
"""
|
"""
|
||||||
serializer_class = EmailVerificationSerializer
|
serializer_class = EmailVerificationSerializer
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Подтверждение email пользователя."""
|
"""Подтверждение email пользователя."""
|
||||||
|
|
@ -464,6 +472,7 @@ class ResendVerificationEmailView(generics.GenericAPIView):
|
||||||
Можно использовать с авторизацией или без (передавая email)
|
Можно использовать с авторизацией или без (передавая email)
|
||||||
"""
|
"""
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Повторная отправка письма подтверждения email."""
|
"""Повторная отправка письма подтверждения email."""
|
||||||
|
|
@ -806,8 +815,8 @@ class GroupViewSet(viewsets.ModelViewSet):
|
||||||
distinct=True
|
distinct=True
|
||||||
)
|
)
|
||||||
).only(
|
).only(
|
||||||
'id', 'name', 'description', 'mentor_id', 'max_students',
|
'id', 'name', 'description', 'mentor_id',
|
||||||
'is_active', 'created_at', 'updated_at'
|
'created_at'
|
||||||
)
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,16 @@ app.conf.beat_schedule = {
|
||||||
'task': 'apps.materials.tasks.cleanup_old_unused_materials',
|
'task': 'apps.materials.tasks.cleanup_old_unused_materials',
|
||||||
'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00
|
'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# РЕФЕРАЛЬНАЯ СИСТЕМА
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Обработка отложенных реферальных бонусов каждый день в 6:00
|
||||||
|
'process-pending-referral-bonuses': {
|
||||||
|
'task': 'apps.referrals.tasks.process_pending_referral_bonuses',
|
||||||
|
'schedule': crontab(hour=6, minute=0),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.task(bind=True, ignore_result=True)
|
@app.task(bind=True, ignore_result=True)
|
||||||
|
|
|
||||||
|
|
@ -253,8 +253,11 @@ services:
|
||||||
context: ./front_minimal
|
context: ./front_minimal
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_API_URL}
|
- VITE_SERVER_URL=${NEXT_PUBLIC_API_URL}
|
||||||
- NEXT_PUBLIC_ASSET_URL=${NEXT_PUBLIC_API_URL}
|
- VITE_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
|
- VITE_WS_URL=${NEXT_PUBLIC_WS_URL}
|
||||||
|
- VITE_LIVEKIT_URL=${NEXT_PUBLIC_LIVEKIT_URL}
|
||||||
|
- VITE_EXCALIDRAW_PATH=${NEXT_PUBLIC_EXCALIDRAW_PATH}
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}_front_minimal
|
container_name: ${COMPOSE_PROJECT_NAME}_front_minimal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -263,7 +266,7 @@ services:
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV:-development}
|
- NODE_ENV=${NODE_ENV:-development}
|
||||||
- HOSTNAME=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_MINIMAL_PORT:-3005}:3000"
|
- "${FRONTEND_MINIMAL_PORT:-3005}:3000"
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -284,11 +287,13 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./excalidraw-server
|
context: ./excalidraw-server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- NEXT_PUBLIC_BASE_PATH=/devboard
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
|
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV:-production}
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
- NEXT_PUBLIC_BASE_PATH=
|
- NEXT_PUBLIC_BASE_PATH=/devboard
|
||||||
ports:
|
ports:
|
||||||
- "${EXCALIDRAW_PORT:-3001}:3001"
|
- "${EXCALIDRAW_PORT:-3001}:3001"
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ RUN npx patch-package
|
||||||
# Копируем исходный код
|
# Копируем исходный код
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Собираем приложение
|
# Собираем приложение (NEXT_PUBLIC_* переменные нужны на этапе сборки)
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,32 @@
|
||||||
# Development stage для front_minimal (отдельный от front_material)
|
|
||||||
FROM node:20-alpine AS development
|
FROM node:20-alpine AS development
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# front_minimal использует NEXT_PUBLIC_SERVER_URL, NEXT_PUBLIC_ASSET_URL
|
ARG VITE_SERVER_URL
|
||||||
ARG NEXT_PUBLIC_SERVER_URL
|
ARG VITE_API_URL
|
||||||
ARG NEXT_PUBLIC_ASSET_URL
|
ARG VITE_WS_URL
|
||||||
ARG NEXT_PUBLIC_BASE_PATH
|
ARG VITE_LIVEKIT_URL
|
||||||
ARG BUILD_STATIC_EXPORT=false
|
ARG VITE_EXCALIDRAW_PATH
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_SERVER_URL}
|
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||||
ENV NEXT_PUBLIC_ASSET_URL=${NEXT_PUBLIC_ASSET_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH:-}
|
ENV VITE_WS_URL=${VITE_WS_URL}
|
||||||
ENV BUILD_STATIC_EXPORT=${BUILD_STATIC_EXPORT}
|
ENV VITE_LIVEKIT_URL=${VITE_LIVEKIT_URL}
|
||||||
|
ENV VITE_EXCALIDRAW_PATH=${VITE_EXCALIDRAW_PATH}
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV WATCHPACK_POLLING=true
|
|
||||||
ENV CHOKIDAR_USEPOLLING=true
|
ENV CHOKIDAR_USEPOLLING=true
|
||||||
|
|
||||||
# front_minimal: есть и package-lock.json и yarn.lock, используем npm
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Entrypoint: при volume-монтировании проверяем node_modules
|
# Entrypoint: при volume-монтировании проверяем node_modules, затем запускаем vite
|
||||||
RUN echo '#!/bin/sh' > /entrypoint.sh && \
|
RUN echo '#!/bin/sh' > /entrypoint.sh && \
|
||||||
echo 'set -e' >> /entrypoint.sh && \
|
echo 'set -e' >> /entrypoint.sh && \
|
||||||
echo 'if [ ! -d node_modules/next ] 2>/dev/null || [ ! -f node_modules/.package-lock.json ] 2>/dev/null; then npm install; fi' >> /entrypoint.sh && \
|
echo 'if [ ! -d node_modules/vite ] 2>/dev/null; then npm install; fi' >> /entrypoint.sh && \
|
||||||
echo 'exec npx next dev -p 3000 --hostname 0.0.0.0' >> /entrypoint.sh && \
|
echo 'exec npx vite --port 3000 --host 0.0.0.0' >> /entrypoint.sh && \
|
||||||
chmod +x /entrypoint.sh
|
chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/logo/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Училл</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -29,6 +29,9 @@
|
||||||
"@fullcalendar/timeline": "^6.1.14",
|
"@fullcalendar/timeline": "^6.1.14",
|
||||||
"@hookform/resolvers": "^3.6.0",
|
"@hookform/resolvers": "^3.6.0",
|
||||||
"@iconify/react": "^5.0.1",
|
"@iconify/react": "^5.0.1",
|
||||||
|
"@livekit/components-core": "^0.12.13",
|
||||||
|
"@livekit/components-react": "^2.9.20",
|
||||||
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@mui/lab": "^5.0.0-alpha.170",
|
"@mui/lab": "^5.0.0-alpha.170",
|
||||||
"@mui/material": "^5.15.20",
|
"@mui/material": "^5.15.20",
|
||||||
"@mui/material-nextjs": "^5.15.11",
|
"@mui/material-nextjs": "^5.15.11",
|
||||||
|
|
@ -51,6 +54,7 @@
|
||||||
"autosuggest-highlight": "^3.3.4",
|
"autosuggest-highlight": "^3.3.4",
|
||||||
"aws-amplify": "^6.3.6",
|
"aws-amplify": "^6.3.6",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"embla-carousel": "^8.1.5",
|
"embla-carousel": "^8.1.5",
|
||||||
"embla-carousel-auto-height": "^8.1.5",
|
"embla-carousel-auto-height": "^8.1.5",
|
||||||
|
|
@ -62,6 +66,7 @@
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"i18next-browser-languagedetector": "^8.0.0",
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"mapbox-gl": "^3.4.0",
|
"mapbox-gl": "^3.4.0",
|
||||||
"mui-one-time-password-input": "^2.0.2",
|
"mui-one-time-password-input": "^2.0.2",
|
||||||
|
|
@ -3239,6 +3244,12 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bufbuild/protobuf": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||||
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
|
||||||
|
|
@ -4076,20 +4087,22 @@
|
||||||
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
|
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.6.0",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
"integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==",
|
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/utils": "^0.2.1"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/dom": {
|
"node_modules/@floating-ui/dom": {
|
||||||
"version": "1.6.1",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||||
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==",
|
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.7.3",
|
||||||
"@floating-ui/utils": "^0.2.1"
|
"@floating-ui/utils": "^0.2.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/react-dom": {
|
"node_modules/@floating-ui/react-dom": {
|
||||||
|
|
@ -4105,9 +4118,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/utils": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||||
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/barlow": {
|
"node_modules/@fontsource/barlow": {
|
||||||
"version": "5.0.13",
|
"version": "5.0.13",
|
||||||
|
|
@ -4382,6 +4396,76 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@livekit/components-core": {
|
||||||
|
"version": "0.12.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
||||||
|
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "1.7.4",
|
||||||
|
"loglevel": "1.9.1",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/components-react": {
|
||||||
|
"version": "2.9.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.20.tgz",
|
||||||
|
"integrity": "sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/components-core": "0.12.13",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"jose": "^6.0.12",
|
||||||
|
"usehooks-ts": "3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@livekit/krisp-noise-filter": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/components-styles": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/mutex": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/protocol": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||||
|
|
@ -6740,6 +6824,13 @@
|
||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dom-mediacapture-record": {
|
||||||
|
"version": "1.0.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||||
|
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/geojson": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.13",
|
"version": "7946.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
|
||||||
|
|
@ -7994,6 +8085,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.11",
|
"version": "1.11.11",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
|
||||||
|
|
@ -10610,6 +10711,15 @@
|
||||||
"restructure": "^3.0.0"
|
"restructure": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-cookie": {
|
"node_modules/js-cookie": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
|
@ -10779,6 +10889,40 @@
|
||||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
|
||||||
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
|
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/livekit-client": {
|
||||||
|
"version": "2.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz",
|
||||||
|
"integrity": "sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/mutex": "1.1.1",
|
||||||
|
"@livekit/protocol": "1.44.0",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"sdp-transform": "^2.15.0",
|
||||||
|
"ts-debounce": "^4.0.0",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"typed-emitter": "^2.1.0",
|
||||||
|
"webrtc-adapter": "^9.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/dom-mediacapture-record": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/livekit-client/node_modules/loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -10830,6 +10974,19 @@
|
||||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||||
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
|
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/loglevel": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/long": {
|
"node_modules/long": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
|
||||||
|
|
@ -13237,9 +13394,10 @@
|
||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
||||||
},
|
},
|
||||||
"node_modules/rxjs": {
|
"node_modules/rxjs": {
|
||||||
"version": "7.8.1",
|
"version": "7.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -13323,6 +13481,21 @@
|
||||||
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
||||||
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
|
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/sdp": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sdp-transform": {
|
||||||
|
"version": "2.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||||
|
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"sdp-verify": "checker.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|
@ -14044,6 +14217,12 @@
|
||||||
"typescript": ">=4.2.0"
|
"typescript": ">=4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-debounce": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
|
|
@ -14069,9 +14248,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.6.2",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/turndown": {
|
"node_modules/turndown": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
|
|
@ -14185,6 +14365,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typed-emitter": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"rxjs": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.4.5",
|
"version": "5.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
|
@ -14473,6 +14662,21 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/usehooks-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.debounce": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|
@ -14580,6 +14784,19 @@
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/webrtc-adapter": {
|
||||||
|
"version": "9.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
|
||||||
|
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"sdp": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0",
|
||||||
|
"npm": ">=3.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/websocket-driver": {
|
"node_modules/websocket-driver": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,20 @@
|
||||||
{
|
{
|
||||||
"name": "@minimal-kit/next-js",
|
"name": "platform-frontend",
|
||||||
"author": "Minimals",
|
"author": "Platform",
|
||||||
"version": "6.0.1",
|
"version": "1.0.0",
|
||||||
"description": "Next & JavaScript",
|
"description": "Platform frontend — Vite + React",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3032",
|
"dev": "vite --port 3032",
|
||||||
"start": "next start -p 3032",
|
"build": "vite build",
|
||||||
"build": "next build",
|
"preview": "vite preview --port 3032",
|
||||||
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
|
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||||
"lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"",
|
"lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||||
"fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
|
"fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||||
"fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
|
"fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||||
"rm:all": "rm -rf node_modules .next out dist build",
|
"rm:all": "rm -rf node_modules dist .vite",
|
||||||
"re:start": "yarn rm:all && yarn install && yarn dev",
|
"re:start": "yarn rm:all && yarn install && yarn dev",
|
||||||
"re:build": "yarn rm:all && yarn install && yarn build",
|
"re:build": "yarn rm:all && yarn install && yarn build"
|
||||||
"re:build-npm": "npm run rm:all && npm install && npm run build",
|
|
||||||
"start:out": "npx serve@latest out -p 3032"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth0/auth0-react": "^2.2.4",
|
"@auth0/auth0-react": "^2.2.4",
|
||||||
|
|
@ -40,9 +38,11 @@
|
||||||
"@fullcalendar/timeline": "^6.1.14",
|
"@fullcalendar/timeline": "^6.1.14",
|
||||||
"@hookform/resolvers": "^3.6.0",
|
"@hookform/resolvers": "^3.6.0",
|
||||||
"@iconify/react": "^5.0.1",
|
"@iconify/react": "^5.0.1",
|
||||||
|
"@livekit/components-core": "^0.12.13",
|
||||||
|
"@livekit/components-react": "^2.9.20",
|
||||||
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@mui/lab": "^5.0.0-alpha.170",
|
"@mui/lab": "^5.0.0-alpha.170",
|
||||||
"@mui/material": "^5.15.20",
|
"@mui/material": "^5.15.20",
|
||||||
"@mui/material-nextjs": "^5.15.11",
|
|
||||||
"@mui/x-data-grid": "^7.7.0",
|
"@mui/x-data-grid": "^7.7.0",
|
||||||
"@mui/x-date-pickers": "^7.7.0",
|
"@mui/x-date-pickers": "^7.7.0",
|
||||||
"@mui/x-tree-view": "^7.7.0",
|
"@mui/x-tree-view": "^7.7.0",
|
||||||
|
|
@ -62,6 +62,7 @@
|
||||||
"autosuggest-highlight": "^3.3.4",
|
"autosuggest-highlight": "^3.3.4",
|
||||||
"aws-amplify": "^6.3.6",
|
"aws-amplify": "^6.3.6",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"embla-carousel": "^8.1.5",
|
"embla-carousel": "^8.1.5",
|
||||||
"embla-carousel-auto-height": "^8.1.5",
|
"embla-carousel-auto-height": "^8.1.5",
|
||||||
|
|
@ -73,10 +74,10 @@
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"i18next-browser-languagedetector": "^8.0.0",
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"mapbox-gl": "^3.4.0",
|
"mapbox-gl": "^3.4.0",
|
||||||
"mui-one-time-password-input": "^2.0.2",
|
"mui-one-time-password-input": "^2.0.2",
|
||||||
"next": "^14.2.4",
|
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-apexcharts": "^1.4.1",
|
"react-apexcharts": "^1.4.1",
|
||||||
|
|
@ -103,8 +104,9 @@
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@svgr/webpack": "^8.1.0",
|
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
|
@ -117,6 +119,9 @@
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-unused-imports": "^3.2.0",
|
"eslint-plugin-unused-imports": "^3.2.0",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.2",
|
||||||
"typescript": "^5.4.5"
|
"react-router-dom": "^6.30.3",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.4.21",
|
||||||
|
"vite-plugin-svgr": "^4.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,86 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
|
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
|
||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
import { getCalendarLessons, getMentorStudents, getMentorSubjects, createCalendarLesson } from 'src/utils/dashboard-api';
|
|
||||||
|
import {
|
||||||
|
getCalendarLessons,
|
||||||
|
getMentorStudents,
|
||||||
|
getMentorSubjects,
|
||||||
|
createCalendarLesson,
|
||||||
|
updateCalendarLesson,
|
||||||
|
deleteCalendarLesson,
|
||||||
|
} from 'src/utils/dashboard-api';
|
||||||
|
import { getGroups } from 'src/utils/groups-api';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
const CALENDAR_ENDPOINT = '/schedule/lessons/calendar/';
|
|
||||||
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
|
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
|
||||||
const SUBJECTS_ENDPOINT = '/schedule/subjects/';
|
const SUBJECTS_ENDPOINT = '/schedule/subjects/';
|
||||||
|
const GROUPS_ENDPOINT = '/groups/';
|
||||||
|
|
||||||
const swrOptions = {
|
const swrOptions = {
|
||||||
revalidateIfStale: true,
|
revalidateIfStale: true,
|
||||||
revalidateOnFocus: true,
|
revalidateOnFocus: false,
|
||||||
revalidateOnReconnect: true,
|
revalidateOnReconnect: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ключ кэша для календаря (по месяцу)
|
||||||
|
function calendarKey(date = new Date()) {
|
||||||
|
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
return ['calendar', start, end];
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export function useGetEvents() {
|
export function useGetEvents(currentDate) {
|
||||||
const startDate = '2026-02-01';
|
const date = currentDate || new Date();
|
||||||
const endDate = '2026-04-30';
|
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
|
||||||
|
const getChildId = () => {
|
||||||
|
if (user?.role !== 'parent') return null;
|
||||||
|
try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || null) : null; } catch { return null; }
|
||||||
|
};
|
||||||
|
const [childId, setChildId] = useState(getChildId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role !== 'parent') return undefined;
|
||||||
|
const handler = () => setChildId(getChildId());
|
||||||
|
window.addEventListener('child-changed', handler);
|
||||||
|
return () => window.removeEventListener('child-changed', handler);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user?.role]);
|
||||||
|
|
||||||
const { data: response, isLoading, error, isValidating } = useSWR(
|
const { data: response, isLoading, error, isValidating } = useSWR(
|
||||||
[CALENDAR_ENDPOINT, startDate, endDate],
|
['calendar', start, end, childId],
|
||||||
([url, start, end]) => getCalendarLessons(start, end),
|
([, s, e, cid]) => getCalendarLessons(s, e, cid ? { child_id: cid } : undefined),
|
||||||
swrOptions
|
swrOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
const memoizedValue = useMemo(() => {
|
const memoizedValue = useMemo(() => {
|
||||||
const lessonsArray = response?.data?.lessons || [];
|
const lessonsArray = response?.data?.lessons || response?.lessons || [];
|
||||||
|
|
||||||
const events = lessonsArray.map((lesson) => {
|
const events = lessonsArray.map((lesson) => {
|
||||||
const start = lesson.start_time || lesson.start;
|
const start = lesson.start_time || lesson.start;
|
||||||
const end = lesson.end_time || lesson.end || start;
|
const end = lesson.end_time || lesson.end || start;
|
||||||
|
|
||||||
const startTimeStr = start ? new Date(start).toLocaleTimeString('ru-RU', {
|
const startTimeStr = start
|
||||||
|
? new Date(start).toLocaleTimeString('ru-RU', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hourCycle: 'h23'
|
hourCycle: 'h23',
|
||||||
}) : '';
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
const subject = lesson.subject_name || lesson.subject || 'Урок';
|
const subject = lesson.subject_name || lesson.subject || 'Урок';
|
||||||
const student = lesson.client_name || '';
|
const participant = lesson.group_name
|
||||||
const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`;
|
? `Группа: ${lesson.group_name}`
|
||||||
|
: (lesson.client_name || '');
|
||||||
|
const displayTitle = `${startTimeStr} ${subject}${participant ? ` - ${participant}` : ''}`;
|
||||||
|
|
||||||
const status = String(lesson.status || 'scheduled').toLowerCase();
|
const status = String(lesson.status || 'scheduled').toLowerCase();
|
||||||
let eventColor = '#7635dc';
|
let eventColor = '#7635dc';
|
||||||
|
|
@ -64,6 +103,8 @@ export function useGetEvents() {
|
||||||
status,
|
status,
|
||||||
student: lesson.client_name || '',
|
student: lesson.client_name || '',
|
||||||
mentor: lesson.mentor_name || '',
|
mentor: lesson.mentor_name || '',
|
||||||
|
group: lesson.group || null,
|
||||||
|
group_name: lesson.group_name || '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -83,14 +124,16 @@ export function useGetEvents() {
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export function useGetStudents() {
|
export function useGetStudents() {
|
||||||
const { data: response, isLoading, error } = useSWR(STUDENTS_ENDPOINT, getMentorStudents, swrOptions);
|
const { data: response, isLoading, error } = useSWR(
|
||||||
|
STUDENTS_ENDPOINT,
|
||||||
|
getMentorStudents,
|
||||||
|
swrOptions
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const rawData = response?.data?.results || response?.results || response || [];
|
const rawData = response?.data?.results || response?.results || response || [];
|
||||||
const studentsArray = Array.isArray(rawData) ? rawData : [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
students: studentsArray,
|
students: Array.isArray(rawData) ? rawData : [],
|
||||||
studentsLoading: isLoading,
|
studentsLoading: isLoading,
|
||||||
studentsError: error,
|
studentsError: error,
|
||||||
};
|
};
|
||||||
|
|
@ -98,41 +141,72 @@ export function useGetStudents() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetSubjects() {
|
export function useGetSubjects() {
|
||||||
const { data: response, isLoading, error } = useSWR(SUBJECTS_ENDPOINT, getMentorSubjects, swrOptions);
|
const { data: response, isLoading, error } = useSWR(
|
||||||
|
SUBJECTS_ENDPOINT,
|
||||||
|
getMentorSubjects,
|
||||||
|
swrOptions
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const rawData = response?.data || response?.results || response || [];
|
const rawData = response?.data || response?.results || response || [];
|
||||||
const subjectsArray = Array.isArray(rawData) ? rawData : [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subjects: subjectsArray,
|
subjects: Array.isArray(rawData) ? rawData : [],
|
||||||
subjectsLoading: isLoading,
|
subjectsLoading: isLoading,
|
||||||
subjectsError: error,
|
subjectsError: error,
|
||||||
};
|
};
|
||||||
}, [response, isLoading, error]);
|
}, [response, isLoading, error]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
export function useGetGroups() {
|
||||||
|
const { data, isLoading, error } = useSWR(GROUPS_ENDPOINT, getGroups, swrOptions);
|
||||||
|
|
||||||
export async function createEvent(eventData) {
|
return useMemo(() => ({
|
||||||
const payload = {
|
groups: Array.isArray(data) ? data : [],
|
||||||
client: String(eventData.client),
|
groupsLoading: isLoading,
|
||||||
title: eventData.title.replace(' - ', ' — '),
|
groupsError: error,
|
||||||
description: eventData.description,
|
}), [data, isLoading, error]);
|
||||||
start_time: eventData.start_time,
|
|
||||||
duration: eventData.duration,
|
|
||||||
price: eventData.price,
|
|
||||||
is_recurring: eventData.is_recurring,
|
|
||||||
subject_id: Number(eventData.subject),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await createCalendarLesson(payload);
|
|
||||||
|
|
||||||
// Обновляем кэш, чтобы занятия появлялись сразу
|
|
||||||
mutate([CALENDAR_ENDPOINT, '2026-02-01', '2026-04-30']);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEvent(eventData) { console.log('Update Event:', eventData); }
|
// ----------------------------------------------------------------------
|
||||||
export async function deleteEvent(eventId) { console.log('Delete Event:', eventId); }
|
|
||||||
|
function revalidateCalendar() {
|
||||||
|
mutate((key) => Array.isArray(key) && key[0] === 'calendar', undefined, { revalidate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvent(eventData) {
|
||||||
|
const isGroup = !!eventData.group;
|
||||||
|
const payload = {
|
||||||
|
title: eventData.title || 'Занятие',
|
||||||
|
description: eventData.description || '',
|
||||||
|
start_time: eventData.start_time,
|
||||||
|
duration: eventData.duration || 60,
|
||||||
|
price: eventData.price,
|
||||||
|
is_recurring: eventData.is_recurring || false,
|
||||||
|
...(eventData.subject && { subject_id: Number(eventData.subject) }),
|
||||||
|
...(isGroup ? { group: eventData.group } : { client: String(eventData.client) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await createCalendarLesson(payload);
|
||||||
|
revalidateCalendar();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(eventData, currentDate) {
|
||||||
|
const { id, ...data } = eventData;
|
||||||
|
|
||||||
|
const updatePayload = {};
|
||||||
|
if (data.start_time) updatePayload.start_time = data.start_time;
|
||||||
|
if (data.duration) updatePayload.duration = data.duration;
|
||||||
|
if (data.price != null) updatePayload.price = data.price;
|
||||||
|
if (data.description != null) updatePayload.description = data.description;
|
||||||
|
if (data.status) updatePayload.status = data.status;
|
||||||
|
|
||||||
|
const res = await updateCalendarLesson(String(id), updatePayload);
|
||||||
|
revalidateCalendar();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(eventId, deleteAllFuture = false) {
|
||||||
|
await deleteCalendarLesson(String(eventId), deleteAllFuture);
|
||||||
|
revalidateCalendar();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { LocalizationProvider } from 'src/locales';
|
||||||
|
import { I18nProvider } from 'src/locales/i18n-provider';
|
||||||
|
import { ThemeProvider } from 'src/theme/theme-provider';
|
||||||
|
|
||||||
|
import { Snackbar } from 'src/components/snackbar';
|
||||||
|
import { ProgressBar } from 'src/components/progress-bar';
|
||||||
|
import { MotionLazy } from 'src/components/animate/motion-lazy';
|
||||||
|
import { SettingsDrawer, defaultSettings, SettingsProvider } from 'src/components/settings';
|
||||||
|
|
||||||
|
import { AuthProvider } from 'src/auth/context/jwt';
|
||||||
|
|
||||||
|
import { Router } from 'src/routes/sections';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nProvider>
|
||||||
|
<LocalizationProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<SettingsProvider settings={defaultSettings} caches="localStorage">
|
||||||
|
<ThemeProvider>
|
||||||
|
<MotionLazy>
|
||||||
|
<Snackbar />
|
||||||
|
<ProgressBar />
|
||||||
|
<SettingsDrawer />
|
||||||
|
<Router />
|
||||||
|
</MotionLazy>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</LocalizationProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||||
|
|
||||||
|
import { GuestGuard } from 'src/auth/guard';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<GuestGuard>
|
||||||
|
<AuthSplitLayout>{children}</AuthSplitLayout>
|
||||||
|
</GuestGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { JwtForgotPasswordView } from 'src/sections/auth/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Forgot password | Jwt - ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <JwtForgotPasswordView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||||
|
|
||||||
|
import { GuestGuard } from 'src/auth/guard';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<GuestGuard>
|
||||||
|
<AuthSplitLayout>{children}</AuthSplitLayout>
|
||||||
|
</GuestGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { JwtResetPasswordView } from 'src/sections/auth/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Reset password | Jwt - ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <JwtResetPasswordView />;
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,14 @@ import { GuestGuard } from 'src/auth/guard';
|
||||||
export default function Layout({ children }) {
|
export default function Layout({ children }) {
|
||||||
return (
|
return (
|
||||||
<GuestGuard>
|
<GuestGuard>
|
||||||
<AuthSplitLayout section={{ title: 'Hi, Welcome back' }}>{children}</AuthSplitLayout>
|
<AuthSplitLayout
|
||||||
|
section={{
|
||||||
|
title: 'Добро пожаловать',
|
||||||
|
subtitle: 'Платформа для онлайн-обучения и работы с репетиторами.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthSplitLayout>
|
||||||
</GuestGuard>
|
</GuestGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return <AuthSplitLayout>{children}</AuthSplitLayout>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { JwtVerifyEmailView } from 'src/sections/auth/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Verify email | Jwt - ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <JwtVerifyEmailView />;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { CONFIG } from 'src/config-global';
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
import { OverviewAnalyticsView } from 'src/sections/overview/analytics/view';
|
import { AnalyticsView } from 'src/sections/analytics/view';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export const metadata = { title: `Analytics | Dashboard - ${CONFIG.site.name}` };
|
export const metadata = { title: `Аналитика | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <OverviewAnalyticsView />;
|
return <AnalyticsView />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { BoardView } from 'src/sections/board/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Доска | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <BoardView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ChatPlatformView } from 'src/sections/chat/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Чат | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ChatPlatformView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ChildrenProgressView } from 'src/sections/children/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Прогресс ребёнка | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ChildrenProgressView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ChildrenView } from 'src/sections/children/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Мои дети | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ChildrenView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { FeedbackView } from 'src/sections/feedback/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Обратная связь | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <FeedbackView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { HomeworkView } from 'src/sections/homework/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Домашние задания | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <HomeworkView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { LessonDetailView } from 'src/sections/lesson-detail/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Занятие | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page({ params }) {
|
||||||
|
return <LessonDetailView id={params.id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic';
|
||||||
|
|
||||||
|
export { dynamic };
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { MaterialsView } from 'src/sections/materials/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Материалы | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <MaterialsView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { MyProgressView } from 'src/sections/my-progress/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Мой прогресс | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <MyProgressView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { NotificationsView } from 'src/sections/notifications/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Уведомления | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <NotificationsView />;
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,130 @@
|
||||||
'use client';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
import { CONFIG } from 'src/config-global';
|
|
||||||
import { useAuthContext } from 'src/auth/hooks';
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
// Временно импортируем только ментора (позже добавим клиента и родителя)
|
|
||||||
import { OverviewCourseView } from 'src/sections/overview/course/view';
|
import { OverviewCourseView } from 'src/sections/overview/course/view';
|
||||||
|
import { OverviewClientView } from 'src/sections/overview/client/view';
|
||||||
|
|
||||||
export default function Page() {
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadChildren() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/parent/dashboard/');
|
||||||
|
const raw = res.data?.children ?? [];
|
||||||
|
return raw.map((item) => {
|
||||||
|
const c = item.child ?? item;
|
||||||
|
return { id: c.id, name: c.name || c.email || '' };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
const { user, loading } = useAuthContext();
|
const { user, loading } = useAuthContext();
|
||||||
|
|
||||||
|
const [selectedChild, setSelectedChild] = useState(null);
|
||||||
|
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||||
|
const [noChildren, setNoChildren] = useState(false);
|
||||||
|
|
||||||
|
// Load children for parent role
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role !== 'parent') return undefined;
|
||||||
|
|
||||||
|
setChildrenLoading(true);
|
||||||
|
|
||||||
|
loadChildren().then((list) => {
|
||||||
|
setChildrenLoading(false);
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
setNoChildren(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to restore saved child
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('selected_child');
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
const exists = list.find((c) => c.id === parsed.id);
|
||||||
|
if (exists) {
|
||||||
|
setSelectedChild(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Auto-select first child
|
||||||
|
const first = list[0];
|
||||||
|
localStorage.setItem('selected_child', JSON.stringify(first));
|
||||||
|
window.dispatchEvent(new Event('child-changed'));
|
||||||
|
setSelectedChild(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
// React to child switch from nav selector
|
||||||
|
const handler = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('selected_child');
|
||||||
|
if (saved) setSelectedChild(JSON.parse(saved));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
window.addEventListener('child-changed', handler);
|
||||||
|
return () => window.removeEventListener('child-changed', handler);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Загрузка...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Роутинг по ролям
|
|
||||||
if (user.role === 'mentor') {
|
|
||||||
return <OverviewCourseView />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === 'client') {
|
|
||||||
return <div>Дашборд Клиента (в разработке)</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === 'parent') {
|
|
||||||
return <div>Дашборд Родителя (в разработке)</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<p>Неизвестная роль пользователя: {user.role}</p>
|
<CircularProgress />
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
if (user.role === 'mentor') return <OverviewCourseView />;
|
||||||
|
|
||||||
|
if (user.role === 'client') return <OverviewClientView />;
|
||||||
|
|
||||||
|
if (user.role === 'parent') {
|
||||||
|
if (childrenLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noChildren) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Typography variant="h6" color="text.secondary">Нет привязанных детей</Typography>
|
||||||
|
<Typography variant="body2" color="text.disabled">
|
||||||
|
Обратитесь к администратору для привязки аккаунта ребёнка
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedChild) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OverviewClientView childId={selectedChild.id} childName={selectedChild.name} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { PaymentPlatformView } from 'src/sections/payment/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Оплата | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PaymentPlatformView />;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { DashboardContent } from 'src/layouts/dashboard';
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { AccountPlatformView } from 'src/sections/account-platform/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Профиль | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AccountPlatformView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ReferralsView } from 'src/sections/referrals/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Рефералы | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ReferralsView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { StudentsView } from 'src/sections/students/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Ученики | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <StudentsView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
import { InviteRegisterView } from 'src/sections/invite/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Регистрация по приглашению | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page({ params }) {
|
||||||
|
return <InviteRegisterView token={params.token} />;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Fullscreen layout — no sidebar or header
|
||||||
|
|
||||||
|
export default function VideoCallLayout({ children }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { VideoCallView } from 'src/sections/video-call/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Видеозвонок | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <VideoCallView />;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
signIn as _signIn,
|
signIn as _signIn,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { Amplify } from 'aws-amplify';
|
import { Amplify } from 'aws-amplify';
|
||||||
import { useMemo, useEffect, useCallback } from 'react';
|
import { useMemo, useEffect, useCallback } from 'react';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useAuth0, Auth0Provider } from '@auth0/auth0-react';
|
import { useAuth0, Auth0Provider } from '@auth0/auth0-react';
|
||||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { doc, setDoc, collection } from 'firebase/firestore';
|
import { doc, setDoc, collection } from 'firebase/firestore';
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { doc, getDoc } from 'firebase/firestore';
|
import { doc, getDoc } from 'firebase/firestore';
|
||||||
import { onAuthStateChanged } from 'firebase/auth';
|
import { onAuthStateChanged } from 'firebase/auth';
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,25 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import axios, { endpoints } from 'src/utils/axios';
|
import axios, { endpoints } from 'src/utils/axios';
|
||||||
|
|
||||||
import { setSession } from './utils';
|
import { setSession } from './utils';
|
||||||
import { STORAGE_KEY } from './constant';
|
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
|
||||||
|
|
||||||
/** **************************************
|
/** **************************************
|
||||||
* Sign in
|
* Sign in
|
||||||
*************************************** */
|
*************************************** */
|
||||||
export const signInWithPassword = async ({ email, password }) => {
|
export const signInWithPassword = async ({ email, password }) => {
|
||||||
try {
|
try {
|
||||||
const params = { email, password };
|
const res = await axios.post(endpoints.auth.signIn, { email, password });
|
||||||
|
|
||||||
const res = await axios.post(endpoints.auth.signIn, params);
|
const data = res.data?.data;
|
||||||
|
const accessToken = data?.tokens?.access;
|
||||||
// Адаптация под твой API: { data: { tokens: { access } } }
|
const refreshToken = data?.tokens?.refresh;
|
||||||
const accessToken = res.data?.data?.tokens?.access;
|
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new Error('Access token not found in response');
|
throw new Error('Access token not found in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSession(accessToken);
|
await setSession(accessToken, refreshToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during sign in:', error);
|
console.error('Error during sign in:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -31,24 +29,23 @@ export const signInWithPassword = async ({ email, password }) => {
|
||||||
/** **************************************
|
/** **************************************
|
||||||
* Sign up
|
* Sign up
|
||||||
*************************************** */
|
*************************************** */
|
||||||
export const signUp = async ({ email, password, firstName, lastName }) => {
|
export const signUp = async ({ email, password, passwordConfirm, firstName, lastName, role, city, timezone }) => {
|
||||||
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
firstName,
|
password_confirm: passwordConfirm,
|
||||||
lastName,
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
role: role || 'client',
|
||||||
|
city: city || '',
|
||||||
|
timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
await axios.post(endpoints.auth.signUp, params);
|
||||||
const res = await axios.post(endpoints.auth.signUp, params);
|
|
||||||
|
|
||||||
const { accessToken } = res.data;
|
// Всегда требуем подтверждение email перед входом
|
||||||
|
return { requiresVerification: true };
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error('Access token not found in response');
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionStorage.setItem(STORAGE_KEY, accessToken);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during sign up:', error);
|
console.error('Error during sign up:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -66,3 +63,67 @@ export const signOut = async () => {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Refresh token
|
||||||
|
*************************************** */
|
||||||
|
export const refreshAccessToken = async () => {
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_STORAGE_KEY);
|
||||||
|
if (!refreshToken) throw new Error('No refresh token');
|
||||||
|
|
||||||
|
const res = await axios.post(endpoints.auth.refresh, { refresh: refreshToken }, {
|
||||||
|
headers: { Authorization: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = res.data?.access;
|
||||||
|
if (!accessToken) throw new Error('No access token in refresh response');
|
||||||
|
|
||||||
|
await setSession(accessToken, refreshToken);
|
||||||
|
return accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during token refresh:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Request password reset
|
||||||
|
*************************************** */
|
||||||
|
export const requestPasswordReset = async ({ email }) => {
|
||||||
|
try {
|
||||||
|
await axios.post(endpoints.auth.passwordReset, { email });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during password reset request:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Confirm password reset
|
||||||
|
*************************************** */
|
||||||
|
export const confirmPasswordReset = async ({ token, newPassword, newPasswordConfirm }) => {
|
||||||
|
try {
|
||||||
|
await axios.post(endpoints.auth.passwordResetConfirm, {
|
||||||
|
token,
|
||||||
|
new_password: newPassword,
|
||||||
|
new_password_confirm: newPasswordConfirm,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during password reset confirm:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Verify email
|
||||||
|
*************************************** */
|
||||||
|
export const verifyEmail = async ({ token }) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post(endpoints.auth.verifyEmail, { token });
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during email verification:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo, useEffect, useCallback } from 'react';
|
import { useMemo, useEffect, useCallback } from 'react';
|
||||||
import { useSetState } from 'src/hooks/use-set-state';
|
import { useSetState } from 'src/hooks/use-set-state';
|
||||||
import axios, { endpoints } from 'src/utils/axios';
|
import axios, { endpoints } from 'src/utils/axios';
|
||||||
import { STORAGE_KEY } from './constant';
|
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
|
||||||
import { AuthContext } from '../auth-context';
|
import { AuthContext } from '../auth-context';
|
||||||
import { setSession, isValidToken } from './utils';
|
import { setSession, isValidToken } from './utils';
|
||||||
|
import { refreshAccessToken } from './action';
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const { state, setState } = useSetState({
|
const { state, setState } = useSetState({
|
||||||
|
|
@ -15,25 +15,28 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
const checkUserSession = useCallback(async () => {
|
const checkUserSession = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const accessToken = sessionStorage.getItem(STORAGE_KEY);
|
let accessToken = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
if (accessToken && isValidToken(accessToken)) {
|
if (accessToken && isValidToken(accessToken)) {
|
||||||
setSession(accessToken);
|
setSession(accessToken);
|
||||||
|
} else {
|
||||||
|
// Пробуем обновить через refresh token
|
||||||
|
try {
|
||||||
|
accessToken = await refreshAccessToken();
|
||||||
|
} catch {
|
||||||
|
setState({ user: null, loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await axios.get(endpoints.auth.me);
|
const res = await axios.get(endpoints.auth.me);
|
||||||
|
|
||||||
// Гарантируем получение объекта пользователя из data
|
|
||||||
const userData = res.data?.data || res.data;
|
const userData = res.data?.data || res.data;
|
||||||
|
|
||||||
// Если прилетел массив или невалидный объект - сбрасываем
|
|
||||||
if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
|
if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
|
||||||
throw new Error('Invalid user data format');
|
throw new Error('Invalid user data format');
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ user: { ...userData, accessToken }, loading: false });
|
setState({ user: { ...userData, accessToken }, loading: false });
|
||||||
} else {
|
|
||||||
setState({ user: null, loading: false });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth Debug]:', error);
|
console.error('[Auth Debug]:', error);
|
||||||
setState({ user: null, loading: false });
|
setState({ user: null, loading: false });
|
||||||
|
|
@ -52,7 +55,7 @@ export function AuthProvider({ children }) {
|
||||||
user: state.user
|
user: state.user
|
||||||
? {
|
? {
|
||||||
...state.user,
|
...state.user,
|
||||||
role: state.user?.role ?? 'admin',
|
role: state.user?.role ?? 'client',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
checkUserSession,
|
checkUserSession,
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export const STORAGE_KEY = 'jwt_access_token';
|
export const STORAGE_KEY = 'jwt_access_token';
|
||||||
|
export const REFRESH_STORAGE_KEY = 'jwt_refresh_token';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
import axios from 'src/utils/axios';
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
import { STORAGE_KEY } from './constant';
|
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -57,26 +57,29 @@ export function tokenExpired(exp) {
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
alert('Token expired!');
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(REFRESH_STORAGE_KEY);
|
||||||
window.location.href = paths.auth.jwt.signIn;
|
window.location.href = paths.auth.jwt.signIn;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during token expiration:', error);
|
console.error('Error during token expiration:', error);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}, timeLeft);
|
}, timeLeft);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export async function setSession(accessToken) {
|
export async function setSession(accessToken, refreshToken) {
|
||||||
try {
|
try {
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
sessionStorage.setItem(STORAGE_KEY, accessToken);
|
localStorage.setItem(STORAGE_KEY, accessToken);
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
localStorage.setItem(REFRESH_STORAGE_KEY, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
|
axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
|
||||||
|
|
||||||
const decodedToken = jwtDecode(accessToken); // ~3 days by minimals server
|
const decodedToken = jwtDecode(accessToken);
|
||||||
|
|
||||||
if (decodedToken && 'exp' in decodedToken) {
|
if (decodedToken && 'exp' in decodedToken) {
|
||||||
tokenExpired(decodedToken.exp);
|
tokenExpired(decodedToken.exp);
|
||||||
|
|
@ -84,7 +87,8 @@ export async function setSession(accessToken) {
|
||||||
throw new Error('Invalid access token!');
|
throw new Error('Invalid access token!');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_STORAGE_KEY);
|
||||||
delete axios.defaults.headers.common.Authorization;
|
delete axios.defaults.headers.common.Authorization;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { paths } from 'src/routes/paths';
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo, useEffect, useCallback } from 'react';
|
import { useMemo, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
|
@ -10,6 +9,21 @@ import { CONFIG } from 'src/config-global';
|
||||||
import { SplashScreen } from 'src/components/loading-screen';
|
import { SplashScreen } from 'src/components/loading-screen';
|
||||||
|
|
||||||
import { useAuthContext } from '../hooks';
|
import { useAuthContext } from '../hooks';
|
||||||
|
import { STORAGE_KEY } from '../context/jwt/constant';
|
||||||
|
import { isValidToken } from '../context/jwt/utils';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Synchronously check if there's a valid token in localStorage
|
||||||
|
// to avoid showing SplashScreen on every load when user is already authenticated
|
||||||
|
function hasValidStoredToken() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return token ? isValidToken(token) : false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -22,7 +36,8 @@ export function AuthGuard({ children }) {
|
||||||
|
|
||||||
const { authenticated, loading } = useAuthContext();
|
const { authenticated, loading } = useAuthContext();
|
||||||
|
|
||||||
const [isChecking, setIsChecking] = useState(true);
|
// Skip splash if we already have a valid token — avoids flash on every page load
|
||||||
|
const [isChecking, setIsChecking] = useState(() => !hasValidStoredToken());
|
||||||
|
|
||||||
const createQueryString = useCallback(
|
const createQueryString = useCallback(
|
||||||
(name, value) => {
|
(name, value) => {
|
||||||
|
|
@ -40,18 +55,8 @@ export function AuthGuard({ children }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
const { method } = CONFIG.auth;
|
const signInPath = paths.auth.jwt.signIn;
|
||||||
|
|
||||||
const signInPath = {
|
|
||||||
jwt: paths.auth.jwt.signIn,
|
|
||||||
auth0: paths.auth.auth0.signIn,
|
|
||||||
amplify: paths.auth.amplify.signIn,
|
|
||||||
firebase: paths.auth.firebase.signIn,
|
|
||||||
supabase: paths.auth.supabase.signIn,
|
|
||||||
}[method];
|
|
||||||
|
|
||||||
const href = `${signInPath}?${createQueryString('returnTo', pathname)}`;
|
const href = `${signInPath}?${createQueryString('returnTo', pathname)}`;
|
||||||
|
|
||||||
router.replace(href);
|
router.replace(href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { m } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}</>;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { m } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export function AnimateLogo1({ logo, sx, ...other }) {
|
||||||
}}
|
}}
|
||||||
sx={{ display: 'inline-flex' }}
|
sx={{ display: 'inline-flex' }}
|
||||||
>
|
>
|
||||||
{logo ?? <Logo disableLink width={64} height={64} />}
|
{logo ?? <Logo disableLink width={64} height={64} mini />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { LazyMotion } from 'framer-motion';
|
import { LazyMotion } from 'framer-motion';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useMemo } from 'react';
|
import { useRef, useMemo } from 'react';
|
||||||
import { useScroll } from 'framer-motion';
|
import { useScroll } from 'framer-motion';
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import dynamic from 'next/dynamic';
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
import { withLoadingProps } from 'src/utils/with-loading-props';
|
|
||||||
|
|
||||||
import { ChartLoading } from './chart-loading';
|
import { ChartLoading } from './chart-loading';
|
||||||
|
|
||||||
const ApexChart = withLoadingProps((props) =>
|
const ApexChart = lazy(() => import('react-apexcharts').then((mod) => ({ default: mod.default })));
|
||||||
dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => {
|
|
||||||
const { loading, type } = props();
|
|
||||||
|
|
||||||
return loading?.disabled ? null : <ChartLoading type={type} sx={loading?.sx} />;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -41,6 +30,13 @@ export function Chart({
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
{...other}
|
{...other}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
loadingProps?.disabled ? null : (
|
||||||
|
<ChartLoading type={type} sx={loadingProps?.sx} />
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ApexChart
|
<ApexChart
|
||||||
type={type}
|
type={type}
|
||||||
|
|
@ -48,8 +44,8 @@ export function Chart({
|
||||||
options={options}
|
options={options}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
loading={loadingProps}
|
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,9 @@ export function useChart(options) {
|
||||||
animations: {
|
animations: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
speed: 360,
|
speed: 360,
|
||||||
|
easing: 'easeinout',
|
||||||
animateGradually: { enabled: true, delay: 120 },
|
animateGradually: { enabled: true, delay: 120 },
|
||||||
dynamicAnimation: { enabled: true, speed: 360 },
|
dynamicAnimation: { enabled: true, speed: 400, easing: 'easeinout' },
|
||||||
...options?.chart?.animations,
|
...options?.chart?.animations,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export function CustomPopover({ open, onClose, children, anchorEl, slotProps, ..
|
||||||
open={!!open}
|
open={!!open}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
disableScrollLock
|
||||||
anchorOrigin={anchorOrigin}
|
anchorOrigin={anchorOrigin}
|
||||||
transformOrigin={transformOrigin}
|
transformOrigin={transformOrigin}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { Icon, disableCache } from '@iconify/react';
|
import { Icon, disableCache } from '@iconify/react';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Portal from '@mui/material/Portal';
|
import Portal from '@mui/material/Portal';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Portal from '@mui/material/Portal';
|
import Portal from '@mui/material/Portal';
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,70 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useId, forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import NoSsr from '@mui/material/NoSsr';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
import { RouterLink } from 'src/routes/components';
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
import { logoClasses } from './classes';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export const Logo = forwardRef(
|
/**
|
||||||
({ width = 40, height = 40, disableLink = false, className, href = '/', sx, ...other }, ref) => {
|
* mini=false (default) → full logo (logo.svg)
|
||||||
const theme = useTheme();
|
* mini=true → icon only (favicon.png)
|
||||||
|
|
||||||
const gradientId = useId();
|
|
||||||
|
|
||||||
const PRIMARY_LIGHT = theme.vars.palette.primary.light;
|
|
||||||
|
|
||||||
const PRIMARY_MAIN = theme.vars.palette.primary.main;
|
|
||||||
|
|
||||||
const PRIMARY_DARK = theme.vars.palette.primary.dark;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* OR using local (public folder)
|
|
||||||
* const logo = ( <Box alt="logo" component="img" src={`${CONFIG.site.basePath}/logo/logo-single.svg`} width={width} height={height} /> );
|
|
||||||
*/
|
*/
|
||||||
|
export const Logo = forwardRef(
|
||||||
|
({ width, height, mini = false, disableLink = false, className, href = '/dashboard', sx, ...other }, ref) => {
|
||||||
|
|
||||||
const logo = (
|
const defaultWidth = mini ? 40 : 134;
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512">
|
const defaultHeight = mini ? 40 : 40;
|
||||||
<defs>
|
|
||||||
<linearGradient id={`${gradientId}-1`} x1="100%" x2="50%" y1="9.946%" y2="50%">
|
|
||||||
<stop offset="0%" stopColor={PRIMARY_DARK} />
|
|
||||||
<stop offset="100%" stopColor={PRIMARY_MAIN} />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<linearGradient id={`${gradientId}-2`} x1="50%" x2="50%" y1="0%" y2="100%">
|
const w = width ?? defaultWidth;
|
||||||
<stop offset="0%" stopColor={PRIMARY_LIGHT} />
|
const h = height ?? defaultHeight;
|
||||||
<stop offset="100%" stopColor={PRIMARY_MAIN} />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<linearGradient id={`${gradientId}-3`} x1="50%" x2="50%" y1="0%" y2="100%">
|
const logo = mini ? (
|
||||||
<stop offset="0%" stopColor={PRIMARY_LIGHT} />
|
<Box
|
||||||
<stop offset="100%" stopColor={PRIMARY_MAIN} />
|
component="img"
|
||||||
</linearGradient>
|
alt="logo icon"
|
||||||
</defs>
|
src={`${CONFIG.site.basePath}/logo/favicon.png`}
|
||||||
|
sx={{ width: w, height: h, objectFit: 'contain' }}
|
||||||
<g fill={PRIMARY_MAIN} fillRule="evenodd" stroke="none" strokeWidth="1">
|
|
||||||
<path
|
|
||||||
fill={`url(#${`${gradientId}-1`})`}
|
|
||||||
d="M183.168 285.573l-2.918 5.298-2.973 5.363-2.846 5.095-2.274 4.043-2.186 3.857-2.506 4.383-1.6 2.774-2.294 3.939-1.099 1.869-1.416 2.388-1.025 1.713-1.317 2.18-.95 1.558-1.514 2.447-.866 1.38-.833 1.312-.802 1.246-.77 1.18-.739 1.111-.935 1.38-.664.956-.425.6-.41.572-.59.8-.376.497-.537.69-.171.214c-10.76 13.37-22.496 23.493-36.93 29.334-30.346 14.262-68.07 14.929-97.202-2.704l72.347-124.682 2.8-1.72c49.257-29.326 73.08 1.117 94.02 40.927z"
|
|
||||||
/>
|
/>
|
||||||
<path
|
) : (
|
||||||
fill={`url(#${`${gradientId}-2`})`}
|
<Box
|
||||||
d="M444.31 229.726c-46.27-80.956-94.1-157.228-149.043-45.344-7.516 14.384-12.995 42.337-25.267 42.337v-.142c-12.272 0-17.75-27.953-25.265-42.337C189.79 72.356 141.96 148.628 95.69 229.584c-3.483 6.106-6.828 11.932-9.69 16.996 106.038-67.127 97.11 135.667 184 137.278V384c86.891-1.611 77.962-204.405 184-137.28-2.86-5.062-6.206-10.888-9.69-16.994"
|
component="img"
|
||||||
|
alt="logo"
|
||||||
|
src={`${CONFIG.site.basePath}/logo/logo.svg`}
|
||||||
|
sx={{ width: w, height: h, objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
<path
|
|
||||||
fill={`url(#${`${gradientId}-3`})`}
|
|
||||||
d="M450 384c26.509 0 48-21.491 48-48s-21.491-48-48-48-48 21.491-48 48 21.491 48 48 48"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const style = {
|
||||||
<NoSsr
|
|
||||||
fallback={
|
|
||||||
<Box
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
className={logoClasses.root.concat(className ? ` ${className}` : '')}
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'inline-flex',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
component={RouterLink}
|
|
||||||
href={href}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
className={logoClasses.root.concat(className ? ` ${className}` : '')}
|
|
||||||
aria-label="logo"
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
...(disableLink && { pointerEvents: 'none' }),
|
...(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}
|
{...other}
|
||||||
>
|
>
|
||||||
{logo}
|
{logo}
|
||||||
</Box>
|
</Link>
|
||||||
</NoSsr>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function NavList({ data, render, depth, slotProps, enabledRootRedirect })
|
||||||
disabled={data.disabled}
|
disabled={data.disabled}
|
||||||
hasChild={!!data.children}
|
hasChild={!!data.children}
|
||||||
open={data.children && openMenu}
|
open={data.children && openMenu}
|
||||||
externalLink={isExternalLink(data.path)}
|
externalLink={data.externalLink || isExternalLink(data.path)}
|
||||||
enabledRootRedirect={enabledRootRedirect}
|
enabledRootRedirect={enabledRootRedirect}
|
||||||
// styles
|
// styles
|
||||||
slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem}
|
slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import dynamic from 'next/dynamic';
|
import { lazy, Suspense, cloneElement } from 'react';
|
||||||
import { cloneElement } from 'react';
|
|
||||||
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
|
@ -7,13 +6,13 @@ import { flattenArray } from 'src/utils/helper';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
const Tree = dynamic(() => import('react-organizational-chart').then((mod) => mod.Tree), {
|
const Tree = lazy(() =>
|
||||||
ssr: false,
|
import('react-organizational-chart').then((mod) => ({ default: mod.Tree }))
|
||||||
});
|
);
|
||||||
|
|
||||||
const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), {
|
const TreeNode = lazy(() =>
|
||||||
ssr: false,
|
import('react-organizational-chart').then((mod) => ({ default: mod.TreeNode }))
|
||||||
});
|
);
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -27,6 +26,7 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<Tree
|
<Tree
|
||||||
lineWidth="1.5px"
|
lineWidth="1.5px"
|
||||||
nodePadding="4px"
|
nodePadding="4px"
|
||||||
|
|
@ -39,6 +39,7 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
|
||||||
<TreeList key={index} depth={1} data={list} nodeItem={nodeItem} />
|
<TreeList key={index} depth={1} data={list} nodeItem={nodeItem} />
|
||||||
))}
|
))}
|
||||||
</Tree>
|
</Tree>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue