fix
Deploy to Production / deploy-production (push) Successful in 26s Details

This commit is contained in:
root 2026-02-28 21:55:33 +03:00
parent 835bd76479
commit 47e134a857
22 changed files with 2612 additions and 2513 deletions

View File

@ -90,12 +90,40 @@ def create_livekit_room(request):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Проверяем, есть ли LiveKit комната для этого занятия # Если LiveKit комната не создана — создаём «на лету» (fallback при сбое при создании урока)
if not lesson.livekit_room_name: if not lesson.livekit_room_name:
return Response( try:
{'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'}, try:
status=status.HTTP_500_INTERNAL_SERVER_ERROR existing = VideoRoom.objects.get(lesson=lesson)
) room_name = str(existing.room_id)
except VideoRoom.DoesNotExist:
room_name = LiveKitService.generate_room_name()
client_user = lesson.client.user if hasattr(lesson.client, 'user') else lesson.client
VideoRoom.objects.create(
lesson=lesson,
mentor=lesson.mentor,
client=client_user,
room_id=room_name,
is_recording=True,
max_participants=10 if lesson.group else 2,
)
mentor_token = LiveKitService.generate_access_token(
room_name=room_name,
participant_name=lesson.mentor.get_full_name(),
participant_identity=str(lesson.mentor.pk),
is_admin=True,
expires_in_minutes=1440,
)
lesson.livekit_room_name = room_name
lesson.livekit_access_token = mentor_token
lesson.save(update_fields=['livekit_room_name', 'livekit_access_token'])
logger.info(f'LiveKit room created on-demand for lesson {lesson.id}')
except Exception:
logger.exception(f'Failed to create LiveKit room for lesson {lesson.id}')
return Response(
{'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
room_name = lesson.livekit_room_name room_name = lesson.livekit_room_name

View File

@ -1,127 +1,127 @@
""" """
Сигналы для видеоконференций. Сигналы для видеоконференций.
""" """
import logging import logging
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from apps.schedule.models import Lesson from apps.schedule.models import Lesson
from .models import VideoRoom from .models import VideoRoom
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@receiver(post_save, sender=Lesson) @receiver(post_save, sender=Lesson)
def create_video_room_for_lesson(sender, instance, created, **kwargs): def create_video_room_for_lesson(sender, instance, created, **kwargs):
""" """
Автоматическое создание видеокомнаты и ссылки на встречу при создании занятия. Автоматическое создание видеокомнаты и ссылки на встречу при создании занятия.
""" """
# Создаем комнату для всех занятий (не только confirmed) # Создаем комнату для всех занятий (не только confirmed)
if created or not hasattr(instance, 'video_room'): if created or not hasattr(instance, 'video_room'):
try: try:
# Проверяем, есть ли уже комната # Проверяем, есть ли уже комната
try: try:
video_room = instance.video_room video_room = instance.video_room
except VideoRoom.DoesNotExist: except VideoRoom.DoesNotExist:
video_room = None video_room = None
if not video_room: if not video_room:
client_user = instance.client.user if hasattr(instance.client, 'user') else instance.client client_user = instance.client.user if hasattr(instance.client, 'user') else instance.client
video_room = VideoRoom.objects.create( video_room = VideoRoom.objects.create(
lesson=instance, lesson=instance,
mentor=instance.mentor, mentor=instance.mentor,
client=client_user, client=client_user,
is_recording=True, # По умолчанию включаем запись is_recording=True, # По умолчанию включаем запись
max_participants=2 max_participants=2
) )
logger.info(f'Создана видеокомната {video_room.room_id} для занятия {instance.id}') logger.info(f'Создана видеокомната {video_room.room_id} для занятия {instance.id}')
# Создаем или обновляем meeting_url на основе видеокомнаты (LiveKit) # Создаем или обновляем meeting_url на основе видеокомнаты (LiveKit)
from django.conf import settings from django.conf import settings
if not instance.meeting_url: if not instance.meeting_url:
# Используем LiveKit вместо старого video room # Используем LiveKit вместо старого video room
# ВАЖНО: включаем lesson_id для синхронизации доски между пользователями # ВАЖНО: включаем lesson_id для синхронизации доски между пользователями
meeting_url = f'{settings.FRONTEND_URL}/livekit/{video_room.room_id}?lesson_id={instance.id}' meeting_url = f'{settings.FRONTEND_URL}/livekit/{video_room.room_id}?lesson_id={instance.id}'
instance.meeting_url = meeting_url instance.meeting_url = meeting_url
# Сохраняем без вызова сигналов, чтобы избежать рекурсии # Сохраняем без вызова сигналов, чтобы избежать рекурсии
Lesson.objects.filter(id=instance.id).update(meeting_url=meeting_url) Lesson.objects.filter(id=instance.id).update(meeting_url=meeting_url)
logger.info(f'Создана ссылка на LiveKit встречу для занятия {instance.id}: {meeting_url}') logger.info(f'Создана ссылка на LiveKit встречу для занятия {instance.id}: {meeting_url}')
# Планируем автоматическое удаление видеокомнаты через 10 минут после окончания занятия # Планируем автоматическое удаление видеокомнаты через 10 минут после окончания занятия
if instance.end_time: if instance.end_time:
from .tasks import schedule_video_room_deletion from .tasks import schedule_video_room_deletion
schedule_video_room_deletion.delay(instance.id) schedule_video_room_deletion.delay(instance.id)
logger.info(f'Запланировано автоматическое удаление видеокомнаты для занятия {instance.id}') logger.info(f'Запланировано автоматическое удаление видеокомнаты для занятия {instance.id}')
# Отправляем уведомления о создании комнаты (только при создании) # Отправляем уведомления о создании комнаты (только при создании)
if created: if created:
from apps.notifications.services import NotificationService from apps.notifications.services import NotificationService
meeting_link = f'/video/rooms/{video_room.room_id}/join/' meeting_link = f'/video/rooms/{video_room.room_id}/join/'
# Уведомление ментору # Уведомление ментору
NotificationService.send_notification( NotificationService.send_notification(
user=instance.mentor, user=instance.mentor,
notification_type='video_room_created', notification_type='video_room_created',
message=f'Создана видеокомната для занятия "{instance.title}"', message=f'Создана видеокомната для занятия "{instance.title}"',
link=meeting_link link=meeting_link
) )
# Уведомление клиенту (если есть) # Уведомление клиенту (если есть)
if instance.client: if instance.client:
try: try:
client_user = instance.client.user if hasattr(instance.client, 'user') else None client_user = instance.client.user if hasattr(instance.client, 'user') else None
if client_user: if client_user:
NotificationService.send_notification( NotificationService.send_notification(
user=client_user, user=client_user,
notification_type='video_room_created', notification_type='video_room_created',
message=f'Создана видеокомната для занятия "{instance.title}"', message=f'Создана видеокомната для занятия "{instance.title}"',
link=meeting_link link=meeting_link
) )
except Exception as e: except Exception as e:
logger.warning(f'Не удалось отправить уведомление клиенту для занятия {instance.id}: {str(e)}') logger.warning(f'Не удалось отправить уведомление клиенту для занятия {instance.id}: {str(e)}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка создания видеокомнаты для занятия {instance.id}: {str(e)}') logger.error(f'Ошибка создания видеокомнаты для занятия {instance.id}: {str(e)}')
@receiver(post_save, sender=VideoRoom) @receiver(post_save, sender=VideoRoom)
def handle_video_room_status_change(sender, instance, created, **kwargs): def handle_video_room_status_change(sender, instance, created, **kwargs):
""" """
Обработка изменения статуса видеокомнаты. Обработка изменения статуса видеокомнаты.
""" """
if not created: if not created:
# Если комната только что завершилась # Если комната только что завершилась
if instance.status == 'ended' and instance.ended_at: if instance.status == 'ended' and instance.ended_at:
# Обновляем статус связанного занятия, если оно еще не завершено # Обновляем статус связанного занятия, если оно еще не завершено
try: try:
lesson = instance.lesson lesson = instance.lesson
if lesson.status in ['scheduled', 'in_progress']: if lesson.status in ['scheduled', 'in_progress']:
lesson.status = 'completed' lesson.status = 'completed'
lesson.completed_at = timezone.now() lesson.completed_at = timezone.now()
lesson.save(update_fields=['status', 'completed_at']) lesson.save(update_fields=['status', 'completed_at'])
logger.info(f'Статус занятия {lesson.id} обновлен на "completed" после завершения видеокомнаты') logger.info(f'Статус занятия {lesson.id} обновлен на "completed" после завершения видеокомнаты')
except Exception as e: except Exception as e:
logger.error(f'Ошибка обновления статуса занятия для видеокомнаты {instance.room_id}: {str(e)}') logger.error(f'Ошибка обновления статуса занятия для видеокомнаты {instance.room_id}: {str(e)}')
# Генерируем лог звонка # Генерируем лог звонка
from .tasks import generate_call_log from .tasks import generate_call_log
generate_call_log.delay(instance.id) generate_call_log.delay(instance.id)
logger.info(f'Комната {instance.room_id} завершена, запущена генерация лога') logger.info(f'Комната {instance.room_id} завершена, запущена генерация лога')
# Если есть запись, начинаем обработку # Если есть запись, начинаем обработку
if instance.is_recording: if instance.is_recording:
from .models import ScreenRecording from .models import ScreenRecording
# Проверяем есть ли запись # Проверяем есть ли запись
try: try:
recording = ScreenRecording.objects.get(room=instance) recording = ScreenRecording.objects.get(room=instance)
if recording.status == 'processing': if recording.status == 'processing':
from .tasks import process_recording from .tasks import process_recording
process_recording.delay(recording.id) process_recording.delay(recording.id)
logger.info(f'Запущена обработка записи {recording.id}') logger.info(f'Запущена обработка записи {recording.id}')
except ScreenRecording.DoesNotExist: except ScreenRecording.DoesNotExist:
logger.warning(f'Запись для комнаты {instance.room_id} не найдена') logger.warning(f'Запись для комнаты {instance.room_id} не найдена')

View File

@ -1,168 +1,168 @@
""" """
Celery конфигурация для фоновых задач. Celery конфигурация для фоновых задач.
""" """
import os import os
from celery import Celery from celery import Celery
from celery.schedules import crontab from celery.schedules import crontab
from celery import signals from celery import signals
# Установка настроек Django для Celery # Установка настроек Django для Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('platform') app = Celery('platform')
# Загрузка конфигурации из Django settings с префиксом CELERY_ # Загрузка конфигурации из Django settings с префиксом CELERY_
app.config_from_object('django.conf:settings', namespace='CELERY') app.config_from_object('django.conf:settings', namespace='CELERY')
# Автоматическое обнаружение tasks.py в приложениях # Автоматическое обнаружение tasks.py в приложениях
app.autodiscover_tasks() app.autodiscover_tasks()
@signals.worker_ready.connect @signals.worker_ready.connect
def on_worker_ready(sender=None, **kwargs): def on_worker_ready(sender=None, **kwargs):
""" """
Запуск задач при старте Celery worker. Запуск задач при старте Celery worker.
Обрабатывает накопившиеся занятия, которые нужно обновить. Обрабатывает накопившиеся занятия, которые нужно обновить.
""" """
from apps.schedule.tasks import start_lessons_automatically from apps.schedule.tasks import start_lessons_automatically
try: try:
# Запускаем задачу синхронно при старте worker, чтобы сразу обработать накопившиеся занятия # Запускаем задачу синхронно при старте worker, чтобы сразу обработать накопившиеся занятия
start_lessons_automatically() start_lessons_automatically()
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info('[Celery] Задача start_lessons_automatically выполнена при старте worker') logger.info('[Celery] Задача start_lessons_automatically выполнена при старте worker')
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.warning(f'[Celery] Ошибка при запуске start_lessons_automatically при старте: {str(e)}') logger.warning(f'[Celery] Ошибка при запуске start_lessons_automatically при старте: {str(e)}')
# Периодические задачи (Celery Beat) # Периодические задачи (Celery Beat)
app.conf.beat_schedule = { app.conf.beat_schedule = {
# ============================================ # ============================================
# ЗАДАЧИ ПОДПИСОК # ЗАДАЧИ ПОДПИСОК
# ============================================ # ============================================
# Проверка истекших подписок каждый день в 00:00 # Проверка истекших подписок каждый день в 00:00
'check-expired-subscriptions': { 'check-expired-subscriptions': {
'task': 'apps.subscriptions.tasks.check_expired_subscriptions', 'task': 'apps.subscriptions.tasks.check_expired_subscriptions',
'schedule': crontab(hour=0, minute=0), # Каждый день в 00:00 'schedule': crontab(hour=0, minute=0), # Каждый день в 00:00
}, },
# Отправка предупреждений об истечении подписок каждый день в 10:00 # Отправка предупреждений об истечении подписок каждый день в 10:00
'send-expiration-warnings': { 'send-expiration-warnings': {
'task': 'apps.subscriptions.tasks.send_expiration_warnings', 'task': 'apps.subscriptions.tasks.send_expiration_warnings',
'schedule': crontab(hour=10, minute=0), # Каждый день в 10:00 'schedule': crontab(hour=10, minute=0), # Каждый день в 10:00
}, },
# Автопродление подписок каждый день в 02:00 # Автопродление подписок каждый день в 02:00
'auto-renew-subscriptions': { 'auto-renew-subscriptions': {
'task': 'apps.subscriptions.tasks.auto_renew_subscriptions', 'task': 'apps.subscriptions.tasks.auto_renew_subscriptions',
'schedule': crontab(hour=2, minute=0), # Каждый день в 02:00 'schedule': crontab(hour=2, minute=0), # Каждый день в 02:00
}, },
# Сброс месячного использования 1-го числа каждого месяца в 00:00 # Сброс месячного использования 1-го числа каждого месяца в 00:00
'reset-monthly-usage': { 'reset-monthly-usage': {
'task': 'apps.subscriptions.tasks.reset_monthly_usage', 'task': 'apps.subscriptions.tasks.reset_monthly_usage',
'schedule': crontab(day_of_month=1, hour=0, minute=0), # 1-го числа в 00:00 'schedule': crontab(day_of_month=1, hour=0, minute=0), # 1-го числа в 00:00
}, },
# Очистка старой истории платежей 1-го числа каждого месяца в 03:00 # Очистка старой истории платежей 1-го числа каждого месяца в 03:00
'cleanup-old-payment-history': { 'cleanup-old-payment-history': {
'task': 'apps.subscriptions.tasks.cleanup_old_payment_history', 'task': 'apps.subscriptions.tasks.cleanup_old_payment_history',
'schedule': crontab(day_of_month=1, hour=3, minute=0), # 1-го числа в 03:00 'schedule': crontab(day_of_month=1, hour=3, minute=0), # 1-го числа в 03:00
}, },
# Генерация отчетов по подпискам 1-го числа каждого месяца в 09:00 # Генерация отчетов по подпискам 1-го числа каждого месяца в 09:00
'generate-subscription-reports': { 'generate-subscription-reports': {
'task': 'apps.subscriptions.tasks.generate_subscription_reports', 'task': 'apps.subscriptions.tasks.generate_subscription_reports',
'schedule': crontab(day_of_month=1, hour=9, minute=0), # 1-го числа в 09:00 'schedule': crontab(day_of_month=1, hour=9, minute=0), # 1-го числа в 09:00
}, },
# ============================================ # ============================================
# ЗАДАЧИ РАСПИСАНИЯ # ЗАДАЧИ РАСПИСАНИЯ
# ============================================ # ============================================
# Автоматическое начало и завершение занятий по времени (каждую минуту) # Автоматическое начало и завершение занятий по времени (каждую минуту)
'start-lessons-automatically': { 'start-lessons-automatically': {
'task': 'apps.schedule.tasks.start_lessons_automatically', 'task': 'apps.schedule.tasks.start_lessons_automatically',
'schedule': 60.0, # каждые 60 секунд 'schedule': 60.0, # каждые 60 секунд
}, },
# Отправка напоминаний о занятиях за 1 час # Отправка напоминаний о занятиях за 1 час
'send-lesson-reminders': { 'send-lesson-reminders': {
'task': 'apps.schedule.tasks.send_lesson_reminders', 'task': 'apps.schedule.tasks.send_lesson_reminders',
'schedule': crontab(minute='*/15'), # Каждые 15 минут 'schedule': crontab(minute='*/15'), # Каждые 15 минут
}, },
# Отправка запросов о подтверждении присутствия за 3 часа до занятия # Отправка запросов о подтверждении присутствия за 3 часа до занятия
'send-attendance-confirmation-requests': { 'send-attendance-confirmation-requests': {
'task': 'apps.schedule.tasks.send_attendance_confirmation_requests', 'task': 'apps.schedule.tasks.send_attendance_confirmation_requests',
'schedule': crontab(minute='*/10'), # Каждые 10 минут 'schedule': crontab(minute='*/10'), # Каждые 10 минут
}, },
# Отправка отложенных уведомлений каждую минуту # Отправка отложенных уведомлений каждую минуту
'send-scheduled-notifications': { 'send-scheduled-notifications': {
'task': 'apps.notifications.tasks.send_scheduled_notifications', 'task': 'apps.notifications.tasks.send_scheduled_notifications',
'schedule': 60.0, # Каждые 60 секунд 'schedule': 60.0, # Каждые 60 секунд
}, },
# Очистка старых уведомлений каждый день в 3:00 # Очистка старых уведомлений каждый день в 3:00
'cleanup-old-notifications': { 'cleanup-old-notifications': {
'task': 'apps.notifications.tasks.cleanup_old_notifications', 'task': 'apps.notifications.tasks.cleanup_old_notifications',
'schedule': crontab(hour=3, minute=0), 'schedule': crontab(hour=3, minute=0),
}, },
# Очистка старых файлов домашних заданий каждую неделю # Очистка старых файлов домашних заданий каждую неделю
'cleanup-old-homework-files': { 'cleanup-old-homework-files': {
'task': 'apps.homework.tasks.cleanup_old_files', 'task': 'apps.homework.tasks.cleanup_old_files',
'schedule': crontab(day_of_week=0, hour=2, minute=0), # Воскресенье в 2:00 'schedule': crontab(day_of_week=0, hour=2, minute=0), # Воскресенье в 2:00
}, },
# Автоматическое завершение неактивных видеокомнат (каждые 5 минут) # Автоматическое завершение неактивных видеокомнат (каждые 5 минут)
'end-inactive-video-rooms': { 'end-inactive-video-rooms': {
'task': 'apps.video.tasks.end_inactive_rooms', 'task': 'apps.video.tasks.end_inactive_rooms',
'schedule': 300.0, # каждые 5 минут 'schedule': 300.0, # каждые 5 минут
}, },
# Поддержание 12 будущих занятий для повторяющихся занятий (каждый день в 2:00) # Поддержание 12 будущих занятий для повторяющихся занятий (каждый день в 2:00)
'maintain-recurring-lessons': { 'maintain-recurring-lessons': {
'task': 'apps.schedule.tasks.maintain_recurring_lessons', 'task': 'apps.schedule.tasks.maintain_recurring_lessons',
'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00 'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00
}, },
# Перенос кастомных предметов в общую модель (каждый день в 3:00) # Перенос кастомных предметов в общую модель (каждый день в 3:00)
'promote-mentor-subjects-to-subjects': { 'promote-mentor-subjects-to-subjects': {
'task': 'apps.schedule.tasks.promote_mentor_subjects_to_subjects', 'task': 'apps.schedule.tasks.promote_mentor_subjects_to_subjects',
'schedule': crontab(hour=3, minute=0), # Каждый день в 3:00 'schedule': crontab(hour=3, minute=0), # Каждый день в 3:00
}, },
# Автоматическое создание бэкапа базы данных каждый день в 2:00 # Автоматическое создание бэкапа базы данных каждый день в 2:00
'backup-database': { 'backup-database': {
'task': 'apps.core.tasks.backup_database', 'task': 'apps.core.tasks.backup_database',
'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00 'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00
}, },
# Очистка старых бэкапов каждый день в 4:00 # Очистка старых бэкапов каждый день в 4:00
'cleanup-old-backups': { 'cleanup-old-backups': {
'task': 'apps.core.tasks.cleanup_old_backups', 'task': 'apps.core.tasks.cleanup_old_backups',
'schedule': crontab(hour=4, minute=0), # Каждый день в 4:00 'schedule': crontab(hour=4, minute=0), # Каждый день в 4:00
}, },
# Синхронизация квот хранилища с подписками каждый день в 1:00 # Синхронизация квот хранилища с подписками каждый день в 1:00
'sync-all-storage-quotas': { 'sync-all-storage-quotas': {
'task': 'apps.materials.tasks.sync_all_storage_quotas', 'task': 'apps.materials.tasks.sync_all_storage_quotas',
'schedule': crontab(hour=1, minute=0), # Каждый день в 1:00 'schedule': crontab(hour=1, minute=0), # Каждый день в 1:00
}, },
# Очистка старых неиспользуемых материалов каждую неделю в воскресенье в 3:00 # Очистка старых неиспользуемых материалов каждую неделю в воскресенье в 3:00
'cleanup-old-unused-materials': { 'cleanup-old-unused-materials': {
'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
}, },
} }
@app.task(bind=True, ignore_result=True) @app.task(bind=True, ignore_result=True)
def debug_task(self): def debug_task(self):
"""Тестовая задача для проверки работы Celery.""" """Тестовая задача для проверки работы Celery."""
print(f'Request: {self.request!r}') print(f'Request: {self.request!r}')

View File

@ -9,8 +9,8 @@ from datetime import timedelta
# ============================================== # ==============================================
SIMPLE_JWT = { SIMPLE_JWT = {
# Время жизни access токена (15 минут) # Время жизни access токена (3 часа - достаточно для длинных уроков)
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), 'ACCESS_TOKEN_LIFETIME': timedelta(hours=3),
# Время жизни refresh токена (7 дней) # Время жизни refresh токена (7 дней)
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7),

View File

@ -403,7 +403,7 @@ REST_FRAMEWORK = {
# ============================================== # ==============================================
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), 'ACCESS_TOKEN_LIFETIME': timedelta(hours=3), # 3 часа - достаточно для длинных уроков
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True, 'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True, 'BLACKLIST_AFTER_ROTATION': True,

File diff suppressed because it is too large Load Diff

View File

@ -12,17 +12,7 @@ export default function DashboardPage() {
const { selectedChild, loading: childLoading, childrenList } = useSelectedChild(); const { selectedChild, loading: childLoading, childrenList } = useSelectedChild();
if (authLoading) { if (authLoading) {
return ( return <LoadingSpinner size="large" fullPage />;
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '50vh',
background: 'var(--md-sys-color-background)'
}}>
<LoadingSpinner size="large" />
</div>
);
} }
if (!user) { if (!user) {
@ -38,17 +28,7 @@ export default function DashboardPage() {
// Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка // Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка
if (user.role === 'parent') { if (user.role === 'parent') {
if (childLoading && childrenList.length === 0) { if (childLoading && childrenList.length === 0) {
return ( return <LoadingSpinner size="large" fullPage />;
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '50vh',
background: 'var(--md-sys-color-background)'
}}>
<LoadingSpinner size="large" />
</div>
);
} }
if (childrenList.length === 0) { if (childrenList.length === 0) {
return ( return (

View File

@ -1,269 +1,269 @@
'use client'; 'use client';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getLessons, type Lesson } from '@/api/schedule'; import { getLessons, type Lesson } from '@/api/schedule';
import { FeedbackModal } from '@/components/schedule/FeedbackModal'; import { FeedbackModal } from '@/components/schedule/FeedbackModal';
import { DashboardLayout } from '@/components/dashboard/ui'; import { DashboardLayout } from '@/components/dashboard/ui';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
function getSubjectName(lesson: Lesson): string { function getSubjectName(lesson: Lesson): string {
if (typeof lesson.subject === 'string') return lesson.subject; if (typeof lesson.subject === 'string') return lesson.subject;
if (lesson.subject && typeof lesson.subject === 'object' && 'name' in lesson.subject) { if (lesson.subject && typeof lesson.subject === 'object' && 'name' in lesson.subject) {
return (lesson.subject as { name: string }).name; return (lesson.subject as { name: string }).name;
} }
return (lesson as { subject_name?: string }).subject_name || 'Занятие'; return (lesson as { subject_name?: string }).subject_name || 'Занятие';
} }
function formatDate(s: string) { function formatDate(s: string) {
return new Date(s).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); return new Date(s).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
} }
function formatTime(s: string) { function formatTime(s: string) {
return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} }
export default function FeedbackPage() { export default function FeedbackPage() {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const [lessons, setLessons] = useState<Lesson[]>([]); const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null); const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
useEffect(() => { useEffect(() => {
if (user?.role !== 'mentor') return; if (user?.role !== 'mentor') return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
setLoading(true); setLoading(true);
const res = await getLessons({ status: 'completed' }); const res = await getLessons({ status: 'completed' });
const list = Array.isArray(res) ? res : res?.results || []; const list = Array.isArray(res) ? res : res?.results || [];
if (!cancelled) setLessons(list); if (!cancelled) setLessons(list);
} catch (e) { } catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Ошибка загрузки'); if (!cancelled) setError(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [user?.role]); }, [user?.role]);
const studentLessons = useMemo( const studentLessons = useMemo(
() => lessons.filter((l) => !(l as { group?: number }).group), () => lessons.filter((l) => !(l as { group?: number }).group),
[lessons] [lessons]
); );
const getFeedbackStatus = (l: Lesson) => { const getFeedbackStatus = (l: Lesson) => {
const has = !!( const has = !!(
l.mentor_grade || l.mentor_grade ||
(l as { school_grade?: number }).school_grade || (l as { school_grade?: number }).school_grade ||
(l.mentor_notes && l.mentor_notes.trim().length > 0) (l.mentor_notes && l.mentor_notes.trim().length > 0)
); );
return has ? 'done' : 'todo'; return has ? 'done' : 'todo';
}; };
const todoLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'todo'); const todoLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'todo');
const doneLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'done'); const doneLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'done');
const openFeedback = (lesson: Lesson) => { const openFeedback = (lesson: Lesson) => {
setSelectedLesson(lesson); setSelectedLesson(lesson);
setShowModal(true); setShowModal(true);
}; };
const handleSuccess = async () => { const handleSuccess = async () => {
setSelectedLesson(null); setSelectedLesson(null);
setShowModal(false); setShowModal(false);
try { try {
const res = await getLessons({ status: 'completed' }); const res = await getLessons({ status: 'completed' });
const list = Array.isArray(res) ? res : res?.results || []; const list = Array.isArray(res) ? res : res?.results || [];
setLessons(list); setLessons(list);
} catch { } catch {
// ignore // ignore
} }
}; };
const loadLessons = async () => { const loadLessons = async () => {
try { try {
const res = await getLessons({ status: 'completed' }); const res = await getLessons({ status: 'completed' });
const list = Array.isArray(res) ? res : res?.results || []; const list = Array.isArray(res) ? res : res?.results || [];
setLessons(list); setLessons(list);
} catch { } catch {
// ignore // ignore
} }
}; };
if (authLoading) { if (authLoading) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<LoadingSpinner size="large" /> <LoadingSpinner size="large" />
</div> </div>
); );
} }
if (user?.role !== 'mentor') { if (user?.role !== 'mentor') {
return ( return (
<DashboardLayout> <DashboardLayout>
<div <div
style={{ style={{
padding: 24, padding: 24,
textAlign: 'center', textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
}} }}
> >
Страница доступна только менторам Страница доступна только менторам
</div> </div>
</DashboardLayout> </DashboardLayout>
); );
} }
const LessonCard = ({ const LessonCard = ({
lesson, lesson,
onFill, onFill,
}: { }: {
lesson: Lesson; lesson: Lesson;
onFill: () => void; onFill: () => void;
}) => { }) => {
const clientName = const clientName =
typeof lesson.client === 'object' && lesson.client?.user typeof lesson.client === 'object' && lesson.client?.user
? `${lesson.client.user.first_name} ${lesson.client.user.last_name}` ? `${lesson.client.user.first_name} ${lesson.client.user.last_name}`
: (lesson as { client_name?: string }).client_name || 'Студент'; : (lesson as { client_name?: string }).client_name || 'Студент';
return ( return (
<div <div
className="ios26-panel" className="ios26-panel"
style={{ style={{
padding: 16, padding: 16,
cursor: 'pointer', cursor: 'pointer',
transition: 'box-shadow 0.2s', transition: 'box-shadow 0.2s',
}} }}
onClick={onFill} onClick={onFill}
> >
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
fontSize: 11, fontSize: 11,
fontWeight: 600, fontWeight: 600,
padding: '4px 10px', padding: '4px 10px',
borderRadius: 8, borderRadius: 8,
background: 'var(--md-sys-color-primary-container)', background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-primary)',
marginBottom: 8, marginBottom: 8,
}} }}
> >
{getSubjectName(lesson)} {getSubjectName(lesson)}
</span> </span>
<h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}> <h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}>
{lesson.title} {lesson.title}
</h4> </h4>
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}> <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span>
{clientName} {clientName}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span>
{formatDate(lesson.start_time)} {formatDate(lesson.start_time)}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>schedule</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>schedule</span>
{formatTime(lesson.start_time)} {formatTime(lesson.end_time)} {formatTime(lesson.start_time)} {formatTime(lesson.end_time)}
</div> </div>
{lesson.mentor_grade != null && ( {lesson.mentor_grade != null && (
<div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}> <div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
Оценка: {lesson.mentor_grade}/5 Оценка: {lesson.mentor_grade}/5
</div> </div>
)} )}
</div> </div>
<button <button
type="button" type="button"
style={{ style={{
marginTop: 12, marginTop: 12,
width: '100%', width: '100%',
padding: '10px 16px', padding: '10px 16px',
borderRadius: 12, borderRadius: 12,
border: 'none', border: 'none',
background: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary-container)' : 'var(--md-sys-color-primary)', background: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary-container)' : 'var(--md-sys-color-primary)',
color: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-primary)', color: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-primary)',
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onFill(); onFill();
}} }}
> >
{lesson.mentor_notes || lesson.mentor_grade != null ? 'Редактировать' : 'Заполнить'} {lesson.mentor_notes || lesson.mentor_grade != null ? 'Редактировать' : 'Заполнить'}
</button> </button>
</div> </div>
); );
}; };
return ( return (
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="feedback-root"> <DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="feedback-root">
{error && ( {error && (
<div <div
style={{ style={{
padding: 16, padding: 16,
marginBottom: 16, marginBottom: 16,
background: 'rgba(186,26,26,0.1)', background: 'rgba(186,26,26,0.1)',
borderRadius: 12, borderRadius: 12,
color: 'var(--md-sys-color-error)', color: 'var(--md-sys-color-error)',
}} }}
> >
{error} {error}
</div> </div>
)} )}
{loading ? ( {loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}> <div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />
</div> </div>
) : ( ) : (
<div className="ios26-feedback-kanban"> <div className="ios26-feedback-kanban">
<div className="ios26-feedback-column"> <div className="ios26-feedback-column">
<h3 className="ios26-feedback-column__title"> <h3 className="ios26-feedback-column__title">
Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''} Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''}
</h3> </h3>
<div className="ios26-feedback-column__cards"> <div className="ios26-feedback-column__cards">
{todoLessons.map((l) => ( {todoLessons.map((l) => (
<LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} /> <LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} />
))} ))}
{todoLessons.length === 0 && ( {todoLessons.length === 0 && (
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}> <p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
Нет занятий Нет занятий
</p> </p>
)} )}
</div> </div>
</div> </div>
<div className="ios26-feedback-column"> <div className="ios26-feedback-column">
<h3 className="ios26-feedback-column__title"> <h3 className="ios26-feedback-column__title">
Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''} Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''}
</h3> </h3>
<div className="ios26-feedback-column__cards"> <div className="ios26-feedback-column__cards">
{doneLessons.map((l) => ( {doneLessons.map((l) => (
<LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} /> <LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} />
))} ))}
{doneLessons.length === 0 && ( {doneLessons.length === 0 && (
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}> <p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
Нет занятий Нет занятий
</p> </p>
)} )}
</div> </div>
</div> </div>
</div> </div>
)} )}
<FeedbackModal <FeedbackModal
isOpen={showModal} isOpen={showModal}
lesson={selectedLesson} lesson={selectedLesson}
onClose={() => { onClose={() => {
setShowModal(false); setShowModal(false);
setSelectedLesson(null); setSelectedLesson(null);
}} }}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { HomeworkPageContent } from '@/components/homework/HomeworkPageContent'; import { HomeworkPageContent } from '@/components/homework/HomeworkPageContent';
export default function HomeworkPage() { export default function HomeworkPage() {
return <HomeworkPageContent />; return <HomeworkPageContent />;
} }

View File

@ -76,41 +76,57 @@ export default function ProtectedLayout({
useEffect(() => { useEffect(() => {
// Проверяем токен в localStorage напрямую, чтобы избежать race condition // Проверяем токен в localStorage напрямую, чтобы избежать race condition
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; const accessToken = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refresh_token') : null;
console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname }); console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasAccessToken: !!accessToken, hasRefreshToken: !!refreshToken, pathname });
if (!loading && !user) { if (!loading && !user) {
console.log('[ProtectedLayout] No user found, redirecting to login'); // Если есть refresh токен, пробуем обновить сессию вместо редиректа
router.replace('/login'); if (refreshToken) {
console.log('[ProtectedLayout] User lost but refresh token exists, trying to restore session...');
import('@/api/auth').then(({ refreshToken: doRefresh }) => {
doRefresh(refreshToken)
.then(({ access }) => {
if (access) {
localStorage.setItem('access_token', access);
console.log('[ProtectedLayout] Session restored, reloading user...');
window.location.reload();
} else {
console.log('[ProtectedLayout] Refresh failed, redirecting to login');
router.replace('/login');
}
})
.catch(() => {
console.log('[ProtectedLayout] Refresh error, redirecting to login');
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
router.replace('/login');
});
});
} else {
console.log('[ProtectedLayout] No user and no refresh token, redirecting to login');
router.replace('/login');
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading]); }, [user, loading]);
if (loading) { // Стабильный loading layout - предотвращает дёрганье
return ( const loadingLayout = (
<div <div className="protected-layout-root">
style={{ <main className="protected-main" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<LoadingSpinner size="large" /> <LoadingSpinner size="large" />
</div> </main>
); </div>
);
if (loading) {
return loadingLayout;
} }
if (!user) { if (!user) {
return ( return loadingLayout;
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: 'var(--md-sys-color-background)' }}>
<div style={{ textAlign: 'center', color: 'var(--md-sys-color-on-surface)' }}>
<LoadingSpinner size="large" />
<p style={{ marginTop: '16px', fontSize: '14px', opacity: 0.8 }}>Проверка авторизации...</p>
</div>
</div>
);
} }
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом // Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом

View File

@ -1,20 +1,20 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
const ProfilePaymentTab = dynamic( const ProfilePaymentTab = dynamic(
() => import('@/components/profile/ProfilePaymentTab').then((m) => ({ default: m.ProfilePaymentTab })), () => import('@/components/profile/ProfilePaymentTab').then((m) => ({ default: m.ProfilePaymentTab })),
{ ssr: false, loading: () => <LoadingSpinner size="medium" /> } { ssr: false, loading: () => <LoadingSpinner size="medium" /> }
); );
export default function PaymentPage() { export default function PaymentPage() {
return ( return (
<div style={{ padding: 24 }} data-tour="payment-root"> <div style={{ padding: 24 }} data-tour="payment-root">
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}> <h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}>
Подписки и оплата Подписки и оплата
</h1> </h1>
<ProfilePaymentTab /> <ProfilePaymentTab />
</div> </div>
); );
} }

