feat: invite by link, 8-char code input, universal_code in profile
Backend: - New InvitationLink model: token, mentor, created_at, used_by, is_banned - Links expire after 12h, single-use, multiple active links allowed - generate-invitation-link: creates InvitationLink (doesn't overwrite old) - info-by-token: validates expiry/usage/ban before returning mentor info - register-by-link: validates InvitationLink, marks used_by on registration - Migration 0013_invitation_link_model Frontend: - Profile: show user's universal_code with copy button - InviteDialog: 8 separate input boxes for code (auto-focus, paste support) - InviteDialog: new "По ссылке" tab — generate link, copy, show expiry - /invite/:token page: public registration page with mentor info preview - Auto-login after invite link registration (setSession + checkUserSession) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
87f52da0eb
commit
d5ebd2898a
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Generated by Django 4.2.7 on 2026-03-12 14:06
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("users", "0012_add_group_to_board"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="InvitationLink",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"token",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=64, unique=True, verbose_name="Токен"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_banned",
|
||||||
|
models.BooleanField(default=False, verbose_name="Забанена"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mentor",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="invitation_links",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Ментор",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"used_by",
|
||||||
|
models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="registered_via_link",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Использована пользователем",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Ссылка-приглашение",
|
||||||
|
"verbose_name_plural": "Ссылки-приглашения",
|
||||||
|
"db_table": "invitation_links",
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -691,4 +691,50 @@ class Group(models.Model):
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} (ментор: {self.mentor.get_full_name()})"
|
return f"{self.name} (ментор: {self.mentor.get_full_name()})"
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationLink(models.Model):
|
||||||
|
"""
|
||||||
|
Ссылка-приглашение от ментора для регистрации ученика.
|
||||||
|
- Каждая ссылка действует 12 часов с момента создания
|
||||||
|
- Одна ссылка — один ученик (used_by)
|
||||||
|
- Несколько ссылок могут быть активны одновременно
|
||||||
|
- По истечении 12 часов ссылка помечается is_banned=True и не может быть использована
|
||||||
|
"""
|
||||||
|
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='Токен')
|
||||||
|
mentor = models.ForeignKey(
|
||||||
|
'User',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='invitation_links',
|
||||||
|
verbose_name='Ментор',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создана')
|
||||||
|
used_by = models.OneToOneField(
|
||||||
|
'User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='registered_via_link',
|
||||||
|
verbose_name='Использована пользователем',
|
||||||
|
)
|
||||||
|
is_banned = models.BooleanField(default=False, verbose_name='Забанена')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'invitation_links'
|
||||||
|
verbose_name = 'Ссылка-приглашение'
|
||||||
|
verbose_name_plural = 'Ссылки-приглашения'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"InvitationLink({self.mentor.email}, {self.token[:8]}...)"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from datetime import timedelta
|
||||||
|
return tz.now() > self.created_at + timedelta(hours=12)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.is_banned and not self.is_expired and self.used_by_id is None
|
||||||
|
|
|
||||||
|
|
@ -1253,23 +1253,38 @@ class ClientManagementViewSet(viewsets.ViewSet):
|
||||||
@action(detail=False, methods=['post'], url_path='generate-invitation-link')
|
@action(detail=False, methods=['post'], url_path='generate-invitation-link')
|
||||||
def generate_invitation_link(self, request):
|
def generate_invitation_link(self, request):
|
||||||
"""
|
"""
|
||||||
Сгенерировать или обновить токен ссылки-приглашения.
|
Создать новую ссылку-приглашение (12 часов, 1 использование).
|
||||||
POST /api/users/manage/clients/generate-invitation-link/
|
Старые ссылки остаются действительными.
|
||||||
|
POST /api/manage/clients/generate-invitation-link/
|
||||||
"""
|
"""
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import InvitationLink
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.role != 'mentor':
|
if user.role != 'mentor':
|
||||||
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
|
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
user.invitation_link_token = secrets.token_urlsafe(32)
|
# Истекаем просроченные ссылки этого ментора
|
||||||
user.save(update_fields=['invitation_link_token'])
|
expire_before = tz.now() - timedelta(hours=12)
|
||||||
|
InvitationLink.objects.filter(
|
||||||
|
mentor=user,
|
||||||
|
is_banned=False,
|
||||||
|
used_by__isnull=True,
|
||||||
|
created_at__lt=expire_before,
|
||||||
|
).update(is_banned=True)
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
inv = InvitationLink.objects.create(mentor=user, token=token)
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
|
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
|
||||||
link = f"{frontend_url}/invite/{user.invitation_link_token}"
|
link = f"{frontend_url}/invite/{token}"
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'invitation_link_token': user.invitation_link_token,
|
'invitation_link_token': token,
|
||||||
'invitation_link': link
|
'invitation_link': link,
|
||||||
|
'expires_at': (inv.created_at + timedelta(hours=12)).isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1453,20 +1468,37 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
Получить информацию о менторе по токену ссылки-приглашения.
|
Получить информацию о менторе по токену ссылки-приглашения.
|
||||||
GET /api/invitation/info-by-token/?token=...
|
GET /api/invitation/info-by-token/?token=...
|
||||||
"""
|
"""
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import InvitationLink
|
||||||
|
|
||||||
token = request.query_params.get('token')
|
token = request.query_params.get('token')
|
||||||
if not token:
|
if not token:
|
||||||
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mentor = User.objects.get(invitation_link_token=token, role='mentor')
|
inv = InvitationLink.objects.select_related('mentor').get(token=token)
|
||||||
return Response({
|
except InvitationLink.DoesNotExist:
|
||||||
'mentor_name': mentor.get_full_name(),
|
|
||||||
'mentor_id': mentor.id,
|
|
||||||
'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
|
|
||||||
})
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if inv.is_banned:
|
||||||
|
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.is_expired:
|
||||||
|
inv.is_banned = True
|
||||||
|
inv.save(update_fields=['is_banned'])
|
||||||
|
return Response({'error': 'Ссылка истекла'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.used_by_id is not None:
|
||||||
|
return Response({'error': 'Ссылка уже использована'}, status=status.HTTP_410_GONE)
|
||||||
|
|
||||||
|
mentor = inv.mentor
|
||||||
|
expires_at = inv.created_at + timedelta(hours=12)
|
||||||
|
return Response({
|
||||||
|
'mentor_name': mentor.get_full_name(),
|
||||||
|
'mentor_id': mentor.id,
|
||||||
|
'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
|
||||||
|
'expires_at': expires_at.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny])
|
@action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny])
|
||||||
def register_by_link(self, request):
|
def register_by_link(self, request):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1482,6 +1514,8 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
"city": "..."
|
"city": "..."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
from .models import InvitationLink
|
||||||
|
|
||||||
token = request.data.get('token')
|
token = request.data.get('token')
|
||||||
first_name = request.data.get('first_name')
|
first_name = request.data.get('first_name')
|
||||||
last_name = request.data.get('last_name')
|
last_name = request.data.get('last_name')
|
||||||
|
|
@ -1494,10 +1528,21 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mentor = User.objects.get(invitation_link_token=token, role='mentor')
|
inv = InvitationLink.objects.select_related('mentor').get(token=token)
|
||||||
except User.DoesNotExist:
|
except InvitationLink.DoesNotExist:
|
||||||
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if inv.is_banned:
|
||||||
|
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.is_expired:
|
||||||
|
inv.is_banned = True
|
||||||
|
inv.save(update_fields=['is_banned'])
|
||||||
|
return Response({'error': 'Срок действия ссылки истёк'}, status=status.HTTP_410_GONE)
|
||||||
|
if inv.used_by_id is not None:
|
||||||
|
return Response({'error': 'Ссылка уже была использована'}, status=status.HTTP_410_GONE)
|
||||||
|
|
||||||
|
mentor = inv.mentor
|
||||||
|
|
||||||
# Если email указан, проверяем его уникальность
|
# Если email указан, проверяем его уникальность
|
||||||
if email:
|
if email:
|
||||||
if User.objects.filter(email=email).exists():
|
if User.objects.filter(email=email).exists():
|
||||||
|
|
@ -1522,7 +1567,11 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
# Генерируем персональный токен для входа
|
# Генерируем персональный токен для входа
|
||||||
student_user.login_token = secrets.token_urlsafe(32)
|
student_user.login_token = secrets.token_urlsafe(32)
|
||||||
student_user.save(update_fields=['login_token'])
|
student_user.save(update_fields=['login_token'])
|
||||||
|
|
||||||
|
# Помечаем ссылку как использованную
|
||||||
|
inv.used_by = student_user
|
||||||
|
inv.save(update_fields=['used_by'])
|
||||||
|
|
||||||
# Создаем профиль клиента
|
# Создаем профиль клиента
|
||||||
client = Client.objects.create(user=student_user)
|
client = Client.objects.create(user=student_user)
|
||||||
|
|
||||||
|
|
@ -1601,65 +1650,80 @@ class ParentManagementViewSet(viewsets.ViewSet):
|
||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
# Поддержка старого формата (child_email) и нового (email + данные)
|
# Поддержка: universal_code (8 симв.) / child_email / email
|
||||||
|
universal_code = (request.data.get('universal_code') or '').strip().upper()
|
||||||
child_email = request.data.get('child_email') or request.data.get('email')
|
child_email = request.data.get('child_email') or request.data.get('email')
|
||||||
|
|
||||||
if not child_email:
|
if not universal_code and not child_email:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Необходимо указать email ребенка'},
|
{'error': 'Необходимо указать 8-значный код ребенка или его email'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Нормализуем email
|
|
||||||
child_email = child_email.lower().strip()
|
|
||||||
|
|
||||||
# Получаем или создаем профиль родителя
|
# Получаем или создаем профиль родителя
|
||||||
parent, created = Parent.objects.get_or_create(user=user)
|
parent, _ = Parent.objects.get_or_create(user=user)
|
||||||
|
|
||||||
# Ищем пользователя
|
|
||||||
created = False
|
created = False
|
||||||
try:
|
|
||||||
child_user = User.objects.get(email=child_email)
|
if universal_code:
|
||||||
# Если пользователь существует, проверяем что это клиент
|
# --- Поиск по universal_code ---
|
||||||
|
allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||||
|
valid_8 = len(universal_code) == 8 and all(c in allowed for c in universal_code)
|
||||||
|
valid_6_legacy = len(universal_code) == 6 and universal_code.isdigit()
|
||||||
|
if not (valid_8 or valid_6_legacy):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Код должен содержать 8 символов (буквы и цифры)'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
child_user = User.objects.get(universal_code=universal_code)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Пользователь с таким кодом не найден'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
if child_user.role != 'client':
|
if child_user.role != 'client':
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Пользователь с таким email уже существует, но не является клиентом'},
|
{'error': 'Пользователь с этим кодом не является учеником (client)'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
except User.DoesNotExist:
|
else:
|
||||||
created = True
|
# --- Поиск / создание по email ---
|
||||||
# Создаем нового пользователя-клиента
|
child_email = child_email.lower().strip()
|
||||||
first_name = request.data.get('first_name', '').strip()
|
try:
|
||||||
last_name = request.data.get('last_name', '').strip()
|
child_user = User.objects.get(email=child_email)
|
||||||
phone = request.data.get('phone', '').strip()
|
if child_user.role != 'client':
|
||||||
|
return Response(
|
||||||
if not first_name or not last_name:
|
{'error': 'Пользователь с таким email не является учеником (client)'},
|
||||||
return Response(
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
{'error': 'Для создания нового пользователя необходимо указать имя и фамилию'},
|
)
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
except User.DoesNotExist:
|
||||||
|
created = True
|
||||||
|
first_name = request.data.get('first_name', '').strip()
|
||||||
|
last_name = request.data.get('last_name', '').strip()
|
||||||
|
phone = request.data.get('phone', '').strip()
|
||||||
|
|
||||||
|
if not first_name or not last_name:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Для создания нового пользователя необходимо указать имя и фамилию'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_password = secrets.token_urlsafe(12)
|
||||||
|
child_user = User.objects.create_user(
|
||||||
|
email=child_email,
|
||||||
|
password=temp_password,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
phone=normalize_phone(phone) if phone else '',
|
||||||
|
role='client',
|
||||||
|
email_verified=True,
|
||||||
)
|
)
|
||||||
|
reset_token = secrets.token_urlsafe(32)
|
||||||
# Создаем пользователя с временным паролем
|
child_user.email_verification_token = reset_token
|
||||||
temp_password = secrets.token_urlsafe(12)
|
child_user.save()
|
||||||
|
send_student_welcome_email_task.delay(child_user.id, reset_token)
|
||||||
child_user = User.objects.create_user(
|
|
||||||
email=child_email,
|
|
||||||
password=temp_password,
|
|
||||||
first_name=first_name,
|
|
||||||
last_name=last_name,
|
|
||||||
phone=normalize_phone(phone) if phone else '',
|
|
||||||
role='client',
|
|
||||||
email_verified=True, # Email автоматически подтвержден при добавлении родителем
|
|
||||||
)
|
|
||||||
|
|
||||||
# Генерируем токен для установки пароля
|
|
||||||
reset_token = secrets.token_urlsafe(32)
|
|
||||||
child_user.email_verification_token = reset_token
|
|
||||||
child_user.save()
|
|
||||||
|
|
||||||
# Отправляем приветственное письмо со ссылкой на установку пароля
|
|
||||||
send_student_welcome_email_task.delay(child_user.id, reset_token)
|
|
||||||
|
|
||||||
# Получаем или создаем профиль клиента
|
# Получаем или создаем профиль клиента
|
||||||
if created: # Если пользователь был только что создан
|
if created: # Если пользователь был только что создан
|
||||||
child = Client.objects.create(
|
child = Client.objects.create(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
import { InviteRegisterView } from 'src/sections/invite/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Регистрация по приглашению | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page({ params }) {
|
||||||
|
return <InviteRegisterView token={params.token} />;
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ const ROOTS = {
|
||||||
|
|
||||||
export const paths = {
|
export const paths = {
|
||||||
videoCall: '/video-call',
|
videoCall: '/video-call',
|
||||||
|
invite: (token) => `/invite/${token}`,
|
||||||
page404: '/404',
|
page404: '/404',
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,10 @@ const VideoCallView = lazy(() =>
|
||||||
import('src/sections/video-call/view').then((m) => ({ default: m.VideoCallView }))
|
import('src/sections/video-call/view').then((m) => ({ default: m.VideoCallView }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const InviteRegisterView = lazy(() =>
|
||||||
|
import('src/sections/invite/view').then((m) => ({ default: m.InviteRegisterView }))
|
||||||
|
);
|
||||||
|
|
||||||
const PrejoinView = lazy(() =>
|
const PrejoinView = lazy(() =>
|
||||||
import('src/sections/prejoin/view/prejoin-view').then((m) => ({ default: m.PrejoinView }))
|
import('src/sections/prejoin/view/prejoin-view').then((m) => ({ default: m.PrejoinView }))
|
||||||
);
|
);
|
||||||
|
|
@ -145,6 +149,11 @@ function AuthLayoutWrapper() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InviteWrapper() {
|
||||||
|
const { token } = useParams();
|
||||||
|
return <InviteRegisterView token={token} />;
|
||||||
|
}
|
||||||
|
|
||||||
function LessonDetailWrapper() {
|
function LessonDetailWrapper() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return <LessonDetailView id={id} />;
|
return <LessonDetailView id={id} />;
|
||||||
|
|
@ -185,6 +194,9 @@ export function Router() {
|
||||||
// Video call — fullscreen, no sidebar
|
// Video call — fullscreen, no sidebar
|
||||||
{ path: 'video-call', element: <AuthGuard><S><VideoCallView /></S></AuthGuard> },
|
{ path: 'video-call', element: <AuthGuard><S><VideoCallView /></S></AuthGuard> },
|
||||||
|
|
||||||
|
// Invite link — public, no auth required
|
||||||
|
{ path: 'invite/:token', element: <S><InviteWrapper /></S> },
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
|
|
|
||||||
|
|
@ -858,6 +858,31 @@ export function AccountPlatformView() {
|
||||||
disabled
|
disabled
|
||||||
helperText="Изменить email нельзя"
|
helperText="Изменить email нельзя"
|
||||||
/>
|
/>
|
||||||
|
{user?.universal_code && (
|
||||||
|
<TextField
|
||||||
|
label="Мой код"
|
||||||
|
value={user.universal_code}
|
||||||
|
fullWidth
|
||||||
|
disabled
|
||||||
|
helperText="Передайте этот код ментору, чтобы он мог добавить вас"
|
||||||
|
inputProps={{ style: { letterSpacing: 4, fontWeight: 700, fontSize: 18 } }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<Tooltip title="Скопировать код">
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(user.universal_code);
|
||||||
|
showSnack('Код скопирован');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon="solar:copy-bold" width={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { InviteRegisterView } from './invite-register-view';
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { z as zod } from 'zod';
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
|
||||||
|
import { useRouter } from 'src/routes/hooks';
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import { useBoolean } from 'src/hooks/use-boolean';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
import { setSession } from 'src/auth/context/jwt/utils';
|
||||||
|
|
||||||
|
import { Logo } from 'src/components/logo';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { Form, Field } from 'src/components/hook-form';
|
||||||
|
|
||||||
|
import { searchCities } from 'src/utils/profile-api';
|
||||||
|
import { getInviteLinkInfo, registerByInviteLink } from 'src/utils/students-api';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RegisterSchema = zod
|
||||||
|
.object({
|
||||||
|
firstName: zod.string().min(1, { message: 'Введите имя!' }),
|
||||||
|
lastName: zod.string().min(1, { message: 'Введите фамилию!' }),
|
||||||
|
email: zod
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'Введите email!' })
|
||||||
|
.email({ message: 'Введите корректный email!' }),
|
||||||
|
city: zod.string().min(1, { message: 'Введите город!' }),
|
||||||
|
password: zod
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'Введите пароль!' })
|
||||||
|
.min(8, { message: 'Минимум 8 символов!' }),
|
||||||
|
passwordConfirm: zod.string().min(1, { message: 'Подтвердите пароль!' }),
|
||||||
|
})
|
||||||
|
.refine((d) => d.password === d.passwordConfirm, {
|
||||||
|
message: 'Пароли не совпадают!',
|
||||||
|
path: ['passwordConfirm'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function CityAutocomplete({ value, onChange, error, helperText }) {
|
||||||
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
const [options, setOptions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const fetch = useCallback(async (query) => {
|
||||||
|
if (!query || query.length < 2) { setOptions([]); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await searchCities(query, 30);
|
||||||
|
setOptions(res.map((c) => (typeof c === 'string' ? c : c.name || c.city || String(c))));
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => fetch(inputValue), 350);
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [inputValue, fetch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={options}
|
||||||
|
loading={loading}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={(_, val) => { setInputValue(val); onChange(val); }}
|
||||||
|
onChange={(_, val) => { if (val) { setInputValue(val); onChange(val); } }}
|
||||||
|
noOptionsText="Города не найдены"
|
||||||
|
loadingText="Поиск..."
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Город"
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{loading ? <CircularProgress size={18} /> : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function InviteRegisterView({ token }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { checkUserSession } = useAuthContext();
|
||||||
|
|
||||||
|
const password = useBoolean();
|
||||||
|
const passwordConfirm = useBoolean();
|
||||||
|
|
||||||
|
const [mentorInfo, setMentorInfo] = useState(null);
|
||||||
|
const [infoLoading, setInfoLoading] = useState(true);
|
||||||
|
const [infoError, setInfoError] = useState('');
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) { setInfoError('Токен отсутствует'); setInfoLoading(false); return; }
|
||||||
|
getInviteLinkInfo(token)
|
||||||
|
.then((data) => setMentorInfo(data))
|
||||||
|
.catch((e) => {
|
||||||
|
const msg = e?.response?.data?.error || e?.response?.data?.message || e?.message || 'Ссылка недействительна или истекла';
|
||||||
|
setInfoError(msg);
|
||||||
|
})
|
||||||
|
.finally(() => setInfoLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const methods = useForm({
|
||||||
|
resolver: zodResolver(RegisterSchema),
|
||||||
|
defaultValues: { firstName: '', lastName: '', email: '', city: '', password: '', passwordConfirm: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control, handleSubmit, formState: { isSubmitting } } = methods; // eslint-disable-line
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
setSubmitError('');
|
||||||
|
const timezone = typeof Intl !== 'undefined'
|
||||||
|
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
: 'Europe/Moscow';
|
||||||
|
|
||||||
|
const res = await registerByInviteLink({
|
||||||
|
token,
|
||||||
|
first_name: data.firstName,
|
||||||
|
last_name: data.lastName,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
city: data.city,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Авто-логин
|
||||||
|
await setSession(res.access, res.refresh);
|
||||||
|
await checkUserSession?.();
|
||||||
|
router.replace(paths.dashboard.root);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e?.response?.data?.error?.message
|
||||||
|
|| e?.response?.data?.error
|
||||||
|
|| e?.response?.data?.message
|
||||||
|
|| e?.message
|
||||||
|
|| 'Ошибка регистрации';
|
||||||
|
setSubmitError(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (infoLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoError) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', p: 3 }}>
|
||||||
|
<Card sx={{ maxWidth: 400, width: 1 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2} alignItems="center">
|
||||||
|
<Iconify icon="solar:link-broken-bold" width={64} sx={{ color: 'error.main' }} />
|
||||||
|
<Typography variant="h6">Ссылка недействительна</Typography>
|
||||||
|
<Alert severity="error">{infoError}</Alert>
|
||||||
|
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||||
|
Ссылка истекла, уже была использована или заблокирована. Попросите ментора создать новую ссылку.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
minHeight: '100vh',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
p: { xs: 2, sm: 4 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card sx={{ maxWidth: 480, width: 1 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Logo disableLink />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mentor info */}
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ p: 2, bgcolor: 'background.neutral', borderRadius: 1.5 }}>
|
||||||
|
<Avatar
|
||||||
|
src={mentorInfo?.avatar_url}
|
||||||
|
sx={{ width: 48, height: 48, bgcolor: 'primary.main' }}
|
||||||
|
>
|
||||||
|
{mentorInfo?.mentor_name?.[0]}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">Вас приглашает</Typography>
|
||||||
|
<Typography variant="subtitle1">{mentorInfo?.mentor_name}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="h5" textAlign="center">Создать аккаунт</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||||
|
После регистрации вы сразу будете подключены к ментору
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{submitError && <Alert severity="error">{submitError}</Alert>}
|
||||||
|
|
||||||
|
<Form methods={methods} onSubmit={onSubmit}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
<Field.Text name="firstName" label="Имя" InputLabelProps={{ shrink: true }} />
|
||||||
|
<Field.Text name="lastName" label="Фамилия" InputLabelProps={{ shrink: true }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Field.Text name="email" label="Email" type="email" InputLabelProps={{ shrink: true }} />
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="city"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<CityAutocomplete
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field.Text
|
||||||
|
name="password"
|
||||||
|
label="Пароль"
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
type={password.value ? 'text' : 'password'}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={password.onToggle} edge="end">
|
||||||
|
<Iconify icon={password.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field.Text
|
||||||
|
name="passwordConfirm"
|
||||||
|
label="Подтвердите пароль"
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
type={passwordConfirm.value ? 'text' : 'password'}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={passwordConfirm.onToggle} edge="end">
|
||||||
|
<Iconify icon={passwordConfirm.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoadingButton
|
||||||
|
fullWidth
|
||||||
|
color="inherit"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
loading={isSubmitting}
|
||||||
|
loadingIndicator="Регистрация..."
|
||||||
|
>
|
||||||
|
Зарегистрироваться
|
||||||
|
</LoadingButton>
|
||||||
|
</Stack>
|
||||||
|
</Form>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
|
@ -15,10 +15,12 @@ import Avatar from '@mui/material/Avatar';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import CardHeader from '@mui/material/CardHeader';
|
import CardHeader from '@mui/material/CardHeader';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import CardContent from '@mui/material/CardContent';
|
import CardContent from '@mui/material/CardContent';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
|
@ -31,12 +33,13 @@ import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { paths } from 'src/routes/paths';
|
import { paths } from 'src/routes/paths';
|
||||||
import { useRouter } from 'src/routes/hooks';
|
import { useRouter } from 'src/routes/hooks';
|
||||||
|
|
||||||
import { resolveMediaUrl } from 'src/utils/axios';
|
import { resolveMediaUrl } from 'src/utils/axios';
|
||||||
import {
|
import {
|
||||||
getStudents,
|
getStudents,
|
||||||
getMyMentors,
|
getMyMentors,
|
||||||
getMyInvitations,
|
getMyInvitations,
|
||||||
addStudentInvitation,
|
addStudentInvitation,
|
||||||
|
generateInvitationLink,
|
||||||
sendMentorshipRequest,
|
sendMentorshipRequest,
|
||||||
acceptMentorshipRequest,
|
acceptMentorshipRequest,
|
||||||
rejectMentorshipRequest,
|
rejectMentorshipRequest,
|
||||||
|
|
@ -274,57 +277,226 @@ function MentorRequests({ onRefresh }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8 отдельных полей для ввода кода
|
||||||
|
function CodeInput({ value, onChange, disabled }) {
|
||||||
|
const refs = useRef([]);
|
||||||
|
const chars = (value || '').padEnd(8, '').split('').slice(0, 8);
|
||||||
|
|
||||||
|
const handleChange = (i, v) => {
|
||||||
|
const ch = v.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
const next = chars.slice();
|
||||||
|
next[i] = ch[0] || '';
|
||||||
|
onChange(next.join('').trimEnd());
|
||||||
|
if (ch && i < 7) refs.current[i + 1]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (i, e) => {
|
||||||
|
if (e.key === 'Backspace' && !chars[i] && i > 0) refs.current[i - 1]?.focus();
|
||||||
|
if (e.key === 'ArrowLeft' && i > 0) refs.current[i - 1]?.focus();
|
||||||
|
if (e.key === 'ArrowRight' && i < 7) refs.current[i + 1]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
|
||||||
|
onChange(text);
|
||||||
|
refs.current[Math.min(text.length, 7)]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={0.5} justifyContent="center">
|
||||||
|
{chars.map((ch, i) => (
|
||||||
|
<TextField
|
||||||
|
key={i}
|
||||||
|
inputRef={(el) => { refs.current[i] = el; }}
|
||||||
|
value={ch}
|
||||||
|
onChange={(e) => handleChange(i, e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||||
|
onPaste={i === 0 ? handlePaste : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{
|
||||||
|
maxLength: 1,
|
||||||
|
style: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 18,
|
||||||
|
letterSpacing: 0,
|
||||||
|
padding: '10px 0',
|
||||||
|
width: 32,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ width: 40 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InviteDialog({ open, onClose, onSuccess }) {
|
function InviteDialog({ open, onClose, onSuccess }) {
|
||||||
const [mode, setMode] = useState('email'); // 'email' | 'code'
|
const [mode, setMode] = useState('email'); // 'email' | 'code' | 'link'
|
||||||
const [value, setValue] = useState('');
|
const [emailValue, setEmailValue] = useState('');
|
||||||
|
const [codeValue, setCodeValue] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [successMsg, setSuccessMsg] = useState(null);
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
|
const [linkData, setLinkData] = useState(null); // { invitation_link, expires_at }
|
||||||
|
const [linkLoading, setLinkLoading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const reset = () => { setValue(''); setError(null); setSuccessMsg(null); };
|
const reset = () => {
|
||||||
|
setEmailValue(''); setCodeValue(''); setError(null); setSuccessMsg(null);
|
||||||
|
setLinkData(null); setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => { if (!loading && !linkLoading) { reset(); onClose(); } };
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!value.trim()) return;
|
const val = mode === 'email' ? emailValue.trim() : codeValue.trim();
|
||||||
|
if (!val) return;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const payload = mode === 'email' ? { email: value.trim() } : { universal_code: value.trim() };
|
const payload = mode === 'email' ? { email: val } : { universal_code: val };
|
||||||
const res = await addStudentInvitation(payload);
|
const res = await addStudentInvitation(payload);
|
||||||
setSuccessMsg(res?.message || 'Приглашение отправлено');
|
setSuccessMsg(res?.message || 'Приглашение отправлено');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e?.response?.data?.detail || e?.response?.data?.email?.[0] || e?.message || 'Ошибка');
|
const msg = e?.response?.data?.error?.message
|
||||||
|
|| e?.response?.data?.detail
|
||||||
|
|| e?.response?.data?.email?.[0]
|
||||||
|
|| e?.message || 'Ошибка';
|
||||||
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateLink = async () => {
|
||||||
|
try {
|
||||||
|
setLinkLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await generateInvitationLink();
|
||||||
|
setLinkData(res);
|
||||||
|
setCopied(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.error?.message || e?.message || 'Ошибка генерации ссылки');
|
||||||
|
} finally {
|
||||||
|
setLinkLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(linkData.invitation_link);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiresText = linkData?.expires_at
|
||||||
|
? `Действует до ${new Date(linkData.expires_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const canSend = mode === 'email' ? !!emailValue.trim() : codeValue.length === 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={() => { if (!loading) { reset(); onClose(); } }} maxWidth="xs" fullWidth>
|
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>Пригласить ученика</DialogTitle>
|
<DialogTitle>Пригласить ученика</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
<Tabs value={mode} onChange={(_, v) => setMode(v)} size="small">
|
<Tabs value={mode} onChange={(_, v) => { setMode(v); setError(null); setSuccessMsg(null); }}>
|
||||||
<Tab value="email" label="По email" />
|
<Tab value="email" label="По email" />
|
||||||
<Tab value="code" label="По коду" />
|
<Tab value="code" label="По коду" />
|
||||||
|
<Tab value="link" label="По ссылке" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<TextField
|
|
||||||
label={mode === 'email' ? 'Email ученика' : 'Код приглашения'}
|
{mode === 'email' && (
|
||||||
value={value}
|
<TextField
|
||||||
onChange={(e) => setValue(e.target.value)}
|
label="Email ученика"
|
||||||
disabled={loading}
|
value={emailValue}
|
||||||
fullWidth
|
onChange={(e) => setEmailValue(e.target.value)}
|
||||||
size="small"
|
disabled={loading}
|
||||||
/>
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'code' && (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||||
|
Введите 8-значный код ученика
|
||||||
|
</Typography>
|
||||||
|
<CodeInput value={codeValue} onChange={setCodeValue} disabled={loading} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'link' && (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Сгенерируйте ссылку для регистрации ученика. Каждая ссылка действует 12 часов и может быть использована только 1 раз.
|
||||||
|
</Typography>
|
||||||
|
{!linkData ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleGenerateLink}
|
||||||
|
disabled={linkLoading}
|
||||||
|
startIcon={linkLoading ? <CircularProgress size={16} /> : <Iconify icon="solar:link-bold" width={18} />}
|
||||||
|
>
|
||||||
|
Сгенерировать ссылку
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<TextField
|
||||||
|
value={linkData.invitation_link}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={copied ? 'Скопировано!' : 'Скопировать'}>
|
||||||
|
<IconButton edge="end" onClick={handleCopyLink} size="small">
|
||||||
|
<Iconify icon={copied ? 'solar:check-circle-bold' : 'solar:copy-bold'} width={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{expiresText && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{expiresText} · только 1 ученик
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={handleGenerateLink}
|
||||||
|
disabled={linkLoading}
|
||||||
|
startIcon={<Iconify icon="solar:refresh-bold" width={16} />}
|
||||||
|
>
|
||||||
|
Новая ссылка
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <Alert severity="error">{error}</Alert>}
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
{successMsg && <Alert severity="success">{successMsg}</Alert>}
|
{successMsg && <Alert severity="success">{successMsg}</Alert>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<Button onClick={() => { reset(); onClose(); }} disabled={loading}>Закрыть</Button>
|
<Button onClick={handleClose} disabled={loading || linkLoading}>Закрыть</Button>
|
||||||
<Button variant="contained" onClick={handleSend} disabled={loading || !value.trim()}>
|
{mode !== 'link' && (
|
||||||
{loading ? 'Отправка...' : 'Пригласить'}
|
<Button
|
||||||
</Button>
|
variant="contained"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={loading || !canSend}
|
||||||
|
startIcon={loading ? <CircularProgress size={16} /> : null}
|
||||||
|
>
|
||||||
|
{loading ? 'Отправка...' : 'Пригласить'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -65,3 +65,15 @@ export async function rejectInvitationAsStudent(invitationId) {
|
||||||
const res = await axios.post('/invitation/reject-as-student/', { invitation_id: invitationId });
|
const res = await axios.post('/invitation/reject-as-student/', { invitation_id: invitationId });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public: get mentor info by invite link token (no auth required)
|
||||||
|
export async function getInviteLinkInfo(token) {
|
||||||
|
const res = await axios.get('/invitation/info-by-token/', { params: { token } });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public: register new student via invite link (no auth required)
|
||||||
|
export async function registerByInviteLink(payload) {
|
||||||
|
const res = await axios.post('/invitation/register-by-link/', payload);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue