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
)
# Проверяем, есть ли LiveKit комната для этого занятия
# Если LiveKit комната не создана — создаём «на лету» (fallback при сбое при создании урока)
if not lesson.livekit_room_name:
return Response(
{'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
try:
try:
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

View File

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

View File

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

View File

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

View File

@ -403,7 +403,7 @@ REST_FRAMEWORK = {
# ==============================================
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),
'ACCESS_TOKEN_LIFETIME': timedelta(hours=3), # 3 часа - достаточно для длинных уроков
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': 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();
if (authLoading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '50vh',
background: 'var(--md-sys-color-background)'
}}>
<LoadingSpinner size="large" />
</div>
);
return <LoadingSpinner size="large" fullPage />;
}
if (!user) {
@ -38,17 +28,7 @@ export default function DashboardPage() {
// Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка
if (user.role === 'parent') {
if (childLoading && childrenList.length === 0) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '50vh',
background: 'var(--md-sys-color-background)'
}}>
<LoadingSpinner size="large" />
</div>
);
return <LoadingSpinner size="large" fullPage />;
}
if (childrenList.length === 0) {
return (

View File

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

View File

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

View File

@ -76,41 +76,57 @@ export default function ProtectedLayout({
useEffect(() => {
// Проверяем токен в 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) {
console.log('[ProtectedLayout] No user found, redirecting to login');
router.replace('/login');
// Если есть refresh токен, пробуем обновить сессию вместо редиректа
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
}, [user, loading]);
if (loading) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
// Стабильный loading layout - предотвращает дёрганье
const loadingLayout = (
<div className="protected-layout-root">
<main className="protected-main" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<LoadingSpinner size="large" />
</div>
);
</main>
</div>
);
if (loading) {
return loadingLayout;
}
if (!user) {
return (
<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>
);
return loadingLayout;
}
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом

View File

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

View File

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

View File

@ -9,10 +9,18 @@ const sizeMap = {
large: '64px',
} as const;
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large';
inline?: boolean;
/** Занимает всё доступное пространство страницы - предотвращает layout shift */
fullPage?: boolean;
}
export function LoadingSpinner({
size = 'medium',
inline = false,
}: { size?: 'small' | 'medium' | 'large'; inline?: boolean }) {
fullPage = false,
}: LoadingSpinnerProps) {
const [mounted, setMounted] = 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 (inline) {
return (
@ -34,6 +54,18 @@ export function LoadingSpinner({
</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>;
}
@ -43,6 +75,7 @@ export function LoadingSpinner({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
...(fullPage ? fullPageStyle : {}),
...(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 [canJoin, setCanJoin] = useState(false);
// Проверяем каждые 10 секунд, чтобы кнопка появилась вовремя (решение проблемы с кэшированием)
useEffect(() => {
const check = () => setCanJoin(canJoinLesson(lesson));
check();
const interval = setInterval(check, 60000);
const interval = setInterval(check, 10000); // 10 секунд
return () => clearInterval(interval);
}, [lesson.start_time, lesson.end_time, lesson.status]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -342,6 +342,47 @@ img {
overflow-y: auto;
overflow-x: hidden;
-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, контенту отступ снизу */