View File

@ -1,268 +1,268 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Box, TextField, List, ListItemButton, ListItemText, Badge, Avatar, Button, IconButton } from '@mui/material'; import { Box, TextField, List, ListItemButton, ListItemText, Badge, Avatar, Button, IconButton } from '@mui/material';
import NotificationsActiveRoundedIcon from '@mui/icons-material/NotificationsActiveRounded'; import NotificationsActiveRoundedIcon from '@mui/icons-material/NotificationsActiveRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import AssignmentRoundedIcon from '@mui/icons-material/AssignmentRounded'; import AssignmentRoundedIcon from '@mui/icons-material/AssignmentRounded';
import AddCommentRoundedIcon from '@mui/icons-material/AddCommentRounded'; import AddCommentRoundedIcon from '@mui/icons-material/AddCommentRounded';
import type { Chat } from '@/api/chat'; import type { Chat } from '@/api/chat';
import { NewChatModal } from './NewChatModal'; import { NewChatModal } from './NewChatModal';
interface ChatListProps { interface ChatListProps {
chats: Chat[]; chats: Chat[];
selectedChatUuid: string | null; selectedChatUuid: string | null;
onSelect: (chat: Chat) => void; onSelect: (chat: Chat) => void;
hasMore?: boolean; hasMore?: boolean;
loadingMore?: boolean; loadingMore?: boolean;
onLoadMore?: () => void; onLoadMore?: () => void;
} }
function parsePreview(text: string) { function parsePreview(text: string) {
const t = (text || '').trim(); const t = (text || '').trim();
if (!t) return { icons: [] as Array<'bell' | 'trash' | 'note'>, text: '—' }; if (!t) return { icons: [] as Array<'bell' | 'trash' | 'note'>, text: '—' };
const icons: Array<'bell' | 'trash' | 'note'> = []; const icons: Array<'bell' | 'trash' | 'note'> = [];
let rest = t; let rest = t;
// выкусываем частые эмодзи-пиктограммы из system сообщений // выкусываем частые эмодзи-пиктограммы из system сообщений
const take = (emoji: string, key: 'bell' | 'trash' | 'note') => { const take = (emoji: string, key: 'bell' | 'trash' | 'note') => {
if (rest.includes(emoji)) { if (rest.includes(emoji)) {
icons.push(key); icons.push(key);
rest = rest.replace(emoji, '').trim(); rest = rest.replace(emoji, '').trim();
} }
}; };
take('🔔', 'bell'); take('🔔', 'bell');
take('🗑️', 'trash'); take('🗑️', 'trash');
take('🗑', 'trash'); take('🗑', 'trash');
take('📝', 'note'); take('📝', 'note');
// убираем лишние эмодзи/разделители в начале // убираем лишние эмодзи/разделители в начале
rest = rest.replace(/^[-–—•\s]+/, '').trim(); rest = rest.replace(/^[-–—•\s]+/, '').trim();
return { icons, text: rest || '—' }; return { icons, text: rest || '—' };
} }
export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMore, onLoadMore }: ChatListProps) { export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMore, onLoadMore }: ChatListProps) {
const [q, setQ] = React.useState(''); const [q, setQ] = React.useState('');
const [isNewChatModalOpen, setIsNewChatModalOpen] = React.useState(false); const [isNewChatModalOpen, setIsNewChatModalOpen] = React.useState(false);
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
const qq = q.trim().toLowerCase(); const qq = q.trim().toLowerCase();
if (!qq) return chats; if (!qq) return chats;
return chats.filter((c) => { return chats.filter((c) => {
const name = (c.participant_name || '').toLowerCase(); const name = (c.participant_name || '').toLowerCase();
const last = (c.last_message || '').toLowerCase(); const last = (c.last_message || '').toLowerCase();
return name.includes(qq) || last.includes(qq); return name.includes(qq) || last.includes(qq);
}); });
}, [chats, q]); }, [chats, q]);
return ( return (
<Box <Box
className="ios-glass-panel" className="ios-glass-panel"
data-tour="chat-list" data-tour="chat-list"
sx={{ sx={{
borderRadius: '20px', borderRadius: '20px',
p: 2, p: 2,
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 1.5, gap: 1.5,
background: 'var(--ios26-glass)', background: 'var(--ios26-glass)',
border: '1px solid var(--ios26-glass-border)', border: '1px solid var(--ios26-glass-border)',
backdropFilter: 'var(--ios26-blur)', backdropFilter: 'var(--ios26-blur)',
}} }}
> >
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<TextField <TextField
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
placeholder="Поиск" placeholder="Поиск"
size="small" size="small"
fullWidth fullWidth
sx={{ sx={{
'& .MuiInputBase-root': { '& .MuiInputBase-root': {
borderRadius: 3, borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.7)', backgroundColor: 'rgba(255,255,255,0.7)',
}, },
}} }}
/> />
<IconButton <IconButton
onClick={() => setIsNewChatModalOpen(true)} onClick={() => setIsNewChatModalOpen(true)}
sx={{ sx={{
bgcolor: 'var(--md-sys-color-primary)', bgcolor: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)', color: 'var(--md-sys-color-on-primary)',
'&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(0.9)' } '&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(0.9)' }
}} }}
> >
<AddCommentRoundedIcon /> <AddCommentRoundedIcon />
</IconButton> </IconButton>
</Box> </Box>
<Box <Box
sx={{ sx={{
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflowY: 'auto', overflowY: 'auto',
overflowX: 'hidden', overflowX: 'hidden',
pr: 0.5, pr: 0.5,
}} }}
> >
<List disablePadding> <List disablePadding>
{filtered.map((chat) => { {filtered.map((chat) => {
const selected = !!selectedChatUuid && chat.uuid === selectedChatUuid; const selected = !!selectedChatUuid && chat.uuid === selectedChatUuid;
const preview = parsePreview(chat.last_message || ''); const preview = parsePreview(chat.last_message || '');
return ( return (
<ListItemButton <ListItemButton
key={chat.uuid || chat.id} key={chat.uuid || chat.id}
selected={selected} selected={selected}
onClick={() => onSelect(chat)} onClick={() => onSelect(chat)}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
mb: 0.5, mb: 0.5,
minWidth: 0, minWidth: 0,
'&.Mui-selected': { '&.Mui-selected': {
backgroundColor: 'rgba(116, 68, 253, 0.12)', backgroundColor: 'rgba(116, 68, 253, 0.12)',
}, },
}} }}
> >
<Box sx={{ position: 'relative', mr: 1.25, width: 36, height: 36, flex: '0 0 36px' }}> <Box sx={{ position: 'relative', mr: 1.25, width: 36, height: 36, flex: '0 0 36px' }}>
<Avatar <Avatar
src={(chat as any).avatar_url || undefined} src={(chat as any).avatar_url || undefined}
alt={chat.participant_name || 'Аватар'} alt={chat.participant_name || 'Аватар'}
sx={{ sx={{
width: 36, width: 36,
height: 36, height: 36,
bgcolor: 'rgba(116, 68, 253, 0.18)', bgcolor: 'rgba(116, 68, 253, 0.18)',
color: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-primary)',
fontWeight: 800, fontWeight: 800,
fontSize: 13, fontSize: 13,
}} }}
> >
{(chat.participant_name || 'Ч') {(chat.participant_name || 'Ч')
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.slice(0, 2) .slice(0, 2)
.map((p) => p[0]) .map((p) => p[0])
.join('') .join('')
.toUpperCase()} .toUpperCase()}
</Avatar> </Avatar>
{!!(chat as any).other_is_online && ( {!!(chat as any).other_is_online && (
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
right: -1, right: -1,
bottom: -1, bottom: -1,
width: 10, width: 10,
height: 10, height: 10,
borderRadius: '999px', borderRadius: '999px',
backgroundColor: '#22c55e', backgroundColor: '#22c55e',
border: '2px solid rgba(255,255,255,0.9)', border: '2px solid rgba(255,255,255,0.9)',
boxShadow: '0 0 0 1px rgba(0,0,0,0.06)', boxShadow: '0 0 0 1px rgba(0,0,0,0.06)',
}} }}
/> />
)} )}
</Box> </Box>
<ListItemText <ListItemText
primary={ primary={
<Box <Box
component="span" component="span"
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
> >
<Box <Box
component="span" component="span"
sx={{ fontWeight: 700, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }} sx={{ fontWeight: 700, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}
> >
{chat.participant_name || 'Чат'} {chat.participant_name || 'Чат'}
</Box> </Box>
{!!chat.unread_count && chat.unread_count > 0 && ( {!!chat.unread_count && chat.unread_count > 0 && (
<Badge <Badge
color="primary" color="primary"
badgeContent={chat.unread_count} badgeContent={chat.unread_count}
sx={{ sx={{
'& .MuiBadge-badge': { '& .MuiBadge-badge': {
backgroundColor: 'var(--md-sys-color-primary)', backgroundColor: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)', color: 'var(--md-sys-color-on-primary)',
}, },
}} }}
/> />
)} )}
</Box> </Box>
} }
secondary={ secondary={
<Box <Box
component="span" component="span"
sx={{ sx={{
fontSize: 12, fontSize: 12,
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
pr: 1, pr: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.75, gap: 0.75,
}} }}
> >
{preview.icons.includes('bell') && ( {preview.icons.includes('bell') && (
<NotificationsActiveRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} /> <NotificationsActiveRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} />
)} )}
{preview.icons.includes('trash') && ( {preview.icons.includes('trash') && (
<DeleteRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }} /> <DeleteRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }} />
)} )}
{preview.icons.includes('note') && ( {preview.icons.includes('note') && (
<AssignmentRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} /> <AssignmentRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} />
)} )}
<Box <Box
component="span" component="span"
sx={{ sx={{
minWidth: 0, minWidth: 0,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{preview.text} {preview.text}
</Box> </Box>
</Box> </Box>
} }
sx={{ minWidth: 0 }} sx={{ minWidth: 0 }}
primaryTypographyProps={{ component: 'span' }} primaryTypographyProps={{ component: 'span' }}
secondaryTypographyProps={{ component: 'span' }} secondaryTypographyProps={{ component: 'span' }}
/> />
</ListItemButton> </ListItemButton>
); );
})} })}
</List> </List>
{hasMore && onLoadMore && ( {hasMore && onLoadMore && (
<Box sx={{ py: 1.5, display: 'flex', justifyContent: 'center' }}> <Box sx={{ py: 1.5, display: 'flex', justifyContent: 'center' }}>
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
onClick={onLoadMore} onClick={onLoadMore}
disabled={loadingMore} disabled={loadingMore}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: 'none',
borderColor: 'var(--md-sys-color-outline-variant)', borderColor: 'var(--md-sys-color-outline-variant)',
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
}} }}
> >
{loadingMore ? 'Загрузка…' : 'Загрузить ещё'} {loadingMore ? 'Загрузка…' : 'Загрузить ещё'}
</Button> </Button>
</Box> </Box>
)} )}
</Box> </Box>
<NewChatModal <NewChatModal
isOpen={isNewChatModalOpen} isOpen={isNewChatModalOpen}
onClose={() => setIsNewChatModalOpen(false)} onClose={() => setIsNewChatModalOpen(false)}
onChatCreated={(chat) => { onChatCreated={(chat) => {
onSelect(chat); onSelect(chat);
// Можно добавить уведомление // Можно добавить уведомление
}} }}
/> />
</Box> </Box>
); );
} }

View File

@ -9,10 +9,18 @@ const sizeMap = {
large: '64px', large: '64px',
} as const; } as const;
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large';
inline?: boolean;
/** Занимает всё доступное пространство страницы - предотвращает layout shift */
fullPage?: boolean;
}
export function LoadingSpinner({ export function LoadingSpinner({
size = 'medium', size = 'medium',
inline = false, inline = false,
}: { size?: 'small' | 'medium' | 'large'; inline?: boolean }) { fullPage = false,
}: LoadingSpinnerProps) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [componentsLoaded, setComponentsLoaded] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false);
@ -23,6 +31,18 @@ export function LoadingSpinner({
}); });
}, []); }, []);
// Стили для fullPage режима - занимает всё пространство, предотвращает дёрганье
const fullPageStyle: React.CSSProperties = fullPage ? {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
minHeight: '100%',
flex: 1,
background: 'var(--md-sys-color-background)',
} : {};
if (!mounted || !componentsLoaded) { if (!mounted || !componentsLoaded) {
if (inline) { if (inline) {
return ( return (
@ -34,6 +54,18 @@ export function LoadingSpinner({
</span> </span>
); );
} }
if (fullPage) {
return (
<div style={fullPageStyle}>
<span
className="material-symbols-outlined"
style={{ fontSize: sizeMap[size], animation: 'lk-spin 1s linear infinite', color: 'var(--md-sys-color-primary)' }}
>
progress_activity
</span>
</div>
);
}
return <div>Загрузка...</div>; return <div>Загрузка...</div>;
} }
@ -43,6 +75,7 @@ export function LoadingSpinner({
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
...(fullPage ? fullPageStyle : {}),
...(inline ? { padding: 0, width: sizeMap[size], height: sizeMap[size] } : { padding: '20px' }), ...(inline ? { padding: 0, width: sizeMap[size], height: sizeMap[size] } : { padding: '20px' }),
}} }}
> >

View File

@ -41,10 +41,11 @@ export const LessonCard: React.FC<LessonCardProps> = ({
const [connectLoading, setConnectLoading] = useState(false); const [connectLoading, setConnectLoading] = useState(false);
const [canJoin, setCanJoin] = useState(false); const [canJoin, setCanJoin] = useState(false);
// Проверяем каждые 10 секунд, чтобы кнопка появилась вовремя (решение проблемы с кэшированием)
useEffect(() => { useEffect(() => {
const check = () => setCanJoin(canJoinLesson(lesson)); const check = () => setCanJoin(canJoinLesson(lesson));
check(); check();
const interval = setInterval(check, 60000); const interval = setInterval(check, 10000); // 10 секунд
return () => clearInterval(interval); return () => clearInterval(interval);
}, [lesson.start_time, lesson.end_time, lesson.status]); }, [lesson.start_time, lesson.end_time, lesson.status]);

View File

@ -1,167 +1,167 @@
/** /**
* Секция «Статистика» (список) для дашборда ментора (iOS 26). * Секция «Статистика» (список) для дашборда ментора (iOS 26).
*/ */
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { MentorDashboardResponse } from '@/api/dashboard'; import { MentorDashboardResponse } from '@/api/dashboard';
import { Panel, SectionHeader } from '../ui'; import { Panel, SectionHeader } from '../ui';
import type { StatsListRow } from '../ui'; import type { StatsListRow } from '../ui';
export interface ExtraStatsSectionProps { export interface ExtraStatsSectionProps {
stats: MentorDashboardResponse | null; stats: MentorDashboardResponse | null;
loading: boolean; loading: boolean;
} }
const IconUsers = ( const IconUsers = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" /> <circle cx="9" cy="7" r="4" />
</svg> </svg>
); );
const IconCheck = ( const IconCheck = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
); );
const IconCalendar = ( const IconCalendar = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" /> <line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" /> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
); );
const IconRevenue = ( const IconRevenue = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="1" x2="12" y2="23" /> <line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /> <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg> </svg>
); );
const IconFile = ( const IconFile = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /> <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" /> <polyline points="13 2 13 9 20 9" />
</svg> </svg>
); );
const IconClipboard = ( const IconClipboard = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 11l3 3L22 4" /> <path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /> <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg> </svg>
); );
const IconTrendingUp = ( const IconTrendingUp = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" /> <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" /> <polyline points="17 6 23 6 23 12" />
</svg> </svg>
); );
function buildRows(stats: MentorDashboardResponse | null, loading: boolean): StatsListRow[] { function buildRows(stats: MentorDashboardResponse | null, loading: boolean): StatsListRow[] {
const s = stats?.summary; const s = stats?.summary;
if (loading || !s) { if (loading || !s) {
return [ return [
{ label: 'Всего учеников:', value: '—', icon: IconUsers }, { label: 'Всего учеников:', value: '—', icon: IconUsers },
{ label: 'Завершённых занятий всего:', value: '—', icon: IconCheck }, { label: 'Завершённых занятий всего:', value: '—', icon: IconCheck },
{ label: 'Занятий на этой неделе:', value: '—', icon: IconCalendar }, { label: 'Занятий на этой неделе:', value: '—', icon: IconCalendar },
{ label: 'Всего занятий (месяц / всего):', value: '—', icon: IconCalendar }, { label: 'Всего занятий (месяц / всего):', value: '—', icon: IconCalendar },
{ label: 'Доход (месяц / всё время):', value: '—', icon: IconRevenue, highlight: 'tertiary' }, { label: 'Доход (месяц / всё время):', value: '—', icon: IconRevenue, highlight: 'tertiary' },
{ label: 'Средняя цена занятия:', value: '—', icon: IconTrendingUp }, { label: 'Средняя цена занятия:', value: '—', icon: IconTrendingUp },
{ label: 'Процент завершённых:', value: '—', icon: IconTrendingUp }, { label: 'Процент завершённых:', value: '—', icon: IconTrendingUp },
{ label: 'ДЗ на проверке:', value: '—', icon: IconClipboard }, { label: 'ДЗ на проверке:', value: '—', icon: IconClipboard },
{ label: 'Всего материалов:', value: '—', icon: IconFile }, { label: 'Всего материалов:', value: '—', icon: IconFile },
]; ];
} }
// Вычисляем среднюю цену занятия // Вычисляем среднюю цену занятия
const averagePrice = const averagePrice =
s.completed_lessons && s.completed_lessons > 0 && s.total_revenue s.completed_lessons && s.completed_lessons > 0 && s.total_revenue
? Math.round(s.total_revenue / s.completed_lessons) ? Math.round(s.total_revenue / s.completed_lessons)
: null; : null;
// Вычисляем процент завершенных занятий // Вычисляем процент завершенных занятий
const completionRate = const completionRate =
s.total_lessons && s.total_lessons > 0 && s.completed_lessons s.total_lessons && s.total_lessons > 0 && s.completed_lessons
? Math.round((s.completed_lessons / s.total_lessons) * 100) ? Math.round((s.completed_lessons / s.total_lessons) * 100)
: null; : null;
return [ return [
{ label: 'Всего учеников:', value: s.total_clients ?? '—', icon: IconUsers }, { label: 'Всего учеников:', value: s.total_clients ?? '—', icon: IconUsers },
{ label: 'Завершённых занятий всего:', value: s.completed_lessons ?? '—', icon: IconCheck }, { label: 'Завершённых занятий всего:', value: s.completed_lessons ?? '—', icon: IconCheck },
{ label: 'Занятий на этой неделе:', value: s.lessons_this_week ?? '—', icon: IconCalendar }, { label: 'Занятий на этой неделе:', value: s.lessons_this_week ?? '—', icon: IconCalendar },
{ {
label: 'Всего занятий (месяц / всего):', label: 'Всего занятий (месяц / всего):',
value: `${s.lessons_this_month ?? 0}/${s.total_lessons ?? 0}`, value: `${s.lessons_this_month ?? 0}/${s.total_lessons ?? 0}`,
icon: IconCalendar, icon: IconCalendar,
}, },
{ {
label: 'Доход (месяц / всё время):', label: 'Доход (месяц / всё время):',
value: value:
s.revenue_this_month != null || s.total_revenue != null s.revenue_this_month != null || s.total_revenue != null
? `${s.revenue_this_month != null ? `${Math.round(s.revenue_this_month).toLocaleString('ru-RU')}` : '0 ₽'} / ${ ? `${s.revenue_this_month != null ? `${Math.round(s.revenue_this_month).toLocaleString('ru-RU')}` : '0 ₽'} / ${
s.total_revenue != null ? `${Math.round(s.total_revenue).toLocaleString('ru-RU')}` : '0 ₽' s.total_revenue != null ? `${Math.round(s.total_revenue).toLocaleString('ru-RU')}` : '0 ₽'
}` }`
: '—', : '—',
icon: IconRevenue, icon: IconRevenue,
highlight: 'tertiary', highlight: 'tertiary',
}, },
{ {
label: 'Средняя цена занятия:', label: 'Средняя цена занятия:',
value: averagePrice ? `${averagePrice.toLocaleString('ru-RU')}` : '—', value: averagePrice ? `${averagePrice.toLocaleString('ru-RU')}` : '—',
icon: IconTrendingUp, icon: IconTrendingUp,
}, },
{ {
label: 'Процент завершённых:', label: 'Процент завершённых:',
value: completionRate != null ? `${completionRate}%` : '—', value: completionRate != null ? `${completionRate}%` : '—',
icon: IconTrendingUp, icon: IconTrendingUp,
}, },
{ {
label: 'ДЗ на проверке:', label: 'ДЗ на проверке:',
value: s.pending_submissions ?? '—', value: s.pending_submissions ?? '—',
icon: IconClipboard, icon: IconClipboard,
highlight: s.pending_submissions && s.pending_submissions > 0 ? 'error' : undefined, highlight: s.pending_submissions && s.pending_submissions > 0 ? 'error' : undefined,
}, },
{ {
label: 'Всего материалов:', label: 'Всего материалов:',
value: s.total_materials ?? '—', value: s.total_materials ?? '—',
icon: IconFile, icon: IconFile,
}, },
]; ];
} }
export const ExtraStatsSection: React.FC<ExtraStatsSectionProps> = ({ stats, loading }) => { export const ExtraStatsSection: React.FC<ExtraStatsSectionProps> = ({ stats, loading }) => {
const rows = buildRows(stats, loading).slice(0, 9); const rows = buildRows(stats, loading).slice(0, 9);
return ( return (
<Panel padding="md" data-tour="mentor-extrastats"> <Panel padding="md" data-tour="mentor-extrastats">
<SectionHeader title="Статистика" /> <SectionHeader title="Статистика" />
<div className="ios26-stat-grid"> <div className="ios26-stat-grid">
{rows.map((row, index) => { {rows.map((row, index) => {
const highlightClass = const highlightClass =
row.highlight === 'tertiary' row.highlight === 'tertiary'
? ' ios26-stat-value--tertiary' ? ' ios26-stat-value--tertiary'
: row.highlight === 'error' : row.highlight === 'error'
? ' ios26-stat-value--error' ? ' ios26-stat-value--error'
: row.highlight === 'primary' || row.highlight === true : row.highlight === 'primary' || row.highlight === true
? ' ios26-stat-value--primary' ? ' ios26-stat-value--primary'
: ''; : '';
return ( return (
<div key={index} className="ios26-stat-tile"> <div key={index} className="ios26-stat-tile">
{row.icon && <div className="ios26-stat-icon">{row.icon}</div>} {row.icon && <div className="ios26-stat-icon">{row.icon}</div>}
<div className="ios26-stat-label">{row.label.replace(/:$/, '')}</div> <div className="ios26-stat-label">{row.label.replace(/:$/, '')}</div>
<div className={`ios26-stat-value${highlightClass}`}>{row.value}</div> <div className={`ios26-stat-value${highlightClass}`}>{row.value}</div>
</div> </div>
); );
})} })}
</div> </div>
</Panel> </Panel>
); );
}; };

View File

@ -1,24 +1,24 @@
/** /**
* Общая обёртка страницы дашборда. * Общая обёртка страницы дашборда.
* Переиспользуется для ментора, клиента, родителя и др. ролей. * Переиспользуется для ментора, клиента, родителя и др. ролей.
*/ */
'use client'; 'use client';
import React from 'react'; import React from 'react';
export interface DashboardLayoutProps { export interface DashboardLayoutProps {
children: React.ReactNode; children: React.ReactNode;
/** Дополнительный класс для контейнера */ /** Дополнительный класс для контейнера */
className?: string; className?: string;
/** data-tour для онбординга */ /** data-tour для онбординга */
'data-tour'?: string; 'data-tour'?: string;
} }
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, className = '', 'data-tour': dataTour }) => { export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, className = '', 'data-tour': dataTour }) => {
return ( return (
<div className={`ios26-dashboard ${className}`.trim()} data-tour={dataTour}> <div className={`ios26-dashboard ${className}`.trim()} data-tour={dataTour}>
{children} {children}
</div> </div>
); );
}; };

View File

@ -1,51 +1,51 @@
/** /**
* Стеклянная панель в стиле iOS 26. * Стеклянная панель в стиле iOS 26.
* Переиспользуется для блоков статистики, графиков, списков. * Переиспользуется для блоков статистики, графиков, списков.
*/ */
'use client'; 'use client';
import React from 'react'; import React from 'react';
export interface PanelProps { export interface PanelProps {
children: React.ReactNode; children: React.ReactNode;
/** Дополнительный класс */ /** Дополнительный класс */
className?: string; className?: string;
/** Включить hover-эффект (тень) */ /** Включить hover-эффект (тень) */
interactive?: boolean; interactive?: boolean;
/** Внутренние отступы. По умолчанию 24px */ /** Внутренние отступы. По умолчанию 24px */
padding?: 'none' | 'sm' | 'md' | 'lg'; padding?: 'none' | 'sm' | 'md' | 'lg';
style?: React.CSSProperties; style?: React.CSSProperties;
/** Атрибут для онбординга (data-tour) */ /** Атрибут для онбординга (data-tour) */
'data-tour'?: string; 'data-tour'?: string;
} }
const paddingMap = { const paddingMap = {
none: 0, none: 0,
sm: 16, sm: 16,
md: 24, md: 24,
lg: 32, lg: 32,
}; };
export const Panel: React.FC<PanelProps> = ({ export const Panel: React.FC<PanelProps> = ({
children, children,
className = '', className = '',
interactive = false, interactive = false,
padding = 'md', padding = 'md',
style, style,
'data-tour': dataTour, 'data-tour': dataTour,
}) => { }) => {
const p = paddingMap[padding]; const p = paddingMap[padding];
return ( return (
<div <div
data-tour={dataTour} data-tour={dataTour}
className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()} className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()}
style={{ style={{
padding: p ? `${p}px` : 0, padding: p ? `${p}px` : 0,
...style, ...style,
}} }}
> >
{children} {children}
</div> </div>
); );
}; };

View File

@ -1,291 +1,291 @@
'use client'; 'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useSelectedChild } from '@/contexts/SelectedChildContext'; import { useSelectedChild } from '@/contexts/SelectedChildContext';
import { import {
getHomework, getHomework,
getHomeworkById, getHomeworkById,
type Homework, type Homework,
} from '@/api/homework'; } from '@/api/homework';
import { HomeworkDetailsModal } from './HomeworkDetailsModal'; import { HomeworkDetailsModal } from './HomeworkDetailsModal';
import { SubmitHomeworkModal } from './SubmitHomeworkModal'; import { SubmitHomeworkModal } from './SubmitHomeworkModal';
import { DashboardLayout } from '@/components/dashboard/ui'; import { DashboardLayout } from '@/components/dashboard/ui';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
function formatDate(s: string | null): string { function formatDate(s: string | null): string {
if (!s) return '—'; if (!s) return '—';
const d = new Date(s); const d = new Date(s);
return isNaN(d.getTime()) ? '—' : d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); return isNaN(d.getTime()) ? '—' : d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
} }
function formatTime(s: string | null): string { function formatTime(s: string | null): string {
if (!s) return '—'; if (!s) return '—';
const d = new Date(s); const d = new Date(s);
return isNaN(d.getTime()) ? '—' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); return isNaN(d.getTime()) ? '—' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} }
function getHomeworkStatus(hw: Homework): 'pending' | 'submitted' | 'returned' | 'reviewed' { function getHomeworkStatus(hw: Homework): 'pending' | 'submitted' | 'returned' | 'reviewed' {
if (hw.status !== 'published') return 'pending'; if (hw.status !== 'published') return 'pending';
if (hw.checked_submissions > 0 && hw.checked_submissions === hw.total_submissions) return 'reviewed'; if (hw.checked_submissions > 0 && hw.checked_submissions === hw.total_submissions) return 'reviewed';
if (hw.returned_submissions > 0 && hw.returned_submissions === hw.total_submissions) return 'returned'; if (hw.returned_submissions > 0 && hw.returned_submissions === hw.total_submissions) return 'returned';
if (hw.total_submissions > 0) return 'submitted'; if (hw.total_submissions > 0) return 'submitted';
return 'pending'; return 'pending';
} }
export function HomeworkPageContent() { export function HomeworkPageContent() {
const { user } = useAuth(); const { user } = useAuth();
const { selectedChild } = useSelectedChild(); const { selectedChild } = useSelectedChild();
const [homework, setHomework] = useState<Homework[]>([]); const [homework, setHomework] = useState<Homework[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedHomework, setSelectedHomework] = useState<Homework | null>(null); const [selectedHomework, setSelectedHomework] = useState<Homework | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const [detailsLoading, setDetailsLoading] = useState(false); const [detailsLoading, setDetailsLoading] = useState(false);
const [submitId, setSubmitId] = useState<number | null>(null); const [submitId, setSubmitId] = useState<number | null>(null);
const [submitOpen, setSubmitOpen] = useState(false); const [submitOpen, setSubmitOpen] = useState(false);
const loadHomework = useCallback(async () => { const loadHomework = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const res = await getHomework({ const res = await getHomework({
page_size: 1000, page_size: 1000,
...(user?.role === 'parent' && selectedChild?.id && { child_id: selectedChild.id }), ...(user?.role === 'parent' && selectedChild?.id && { child_id: selectedChild.id }),
}); });
setHomework(res.results); setHomework(res.results);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки'); setError(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [user?.role, selectedChild?.id]); }, [user?.role, selectedChild?.id]);
useEffect(() => { useEffect(() => {
loadHomework(); loadHomework();
}, [loadHomework]); }, [loadHomework]);
const userRole = user?.role ?? ''; const userRole = user?.role ?? '';
const pending = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'pending' && hw.status === 'published'), [homework]); const pending = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'pending' && hw.status === 'published'), [homework]);
const submitted = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'submitted'), [homework]); const submitted = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'submitted'), [homework]);
const returned = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'returned'), [homework]); const returned = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'returned'), [homework]);
const reviewed = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'reviewed'), [homework]); const reviewed = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'reviewed'), [homework]);
/** Только для ментора: черновики «заполнить позже» — ожидают заполнения задания. */ /** Только для ментора: черновики «заполнить позже» — ожидают заполнения задания. */
const fillLater = useMemo( const fillLater = useMemo(
() => (userRole === 'mentor' ? homework.filter((hw) => hw.fill_later === true) : []), () => (userRole === 'mentor' ? homework.filter((hw) => hw.fill_later === true) : []),
[homework, userRole] [homework, userRole]
); );
/** Только для ментора: задания, у которых есть хотя бы одно решение с черновиком от ИИ. */ /** Только для ментора: задания, у которых есть хотя бы одно решение с черновиком от ИИ. */
const aiDraft = useMemo( const aiDraft = useMemo(
() => (userRole === 'mentor' ? homework.filter((hw) => (hw.ai_draft_count ?? 0) > 0) : []), () => (userRole === 'mentor' ? homework.filter((hw) => (hw.ai_draft_count ?? 0) > 0) : []),
[homework, userRole] [homework, userRole]
); );
const handleViewDetails = useCallback(async (hw: Homework) => { const handleViewDetails = useCallback(async (hw: Homework) => {
try { try {
setDetailsLoading(true); setDetailsLoading(true);
const full = await getHomeworkById(hw.id); const full = await getHomeworkById(hw.id);
setSelectedHomework(full); setSelectedHomework(full);
setDetailsOpen(true); setDetailsOpen(true);
} catch { } catch {
setSelectedHomework(hw); setSelectedHomework(hw);
setDetailsOpen(true); setDetailsOpen(true);
} finally { } finally {
setDetailsLoading(false); setDetailsLoading(false);
} }
}, []); }, []);
const handleSubmit = useCallback((hw: Homework) => { const handleSubmit = useCallback((hw: Homework) => {
setSubmitId(hw.id); setSubmitId(hw.id);
setSubmitOpen(true); setSubmitOpen(true);
}, []); }, []);
const HomeworkCard = ({ const HomeworkCard = ({
hw, hw,
badge, badge,
onView, onView,
onSubmit, onSubmit,
}: { }: {
hw: Homework; hw: Homework;
badge: string; badge: string;
onView: () => void; onView: () => void;
onSubmit?: () => void; onSubmit?: () => void;
}) => ( }) => (
<div <div
className="ios26-panel" className="ios26-panel"
style={{ padding: 16, cursor: 'pointer' }} style={{ padding: 16, cursor: 'pointer' }}
onClick={onView} onClick={onView}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
fontSize: 11, fontSize: 11,
fontWeight: 600, fontWeight: 600,
padding: '4px 10px', padding: '4px 10px',
borderRadius: 8, borderRadius: 8,
background: hw.is_overdue ? 'rgba(186,26,26,0.15)' : 'var(--md-sys-color-primary-container)', background: hw.is_overdue ? 'rgba(186,26,26,0.15)' : 'var(--md-sys-color-primary-container)',
color: hw.is_overdue ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)', color: hw.is_overdue ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)',
}} }}
> >
{badge} {badge}
</span> </span>
</div> </div>
<h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}> <h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}>
{hw.title} {hw.title}
</h4> </h4>
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}> <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
{userRole === 'client' && ( {userRole === 'client' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span>
{hw.mentor.first_name} {hw.mentor.last_name} {hw.mentor.first_name} {hw.mentor.last_name}
</div> </div>
)} )}
{userRole === 'mentor' && hw.total_submissions > 0 && ( {userRole === 'mentor' && hw.total_submissions > 0 && (
<div style={{ marginBottom: 4 }}>Решений: {hw.total_submissions}</div> <div style={{ marginBottom: 4 }}>Решений: {hw.total_submissions}</div>
)} )}
{hw.deadline && ( {hw.deadline && (
<> <>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span>
{formatDate(hw.deadline)} {formatTime(hw.deadline)} {formatDate(hw.deadline)} {formatTime(hw.deadline)}
</div> </div>
</> </>
)} )}
{hw.student_score?.score != null && ( {hw.student_score?.score != null && (
<div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}> <div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
Оценка: {hw.student_score.score} / 5 Оценка: {hw.student_score.score} / 5
</div> </div>
)} )}
</div> </div>
{userRole === 'client' && onSubmit && getHomeworkStatus(hw) === 'pending' && ( {userRole === 'client' && onSubmit && getHomeworkStatus(hw) === 'pending' && (
<button <button
type="button" type="button"
style={{ style={{
marginTop: 12, marginTop: 12,
width: '100%', width: '100%',
padding: '10px 16px', padding: '10px 16px',
borderRadius: 12, borderRadius: 12,
border: 'none', border: 'none',
background: 'var(--md-sys-color-primary)', background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)', color: 'var(--md-sys-color-on-primary)',
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSubmit(); onSubmit();
}} }}
> >
Сдать ДЗ Сдать ДЗ
</button> </button>
)} )}
</div> </div>
); );
const Column = ({ const Column = ({
title, title,
count, count,
items, items,
getBadge, getBadge,
}: { }: {
title: string; title: string;
count: number; count: number;
items: Homework[]; items: Homework[];
getBadge: (hw: Homework) => string; getBadge: (hw: Homework) => string;
}) => ( }) => (
<div className="ios26-feedback-column"> <div className="ios26-feedback-column">
<h3 className="ios26-feedback-column__title"> <h3 className="ios26-feedback-column__title">
{title} {count > 0 ? `(${count})` : ''} {title} {count > 0 ? `(${count})` : ''}
</h3> </h3>
<div className="ios26-feedback-column__cards"> <div className="ios26-feedback-column__cards">
{items.map((hw) => ( {items.map((hw) => (
<HomeworkCard <HomeworkCard
key={hw.id} key={hw.id}
hw={hw} hw={hw}
badge={getBadge(hw)} badge={getBadge(hw)}
onView={() => handleViewDetails(hw)} onView={() => handleViewDetails(hw)}
onSubmit={userRole === 'client' ? () => handleSubmit(hw) : undefined} onSubmit={userRole === 'client' ? () => handleSubmit(hw) : undefined}
/> />
))} ))}
{items.length === 0 && ( {items.length === 0 && (
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}> <p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
Нет заданий Нет заданий
</p> </p>
)} )}
</div> </div>
</div> </div>
); );
return ( return (
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="homework-root"> <DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="homework-root">
{error && ( {error && (
<div <div
style={{ style={{
padding: 16, padding: 16,
marginBottom: 16, marginBottom: 16,
background: 'rgba(186,26,26,0.1)', background: 'rgba(186,26,26,0.1)',
borderRadius: 12, borderRadius: 12,
color: 'var(--md-sys-color-error)', color: 'var(--md-sys-color-error)',
}} }}
> >
{error} {error}
</div> </div>
)} )}
{loading ? ( {loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}> <div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />
</div> </div>
) : homework.length === 0 ? ( ) : homework.length === 0 ? (
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)', padding: 24 }}> <p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)', padding: 24 }}>
Нет заданий Нет заданий
</p> </p>
) : ( ) : (
<div className="ios26-feedback-kanban ios26-homework-kanban"> <div className="ios26-feedback-kanban ios26-homework-kanban">
{userRole === 'mentor' && fillLater.length > 0 && ( {userRole === 'mentor' && fillLater.length > 0 && (
<Column title="Ожидают заполнения" count={fillLater.length} items={fillLater} getBadge={() => 'Заполнить позже'} /> <Column title="Ожидают заполнения" count={fillLater.length} items={fillLater} getBadge={() => 'Заполнить позже'} />
)} )}
{pending.length > 0 && ( {pending.length > 0 && (
<Column title="Ожидают" count={pending.length} items={pending} getBadge={(hw) => (hw.is_overdue ? 'Просрочено' : 'Домашнее задание')} /> <Column title="Ожидают" count={pending.length} items={pending} getBadge={(hw) => (hw.is_overdue ? 'Просрочено' : 'Домашнее задание')} />
)} )}
{submitted.length > 0 && ( {submitted.length > 0 && (
<Column title="На проверке" count={submitted.length} items={submitted} getBadge={() => 'На проверке'} /> <Column title="На проверке" count={submitted.length} items={submitted} getBadge={() => 'На проверке'} />
)} )}
{userRole === 'mentor' && aiDraft.length > 0 && ( {userRole === 'mentor' && aiDraft.length > 0 && (
<Column title="Черновик от ИИ" count={aiDraft.length} items={aiDraft} getBadge={() => 'Черновик от ИИ'} /> <Column title="Черновик от ИИ" count={aiDraft.length} items={aiDraft} getBadge={() => 'Черновик от ИИ'} />
)} )}
{returned.length > 0 && ( {returned.length > 0 && (
<Column title="На доработке" count={returned.length} items={returned} getBadge={() => 'На доработке'} /> <Column title="На доработке" count={returned.length} items={returned} getBadge={() => 'На доработке'} />
)} )}
{reviewed.length > 0 && ( {reviewed.length > 0 && (
<Column title="Проверено" count={reviewed.length} items={reviewed} getBadge={() => 'Проверено'} /> <Column title="Проверено" count={reviewed.length} items={reviewed} getBadge={() => 'Проверено'} />
)} )}
</div> </div>
)} )}
<HomeworkDetailsModal <HomeworkDetailsModal
isOpen={detailsOpen} isOpen={detailsOpen}
homework={selectedHomework} homework={selectedHomework}
userRole={userRole} userRole={userRole}
childId={userRole === 'parent' ? selectedChild?.id ?? null : null} childId={userRole === 'parent' ? selectedChild?.id ?? null : null}
onClose={() => { onClose={() => {
setDetailsOpen(false); setDetailsOpen(false);
setSelectedHomework(null); setSelectedHomework(null);
}} }}
onSuccess={loadHomework} onSuccess={loadHomework}
/> />
<SubmitHomeworkModal <SubmitHomeworkModal
isOpen={submitOpen} isOpen={submitOpen}
homeworkId={submitId ?? 0} homeworkId={submitId ?? 0}
onClose={() => { onClose={() => {
setSubmitOpen(false); setSubmitOpen(false);
setSubmitId(null); setSubmitId(null);
}} }}
onSuccess={loadHomework} onSuccess={loadHomework}
/> />
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@ -1,319 +1,319 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useSelectedChild } from '@/contexts/SelectedChildContext'; import { useSelectedChild } from '@/contexts/SelectedChildContext';
import type { ChildStats } from '@/api/dashboard'; import type { ChildStats } from '@/api/dashboard';
function getAvatarUrl(child: ChildStats | null): string | null { function getAvatarUrl(child: ChildStats | null): string | null {
if (!child) return null; if (!child) return null;
const url = child.avatar_url || child.avatar; const url = child.avatar_url || child.avatar;
if (!url) return null; if (!url) return null;
if (typeof url === 'string' && url.startsWith('http')) return url; if (typeof url === 'string' && url.startsWith('http')) return url;
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : ''; const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
return typeof url === 'string' && url.startsWith('/') ? `${base}${url}` : `${base}/${url}`; return typeof url === 'string' && url.startsWith('/') ? `${base}${url}` : `${base}/${url}`;
} }
/** Минималистичный выбор ребёнка слева от нижней навигации */ /** Минималистичный выбор ребёнка слева от нижней навигации */
export function ChildSelectorCompact() { export function ChildSelectorCompact() {
const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild(); const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const onOutside = (e: MouseEvent) => { const onOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}; };
document.addEventListener('mousedown', onOutside); document.addEventListener('mousedown', onOutside);
return () => document.removeEventListener('mousedown', onOutside); return () => document.removeEventListener('mousedown', onOutside);
}, []); }, []);
if (loading && childrenList.length === 0) { if (loading && childrenList.length === 0) {
return ( return (
<div <div
className="ios26-bottom-nav-button" className="ios26-bottom-nav-button"
style={{ width: 44, minWidth: 44, flexShrink: 0 }} style={{ width: 44, minWidth: 44, flexShrink: 0 }}
aria-hidden aria-hidden
> >
<span className="ios26-bottom-nav-icon" style={{ opacity: 0.5 }}>person</span> <span className="ios26-bottom-nav-icon" style={{ opacity: 0.5 }}>person</span>
</div> </div>
); );
} }
if (childrenList.length === 0) return null; if (childrenList.length === 0) return null;
const avatarUrl = getAvatarUrl(selectedChild); const avatarUrl = getAvatarUrl(selectedChild);
const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?'; const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?';
return ( return (
<div ref={ref} data-tour="parent-child-selector" style={{ position: 'relative', flexShrink: 0 }}> <div ref={ref} data-tour="parent-child-selector" style={{ position: 'relative', flexShrink: 0 }}>
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className="ios26-bottom-nav-button" className="ios26-bottom-nav-button"
style={{ style={{
width: 44, width: 44,
minWidth: 44, minWidth: 44,
padding: 6, padding: 6,
}} }}
aria-label={selectedChild ? `Студент: ${selectedChild.name}` : 'Выбрать студента'} aria-label={selectedChild ? `Студент: ${selectedChild.name}` : 'Выбрать студента'}
> >
<span <span
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
borderRadius: '50%', borderRadius: '50%',
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: 'var(--md-sys-color-primary-container)', background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-primary)',
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
}} }}
> >
{avatarUrl ? ( {avatarUrl ? (
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : ( ) : (
initial initial
)} )}
</span> </span>
<span className="ios26-bottom-nav-label" style={{ fontSize: 9 }}>Студент</span> <span className="ios26-bottom-nav-label" style={{ fontSize: 9 }}>Студент</span>
</button> </button>
{open && ( {open && (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
bottom: '100%', bottom: '100%',
left: 0, left: 0,
marginBottom: 6, marginBottom: 6,
minWidth: 160, minWidth: 160,
maxWidth: 220, maxWidth: 220,
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: 12, borderRadius: 12,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
boxShadow: '0 4px 20px rgba(0,0,0,0.15)', boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
zIndex: 10052, zIndex: 10052,
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{childrenList.map((child) => { {childrenList.map((child) => {
const isSelected = selectedChild?.id === child.id; const isSelected = selectedChild?.id === child.id;
const url = getAvatarUrl(child); const url = getAvatarUrl(child);
return ( return (
<button <button
key={child.id} key={child.id}
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedChild(child); setSelectedChild(child);
setOpen(false); setOpen(false);
}} }}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 14px',
border: 'none', border: 'none',
background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent', background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
textAlign: 'left', textAlign: 'left',
}} }}
> >
<span <span
style={{ style={{
width: 28, width: 28,
height: 28, height: 28,
borderRadius: '50%', borderRadius: '50%',
overflow: 'hidden', overflow: 'hidden',
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: 'var(--md-sys-color-surface-variant)', background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
}} }}
> >
{url ? ( {url ? (
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : ( ) : (
child.name.charAt(0).toUpperCase() || '?' child.name.charAt(0).toUpperCase() || '?'
)} )}
</span> </span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{child.name}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{child.name}</span>
</button> </button>
); );
})} })}
</div> </div>
)} )}
</div> </div>
); );
} }
export function ChildSelector() { export function ChildSelector() {
const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild(); const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const onOutside = (e: MouseEvent) => { const onOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}; };
document.addEventListener('mousedown', onOutside); document.addEventListener('mousedown', onOutside);
return () => document.removeEventListener('mousedown', onOutside); return () => document.removeEventListener('mousedown', onOutside);
}, []); }, []);
if (loading && childrenList.length === 0) { if (loading && childrenList.length === 0) {
return ( return (
<div style={{ <div style={{
padding: '10px 16px', padding: '10px 16px',
background: 'var(--md-sys-color-surface-container)', background: 'var(--md-sys-color-surface-container)',
borderBottom: '1px solid var(--md-sys-color-outline-variant)', borderBottom: '1px solid var(--md-sys-color-outline-variant)',
fontSize: 14, fontSize: 14,
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
}}> }}>
Загрузка... Загрузка...
</div> </div>
); );
} }
if (childrenList.length === 0) return null; if (childrenList.length === 0) return null;
const avatarUrl = getAvatarUrl(selectedChild); const avatarUrl = getAvatarUrl(selectedChild);
const displayName = selectedChild?.name ?? 'Выберите ребёнка'; const displayName = selectedChild?.name ?? 'Выберите ребёнка';
return ( return (
<div <div
ref={ref} ref={ref}
style={{ style={{
padding: '10px 16px', padding: '10px 16px',
background: 'var(--md-sys-color-surface-container)', background: 'var(--md-sys-color-surface-container)',
borderBottom: '1px solid var(--md-sys-color-outline-variant)', borderBottom: '1px solid var(--md-sys-color-outline-variant)',
position: 'relative', position: 'relative',
zIndex: 10050, zIndex: 10050,
}} }}
> >
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
width: '100%', width: '100%',
padding: '8px 12px', padding: '8px 12px',
borderRadius: 12, borderRadius: 12,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
fontSize: 15, fontSize: 15,
fontWeight: 500, fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
textAlign: 'left', textAlign: 'left',
}} }}
> >
<span <span
style={{ style={{
width: 36, width: 36,
height: 36, height: 36,
borderRadius: '50%', borderRadius: '50%',
overflow: 'hidden', overflow: 'hidden',
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: 'var(--md-sys-color-primary-container)', background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-primary)',
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
}} }}
> >
{avatarUrl ? ( {avatarUrl ? (
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : ( ) : (
displayName.charAt(0).toUpperCase() || '?' displayName.charAt(0).toUpperCase() || '?'
)} )}
</span> </span>
<span style={{ flex: 1 }}>{displayName}</span> <span style={{ flex: 1 }}>{displayName}</span>
<span className="material-symbols-outlined" style={{ fontSize: 20, opacity: 0.7 }}> <span className="material-symbols-outlined" style={{ fontSize: 20, opacity: 0.7 }}>
{open ? 'expand_less' : 'expand_more'} {open ? 'expand_less' : 'expand_more'}
</span> </span>
</button> </button>
{open && ( {open && (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
left: 16, left: 16,
right: 16, right: 16,
marginTop: 4, marginTop: 4,
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: 12, borderRadius: 12,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 10051, zIndex: 10051,
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{childrenList.map((child) => { {childrenList.map((child) => {
const isSelected = selectedChild?.id === child.id; const isSelected = selectedChild?.id === child.id;
const url = getAvatarUrl(child); const url = getAvatarUrl(child);
return ( return (
<button <button
key={child.id} key={child.id}
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedChild(child); setSelectedChild(child);
setOpen(false); setOpen(false);
}} }}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
width: '100%', width: '100%',
padding: '12px 16px', padding: '12px 16px',
border: 'none', border: 'none',
background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent', background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
fontSize: 15, fontSize: 15,
cursor: 'pointer', cursor: 'pointer',
textAlign: 'left', textAlign: 'left',
}} }}
> >
<span <span
style={{ style={{
width: 32, width: 32,
height: 32, height: 32,
borderRadius: '50%', borderRadius: '50%',
overflow: 'hidden', overflow: 'hidden',
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: 'var(--md-sys-color-surface-variant)', background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
}} }}
> >
{url ? ( {url ? (
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : ( ) : (
child.name.charAt(0).toUpperCase() || '?' child.name.charAt(0).toUpperCase() || '?'
)} )}
</span> </span>
{child.name} {child.name}
</button> </button>
); );
})} })}
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@ -1,149 +1,149 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useOnboarding } from '@/contexts/OnboardingContext'; import { useOnboarding } from '@/contexts/OnboardingContext';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getOnboardingKey } from '@/lib/onboarding-steps'; import { getOnboardingKey } from '@/lib/onboarding-steps';
const PAGE_LABELS: Record<string, string> = { const PAGE_LABELS: Record<string, string> = {
dashboard: 'Главная', dashboard: 'Главная',
schedule: 'Расписание', schedule: 'Расписание',
students: 'Студенты', students: 'Студенты',
materials: 'Материалы', materials: 'Материалы',
homework: 'Домашние задания', homework: 'Домашние задания',
feedback: 'Обратная связь', feedback: 'Обратная связь',
analytics: 'Аналитика', analytics: 'Аналитика',
payment: 'Тарифы', payment: 'Тарифы',
referrals: 'Рефералы', referrals: 'Рефералы',
profile: 'Профиль', profile: 'Профиль',
chat: 'Чат', chat: 'Чат',
'my-progress': 'Прогресс', 'my-progress': 'Прогресс',
'request-mentor': 'Мои менторы', 'request-mentor': 'Мои менторы',
}; };
const MENTOR_PAGES = ['dashboard', 'schedule', 'students', 'materials', 'homework', 'feedback', 'analytics', 'payment', 'referrals', 'profile']; const MENTOR_PAGES = ['dashboard', 'schedule', 'students', 'materials', 'homework', 'feedback', 'analytics', 'payment', 'referrals', 'profile'];
const CLIENT_PAGES = ['dashboard', 'schedule', 'chat', 'materials', 'homework', 'my-progress', 'request-mentor', 'profile']; const CLIENT_PAGES = ['dashboard', 'schedule', 'chat', 'materials', 'homework', 'my-progress', 'request-mentor', 'profile'];
export function OnboardingTipsSection() { export function OnboardingTipsSection() {
const onboarding = useOnboarding(); const onboarding = useOnboarding();
const { user } = useAuth(); const { user } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [progress, setProgress] = useState({ seen: 0, total: 0 }); const [progress, setProgress] = useState({ seen: 0, total: 0 });
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const role = user?.role === 'mentor' ? 'mentor' : user?.role === 'client' ? 'client' : user?.role === 'parent' ? 'parent' : null; const role = user?.role === 'mentor' ? 'mentor' : user?.role === 'client' ? 'client' : user?.role === 'parent' ? 'parent' : null;
const pages = role === 'mentor' ? MENTOR_PAGES : role === 'client' ? CLIENT_PAGES : MENTOR_PAGES; const pages = role === 'mentor' ? MENTOR_PAGES : role === 'client' ? CLIENT_PAGES : MENTOR_PAGES;
useEffect(() => { useEffect(() => {
if (!onboarding) return; if (!onboarding) return;
onboarding.refreshProgress().then(() => { onboarding.refreshProgress().then(() => {
setProgress(onboarding.getProgress()); setProgress(onboarding.getProgress());
}); });
}, [onboarding, pathname]); }, [onboarding, pathname]);
if (!onboarding || !role) return null; if (!onboarding || !role) return null;
const currentKey = getOnboardingKey(pathname || '', role as 'mentor' | 'client' | 'parent'); const currentKey = getOnboardingKey(pathname || '', role as 'mentor' | 'client' | 'parent');
const handleShowAgain = () => { const handleShowAgain = () => {
if (currentKey) onboarding.runTourManually(currentKey, { force: true }); if (currentKey) onboarding.runTourManually(currentKey, { force: true });
}; };
const handleShowOnPage = (pageKey: string) => { const handleShowOnPage = (pageKey: string) => {
setExpanded(false); setExpanded(false);
const path = pageKey === 'dashboard' ? '/dashboard' : `/${pageKey}`; const path = pageKey === 'dashboard' ? '/dashboard' : `/${pageKey}`;
router.push(path); router.push(path);
setTimeout(() => onboarding.runTourManually(pageKey, { force: true }), 800); setTimeout(() => onboarding.runTourManually(pageKey, { force: true }), 800);
}; };
if (progress.total === 0) return null; if (progress.total === 0) return null;
return ( return (
<div <div
className="ios26-panel" className="ios26-panel"
style={{ style={{
padding: 20, padding: 20,
borderRadius: 16, borderRadius: 16,
background: 'var(--md-sys-color-surface-container-low)', background: 'var(--md-sys-color-surface-container-low)',
border: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))', border: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
}} }}
> >
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 12px 0', color: 'var(--md-sys-color-on-surface)' }}> <h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 12px 0', color: 'var(--md-sys-color-on-surface)' }}>
Подсказки по платформе Подсказки по платформе
</h3> </h3>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: '0 0 16px 0', lineHeight: 1.5 }}> <p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: '0 0 16px 0', lineHeight: 1.5 }}>
Пройдено {progress.seen} из {progress.total} страниц Пройдено {progress.seen} из {progress.total} страниц
</p> </p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{currentKey && ( {currentKey && (
<button <button
type="button" type="button"
onClick={handleShowAgain} onClick={handleShowAgain}
style={{ style={{
padding: '10px 16px', padding: '10px 16px',
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
color: 'var(--md-sys-color-on-primary)', color: 'var(--md-sys-color-on-primary)',
background: 'var(--md-sys-color-primary)', background: 'var(--md-sys-color-primary)',
border: 'none', border: 'none',
borderRadius: 12, borderRadius: 12,
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
Показать подсказки снова Показать подсказки снова
</button> </button>
)} )}
<button <button
type="button" type="button"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
style={{ style={{
padding: '10px 16px', padding: '10px 16px',
fontSize: 14, fontSize: 14,
fontWeight: 500, fontWeight: 500,
color: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-primary)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--md-sys-color-primary)', border: '1px solid var(--md-sys-color-primary)',
borderRadius: 12, borderRadius: 12,
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
{expanded ? 'Свернуть' : 'Подсказки на другой странице'} {expanded ? 'Свернуть' : 'Подсказки на другой странице'}
</button> </button>
</div> </div>
{expanded && ( {expanded && (
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
paddingTop: 16, paddingTop: 16,
borderTop: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))', borderTop: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 8, gap: 8,
}} }}
> >
{pages.map((key) => ( {pages.map((key) => (
<button <button
key={key} key={key}
type="button" type="button"
onClick={() => handleShowOnPage(key)} onClick={() => handleShowOnPage(key)}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 500,
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
background: 'var(--md-sys-color-surface-container-high)', background: 'var(--md-sys-color-surface-container-high)',
border: 'none', border: 'none',
borderRadius: 10, borderRadius: 10,
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
{PAGE_LABELS[key] || key} {PAGE_LABELS[key] || key}
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@ -1,85 +1,85 @@
/** /**
* Симпатичная стилизация онбординг-туров (Driver.js). * Симпатичная стилизация онбординг-туров (Driver.js).
* Мягкие тени, скругления, дружелюбная палитра. * Мягкие тени, скругления, дружелюбная палитра.
*/ */
.driver-popover.driver-onboarding-friendly { .driver-popover.driver-onboarding-friendly {
background: var(--md-sys-color-surface-container-high, #fff); background: var(--md-sys-color-surface-container-high, #fff);
border-radius: 20px; border-radius: 20px;
box-shadow: 0 12px 40px rgba(103, 80, 164, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 12px 40px rgba(103, 80, 164, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(103, 80, 164, 0.12); border: 1px solid rgba(103, 80, 164, 0.12);
padding: 20px 24px; padding: 20px 24px;
max-width: 360px; max-width: 360px;
} }
.driver-popover.driver-onboarding-friendly .driver-popover-title { .driver-popover.driver-onboarding-friendly .driver-popover-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: var(--md-sys-color-on-surface, #1c1b1f); color: var(--md-sys-color-on-surface, #1c1b1f);
margin-bottom: 10px; margin-bottom: 10px;
line-height: 1.35; line-height: 1.35;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.driver-popover.driver-onboarding-friendly .driver-popover-description { .driver-popover.driver-onboarding-friendly .driver-popover-description {
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
color: var(--md-sys-color-on-surface-variant, #49454f); color: var(--md-sys-color-on-surface-variant, #49454f);
margin-bottom: 20px; margin-bottom: 20px;
} }
.driver-popover.driver-onboarding-friendly .driver-popover-footer { .driver-popover.driver-onboarding-friendly .driver-popover-footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding-top: 4px; padding-top: 4px;
} }
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn, .driver-popover.driver-onboarding-friendly .driver-popover-prev-btn,
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { .driver-popover.driver-onboarding-friendly .driver-popover-next-btn {
padding: 10px 20px; padding: 10px 20px;
border-radius: 12px; border-radius: 12px;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease; transition: transform 0.15s ease, box-shadow 0.15s ease;
} }
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn:hover, .driver-popover.driver-onboarding-friendly .driver-popover-prev-btn:hover,
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn:hover { .driver-popover.driver-onboarding-friendly .driver-popover-next-btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn { .driver-popover.driver-onboarding-friendly .driver-popover-prev-btn {
background: var(--md-sys-color-surface-variant, #e7e0ec); background: var(--md-sys-color-surface-variant, #e7e0ec);
color: var(--md-sys-color-on-surface-variant, #49454f); color: var(--md-sys-color-on-surface-variant, #49454f);
} }
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { .driver-popover.driver-onboarding-friendly .driver-popover-next-btn {
background: var(--md-sys-color-primary, #6750a4); background: var(--md-sys-color-primary, #6750a4);
color: var(--md-sys-color-on-primary, #fff); color: var(--md-sys-color-on-primary, #fff);
} }
.driver-popover.driver-onboarding-friendly .driver-popover-progress-text { .driver-popover.driver-onboarding-friendly .driver-popover-progress-text {
font-size: 13px; font-size: 13px;
color: var(--md-sys-color-outline, #79747e); color: var(--md-sys-color-outline, #79747e);
font-weight: 500; font-weight: 500;
} }
.driver-popover.driver-onboarding-friendly .driver-popover-arrow { .driver-popover.driver-onboarding-friendly .driver-popover-arrow {
border-color: var(--md-sys-color-surface-container-high, #fff); border-color: var(--md-sys-color-surface-container-high, #fff);
} }
.driver-overlay { .driver-overlay {
background: rgba(0, 0, 0, 0) !important; background: rgba(0, 0, 0, 0) !important;
} }
.driver-active-element { .driver-active-element {
border-radius: 16px !important; border-radius: 16px !important;
box-shadow: box-shadow:
0 0 0 4px rgba(103, 80, 164, 0.9), 0 0 0 4px rgba(103, 80, 164, 0.9),
0 0 0 8px rgba(103, 80, 164, 0.25), 0 0 0 8px rgba(103, 80, 164, 0.25),
0 0 32px rgba(103, 80, 164, 0.35) !important; 0 0 32px rgba(103, 80, 164, 0.35) !important;
} }

View File

@ -342,6 +342,47 @@ img {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
/* Предотвращение layout shift при переходах */
contain: layout style;
}
/* Плавные переходы между страницами - предотвращение дёрганья */
.protected-layout-root .protected-main > * {
animation: pageContentFadeIn 0.2s ease-out;
will-change: opacity;
}
@keyframes pageContentFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Стабилизация layout при загрузке - контент сразу занимает всё пространство */
.ios26-dashboard,
.ios26-schedule-page,
.ios26-students-page,
.ios26-chat-page,
.ios26-materials-page,
.ios26-profile-page,
.ios26-homework-page,
.ios26-analytics-page,
.ios26-payment-page {
min-height: 100%;
width: 100%;
contain: layout style;
}
/* Отключаем анимацию для уменьшения motion (accessibility) */
@media (prefers-reduced-motion: reduce) {
.protected-layout-root .protected-main > * {
animation: none;
}
} }
/* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */ /* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */