From 083fd4d8267009806316f40da57675ca8c0d0ce2 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 13 Feb 2026 04:30:56 +0300 Subject: [PATCH] =?UTF-8?q?prod:=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=20board,?= =?UTF-8?q?=20nginx,=20WhiteboardIframe=20EXCALIDRAW=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/users/profile_views.py | 14 + backend/apps/users/views.py | 14 +- backend/apps/video/livekit_service.py | 15 +- docker-compose.yml | 16 +- excalidraw-server/next.config.js | 28 +- front_material/app/(auth)/layout.tsx | 4 + front_material/app/(auth)/register/page.tsx | 1060 ++++++++--------- front_material/app/(protected)/layout.tsx | 302 ++--- .../app/(protected)/students/page.tsx | 13 - .../components/auth/AuthRedirect.tsx | 45 + .../components/board/WhiteboardIframe.tsx | 265 +++-- .../components/profile/ProfilePaymentTab.tsx | 424 +++---- 12 files changed, 1147 insertions(+), 1053 deletions(-) create mode 100644 front_material/components/auth/AuthRedirect.tsx diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py index 1355ad4..6b353f6 100644 --- a/backend/apps/users/profile_views.py +++ b/backend/apps/users/profile_views.py @@ -68,6 +68,13 @@ class ProfileViewSet(viewsets.ViewSet): GET /api/users/profile/me/ """ user = request.user + # Убедиться, что у пользователя есть 8-символьный код (для старых пользователей) + if not user.universal_code or len(user.universal_code) != 8: + try: + user.universal_code = user._generate_universal_code() + user.save(update_fields=['universal_code']) + except Exception: + pass serializer = UserSerializer(user, context={'request': request}) # Добавляем дополнительную информацию @@ -374,6 +381,13 @@ class ProfileViewSet(viewsets.ViewSet): user = request.user + # 8-символьный код: если нет — генерируем при обновлении профиля + if not user.universal_code or len(user.universal_code) != 8: + try: + user.universal_code = user._generate_universal_code() + except Exception: + pass + # Обработка удаления аватара if 'avatar' in request.data: avatar_value = request.data.get('avatar') diff --git a/backend/apps/users/views.py b/backend/apps/users/views.py index 5c7bfdb..a76eea8 100644 --- a/backend/apps/users/views.py +++ b/backend/apps/users/views.py @@ -160,10 +160,20 @@ class RegisterView(generics.CreateAPIView): serializer.is_valid(raise_exception=True) user = serializer.save() - # Генерируем токен для подтверждения email и сохраняем одним запросом + # 8-символьный код пользователя: генерируем при регистрации, если ещё нет + update_fields = [] + if not user.universal_code or len(user.universal_code) != 8: + try: + user.universal_code = user._generate_universal_code() + update_fields.append('universal_code') + except Exception: + pass + + # Токен для подтверждения email verification_token = secrets.token_urlsafe(32) user.email_verification_token = verification_token - user.save(update_fields=['email_verification_token']) + update_fields.append('email_verification_token') + user.save(update_fields=update_fields) # Отправляем email подтверждения (асинхронно через Celery) send_verification_email_task.delay(user.id, verification_token) diff --git a/backend/apps/video/livekit_service.py b/backend/apps/video/livekit_service.py index 47ef246..38a7a0c 100644 --- a/backend/apps/video/livekit_service.py +++ b/backend/apps/video/livekit_service.py @@ -31,17 +31,20 @@ class LiveKitService: def get_server_url(request=None) -> str: """ Публичный URL LiveKit для фронтенда. - Приоритет: LIVEKIT_PUBLIC_URL > по request (порт 80/443 = nginx) > dev fallback. + Приоритет: LIVEKIT_PUBLIC_URL > по request (Host + X-Forwarded-Proto за nginx) > dev fallback. """ url = getattr(settings, 'LIVEKIT_PUBLIC_URL', '').strip() if url: return url if request: - scheme = 'wss' if request.is_secure() else 'ws' - host = request.get_host().split(':')[0] - port = request.get_port() or (443 if request.is_secure() else 80) - # Только если запрос через nginx (порт 80/443) - if (request.is_secure() and port == 443) or (not request.is_secure() and port == 80): + # За nginx: X-Forwarded-Proto и Host надёжнее request.is_secure()/get_port() + proto = request.META.get('HTTP_X_FORWARDED_PROTO', '').strip().lower() + if proto in ('https', 'http'): + scheme = 'wss' if proto == 'https' else 'ws' + else: + scheme = 'wss' if request.is_secure() else 'ws' + host = (request.META.get('HTTP_X_FORWARDED_HOST') or request.get_host()).split(':')[0].strip() + if host and not host.startswith('127.0.0.1') and host != 'localhost': return f"{scheme}://{host}/livekit" return 'ws://127.0.0.1:7880' diff --git a/docker-compose.yml b/docker-compose.yml index af6c902..7e444a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,7 @@ services: user: "0:0" env_file: .env # Daphne (ASGI): HTTP + WebSocket (/ws/notifications/, /ws/chat/, /ws/board/ и т.д.) - command: sh -c "python manage.py migrate && daphne -b 0.0.0.0 -p 8000 config.asgi:application" + command: sh -c "python manage.py migrate && python manage.py init_subjects && daphne -b 0.0.0.0 -p 8000 config.asgi:application" environment: - DEBUG=${DEBUG:-True} - SECRET_KEY=dev_secret_key @@ -63,6 +63,8 @@ services: - EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10} # Ссылки в письмах (сброс пароля, подтверждение, приглашения) — без localhost - FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online} + # LiveKit: публичный URL для браузера (обязательно в prod — иначе клиент идёт на 127.0.0.1) + - LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL:-wss://api.uchill.online/livekit} # Telegram бот (профиль: bot-info, привязка аккаунта) - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False} @@ -181,13 +183,15 @@ services: networks: - dev_network + # Видеоуроки: хост nginx (api.uchill.online) проксирует /livekit на 7880. Dev на том же хосте — 7890. + # LIVEKIT_KEYS — строго один ключ в формате "key: secret" (пробел после двоеточия). В .env задайте одну строку: LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf livekit: image: livekit/livekit-server:latest container_name: platform_prod_livekit restart: unless-stopped - env_file: .env environment: - - LIVEKIT_KEYS=${LIVEKIT_API_KEY:-APIKeyPlatform2024Secret}:${LIVEKIT_API_SECRET:-ThisIsAVerySecureSecretKeyForPlatform2024VideoConf} + # Одна строка "key: secret" (пробел после двоеточия). В кавычках, чтобы YAML не воспринял двоеточие как ключ. + - "LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf" ports: - "7880:7880" - "7881:7881" @@ -221,6 +225,9 @@ services: - WATCHPACK_POLLING=true - HOSTNAME=0.0.0.0 - CHOKIDAR_USEPOLLING=true + # Доска: поддомен board.uchill.online (прокси nginx на 3004) или путь на том же домене + - NEXT_PUBLIC_EXCALIDRAW_URL=${NEXT_PUBLIC_EXCALIDRAW_URL:-} + - NEXT_PUBLIC_EXCALIDRAW_PATH=${NEXT_PUBLIC_EXCALIDRAW_PATH:-/excalidraw} ports: - "3010:3000" volumes: @@ -247,6 +254,9 @@ services: dockerfile: Dockerfile container_name: platform_prod_excalidraw restart: unless-stopped + environment: + # basePath в next.config.js: иначе /_next/ запросы уходят на основной фронт и доска пустая + - NEXT_PUBLIC_BASE_PATH=/excalidraw ports: - "3004:3001" networks: diff --git a/excalidraw-server/next.config.js b/excalidraw-server/next.config.js index 3f360a2..0b8099d 100644 --- a/excalidraw-server/next.config.js +++ b/excalidraw-server/next.config.js @@ -1,11 +1,17 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - // Минимальная конфигурация для Excalidraw - reactStrictMode: false, - - // Отключаем SSR полностью - output: 'standalone', -}; - -module.exports = nextConfig; - +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Минимальная конфигурация для Excalidraw + reactStrictMode: false, + + // Прокси nginx: app.uchill.online/excalidraw/ → 3004. Без basePath запросы к /_next/ уходили бы на основной фронт. + basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '', + + // Не редиректить /excalidraw/ ↔ /excalidraw (иначе цикл с nginx/iframe) + trailingSlash: true, + + // Отключаем SSR полностью + output: 'standalone', +}; + +module.exports = nextConfig; + diff --git a/front_material/app/(auth)/layout.tsx b/front_material/app/(auth)/layout.tsx index b2c343e..8485d1c 100644 --- a/front_material/app/(auth)/layout.tsx +++ b/front_material/app/(auth)/layout.tsx @@ -1,9 +1,12 @@ +import { AuthRedirect } from '@/components/auth/AuthRedirect'; + export default function AuthLayout({ children, }: { children: React.ReactNode; }) { return ( +
+
); } diff --git a/front_material/app/(auth)/register/page.tsx b/front_material/app/(auth)/register/page.tsx index 80d8a0c..f19e907 100644 --- a/front_material/app/(auth)/register/page.tsx +++ b/front_material/app/(auth)/register/page.tsx @@ -1,530 +1,530 @@ -'use client'; - -import { useState, useEffect, useRef, useCallback } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { register } from '@/api/auth'; -import { setReferrer } from '@/api/referrals'; -import { searchCitiesFromCSV, type CityOption } from '@/api/profile'; - -const loadMaterialComponents = async () => { - await Promise.all([ - import('@material/web/textfield/filled-text-field.js'), - import('@material/web/button/filled-button.js'), - import('@material/web/button/text-button.js'), - import('@material/web/checkbox/checkbox.js'), - import('@material/web/select/filled-select.js'), - import('@material/web/select/select-option.js'), - ]); -}; - -const REFERRAL_STORAGE_KEY = 'referral_code'; - -const ROLE_OPTIONS: { value: 'mentor' | 'client' | 'parent'; label: string }[] = [ - { value: 'mentor', label: 'Ментор' }, - { value: 'client', label: 'Студент' }, - { value: 'parent', label: 'Родитель' }, -]; - -export default function RegisterPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [city, setCity] = useState(''); - const [citySearchResults, setCitySearchResults] = useState([]); - const [isCityInputFocused, setIsCityInputFocused] = useState(false); - const [isSearchingCities, setIsSearchingCities] = useState(false); - const [timezoneOverride, setTimezoneOverride] = useState(null); - const [role, setRole] = useState<'mentor' | 'client' | 'parent'>('client'); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [referralCode, setReferralCode] = useState(''); - const [showReferralField, setShowReferralField] = useState(false); - const [consent, setConsent] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [registrationSuccess, setRegistrationSuccess] = useState(false); - const [registeredEmail, setRegisteredEmail] = useState(''); - const [componentsLoaded, setComponentsLoaded] = useState(false); - const roleSelectRef = useRef(null); - - // Реферальный код: при открытии — из URL ?ref= или из localStorage - useEffect(() => { - if (typeof window === 'undefined') return; - const refFromUrl = searchParams.get('ref')?.trim() || ''; - if (refFromUrl) { - localStorage.setItem(REFERRAL_STORAGE_KEY, refFromUrl); - setReferralCode(refFromUrl); - setShowReferralField(true); - return; - } - const fromLs = localStorage.getItem(REFERRAL_STORAGE_KEY) || ''; - if (fromLs) { - setReferralCode(fromLs); - setShowReferralField(true); - } - }, [searchParams]); - - useEffect(() => { - const el = roleSelectRef.current; - if (el && role) el.value = role; - }, [role, componentsLoaded]); - - useEffect(() => { - loadMaterialComponents() - .then(() => setComponentsLoaded(true)) - .catch((err) => { - console.error('Error loading Material components:', err); - setComponentsLoaded(true); - }); - }, []); - - const getTimezoneForSubmit = () => { - if (timezoneOverride) return timezoneOverride; - if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { - return Intl.DateTimeFormat().resolvedOptions().timeZone; - } - return 'Europe/Moscow'; - }; - - const handleCitySearch = useCallback(async (query: string) => { - if (query.trim().length < 2) { - setCitySearchResults([]); - return; - } - setIsSearchingCities(true); - try { - const results = await searchCitiesFromCSV(query.trim(), 20); - setCitySearchResults(results); - } catch { - setCitySearchResults([]); - } finally { - setIsSearchingCities(false); - } - }, []); - - useEffect(() => { - const t = setTimeout(() => { - if (city.trim().length >= 2) handleCitySearch(city); - else setCitySearchResults([]); - }, 300); - return () => clearTimeout(t); - }, [city, handleCitySearch]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - if (!consent) { - setError('Необходимо согласие на обработку персональных данных'); - setLoading(false); - return; - } - - if (password !== confirmPassword) { - setError('Пароли не совпадают'); - setLoading(false); - return; - } - - try { - await register({ - email, - password, - password_confirm: confirmPassword, - first_name: firstName, - last_name: lastName, - role, - city: city.trim(), - timezone: getTimezoneForSubmit(), - }); - - // Не авторизуем сразу — требуется подтверждение email - setRegisteredEmail(email); - setRegistrationSuccess(true); - return; - } catch (err: any) { - setError( - err.response?.data?.detail || - (Array.isArray(err.response?.data?.email) - ? err.response.data.email[0] - : err.response?.data?.email) || - err.response?.data?.message || - 'Ошибка регистрации. Проверьте данные.' - ); - } finally { - setLoading(false); - } - }; - - if (!componentsLoaded) { - return ( -
-
-
- ); - } - - if (registrationSuccess) { - return ( -
-

- Регистрация -

-
-

- На адрес {registeredEmail} отправлено письмо с ссылкой для подтверждения. -

-

- Перейдите по ссылке из письма, затем войдите в аккаунт. -

- router.push('/login')} - style={{ - width: '100%', - height: '48px', - fontSize: '16px', - fontWeight: '500', - }} - > - Вернуться ко входу - -
-
- ); - } - - return ( -
- -

- Регистрация -

- -
-
- setFirstName(e.target.value || '')} - required - style={{ width: '100%' }} - /> - setLastName(e.target.value || '')} - required - style={{ width: '100%' }} - /> -
- -
- -
- setCity(e.target.value)} - onFocus={() => { - setIsCityInputFocused(true); - if (city.trim().length >= 2) handleCitySearch(city); - }} - onBlur={() => setTimeout(() => setIsCityInputFocused(false), 200)} - placeholder="Введите город" - autoComplete="off" - required - style={{ - width: '100%', - padding: '14px 16px', - borderRadius: 12, - border: '1px solid var(--md-sys-color-outline, #E6E6E6)', - background: 'var(--md-sys-color-surface-container-highest, #f5f5f5)', - fontSize: 16, - color: 'var(--md-sys-color-on-surface, #1a1a1a)', - boxSizing: 'border-box', - }} - /> - {isSearchingCities && ( -
-
-
- )} -
- {isCityInputFocused && citySearchResults.length > 0 && ( -
- {citySearchResults.map((cityOpt, idx) => ( - - ))} -
- )} -
- -
- { - const v = (e.target?.value ?? '') as string; - if (v === 'mentor' || v === 'client' || v === 'parent') setRole(v); - }} - required - style={{ width: '100%' }} - > - {ROLE_OPTIONS.map((opt) => ( - - {opt.label} - - ))} - -
- -
- setEmail(e.target.value || '')} - required - style={{ width: '100%' }} - /> -
- -
- setPassword(e.target.value || '')} - required - style={{ width: '100%' }} - /> -
- -
- setConfirmPassword(e.target.value || '')} - required - style={{ width: '100%' }} - /> -
- - {/* Реферальный код — сворачиваемый блок */} -
- -
- { - const v = (e.target?.value ?? '') as string; - setReferralCode(v); - if (typeof window !== 'undefined') localStorage.setItem(REFERRAL_STORAGE_KEY, v); - }} - style={{ width: '100%' }} - /> -
-
- -
- setConsent(!!e.target?.checked)} - style={{ flexShrink: 0, marginTop: '4px' }} - /> - -
- - {error && ( -
- {error} -
- )} - - - {loading ? 'Регистрация...' : 'Зарегистрироваться'} - - -
- router.push('/login')} style={{ fontSize: '14px' }}> - Уже есть аккаунт? Войти - -
- -
- ); -} +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { register } from '@/api/auth'; +import { setReferrer } from '@/api/referrals'; +import { searchCitiesFromCSV, type CityOption } from '@/api/profile'; + +const loadMaterialComponents = async () => { + await Promise.all([ + import('@material/web/textfield/filled-text-field.js'), + import('@material/web/button/filled-button.js'), + import('@material/web/button/text-button.js'), + import('@material/web/checkbox/checkbox.js'), + import('@material/web/select/filled-select.js'), + import('@material/web/select/select-option.js'), + ]); +}; + +const REFERRAL_STORAGE_KEY = 'referral_code'; + +const ROLE_OPTIONS: { value: 'mentor' | 'client' | 'parent'; label: string }[] = [ + { value: 'mentor', label: 'Ментор' }, + { value: 'client', label: 'Студент' }, + { value: 'parent', label: 'Родитель' }, +]; + +export default function RegisterPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [city, setCity] = useState(''); + const [citySearchResults, setCitySearchResults] = useState([]); + const [isCityInputFocused, setIsCityInputFocused] = useState(false); + const [isSearchingCities, setIsSearchingCities] = useState(false); + const [timezoneOverride, setTimezoneOverride] = useState(null); + const [role, setRole] = useState<'mentor' | 'client' | 'parent'>('client'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [referralCode, setReferralCode] = useState(''); + const [showReferralField, setShowReferralField] = useState(false); + const [consent, setConsent] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [registrationSuccess, setRegistrationSuccess] = useState(false); + const [registeredEmail, setRegisteredEmail] = useState(''); + const [componentsLoaded, setComponentsLoaded] = useState(false); + const roleSelectRef = useRef(null); + + // Реферальный код: при открытии — из URL ?ref= или из localStorage + useEffect(() => { + if (typeof window === 'undefined') return; + const refFromUrl = searchParams.get('ref')?.trim() || ''; + if (refFromUrl) { + localStorage.setItem(REFERRAL_STORAGE_KEY, refFromUrl); + setReferralCode(refFromUrl); + setShowReferralField(true); + return; + } + const fromLs = localStorage.getItem(REFERRAL_STORAGE_KEY) || ''; + if (fromLs) { + setReferralCode(fromLs); + setShowReferralField(true); + } + }, [searchParams]); + + useEffect(() => { + const el = roleSelectRef.current; + if (el && role) el.value = role; + }, [role, componentsLoaded]); + + useEffect(() => { + loadMaterialComponents() + .then(() => setComponentsLoaded(true)) + .catch((err) => { + console.error('Error loading Material components:', err); + setComponentsLoaded(true); + }); + }, []); + + const getTimezoneForSubmit = () => { + if (timezoneOverride) return timezoneOverride; + if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } + return 'Europe/Moscow'; + }; + + const handleCitySearch = useCallback(async (query: string) => { + if (query.trim().length < 2) { + setCitySearchResults([]); + return; + } + setIsSearchingCities(true); + try { + const results = await searchCitiesFromCSV(query.trim(), 20); + setCitySearchResults(results); + } catch { + setCitySearchResults([]); + } finally { + setIsSearchingCities(false); + } + }, []); + + useEffect(() => { + const t = setTimeout(() => { + if (city.trim().length >= 2) handleCitySearch(city); + else setCitySearchResults([]); + }, 300); + return () => clearTimeout(t); + }, [city, handleCitySearch]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + if (!consent) { + setError('Необходимо согласие на обработку персональных данных'); + setLoading(false); + return; + } + + if (password !== confirmPassword) { + setError('Пароли не совпадают'); + setLoading(false); + return; + } + + try { + await register({ + email, + password, + password_confirm: confirmPassword, + first_name: firstName, + last_name: lastName, + role, + city: city.trim(), + timezone: getTimezoneForSubmit(), + }); + + // Не авторизуем сразу — требуется подтверждение email + setRegisteredEmail(email); + setRegistrationSuccess(true); + return; + } catch (err: any) { + setError( + err.response?.data?.detail || + (Array.isArray(err.response?.data?.email) + ? err.response.data.email[0] + : err.response?.data?.email) || + err.response?.data?.message || + 'Ошибка регистрации. Проверьте данные.' + ); + } finally { + setLoading(false); + } + }; + + if (!componentsLoaded) { + return ( +
+
+
+ ); + } + + if (registrationSuccess) { + return ( +
+

+ Регистрация +

+
+

+ На адрес {registeredEmail} отправлено письмо с ссылкой для подтверждения. +

+

+ Перейдите по ссылке из письма, затем войдите в аккаунт. +

+ router.push('/login')} + style={{ + width: '100%', + height: '48px', + fontSize: '16px', + fontWeight: '500', + }} + > + Вернуться ко входу + +
+
+ ); + } + + return ( +
+ +

+ Регистрация +

+ +
+
+ setFirstName(e.target.value || '')} + required + style={{ width: '100%' }} + /> + setLastName(e.target.value || '')} + required + style={{ width: '100%' }} + /> +
+ +
+ +
+ setCity(e.target.value)} + onFocus={() => { + setIsCityInputFocused(true); + if (city.trim().length >= 2) handleCitySearch(city); + }} + onBlur={() => setTimeout(() => setIsCityInputFocused(false), 200)} + placeholder="Введите город" + autoComplete="off" + required + style={{ + width: '100%', + padding: '14px 16px', + borderRadius: 12, + border: '1px solid var(--md-sys-color-outline, #E6E6E6)', + background: 'var(--md-sys-color-surface-container-highest, #f5f5f5)', + fontSize: 16, + color: 'var(--md-sys-color-on-surface, #1a1a1a)', + boxSizing: 'border-box', + }} + /> + {isSearchingCities && ( +
+
+
+ )} +
+ {isCityInputFocused && citySearchResults.length > 0 && ( +
+ {citySearchResults.map((cityOpt, idx) => ( + + ))} +
+ )} +
+ +
+ { + const v = (e.target?.value ?? '') as string; + if (v === 'mentor' || v === 'client' || v === 'parent') setRole(v); + }} + required + style={{ width: '100%' }} + > + {ROLE_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + +
+ +
+ setEmail(e.target.value || '')} + required + style={{ width: '100%' }} + /> +
+ +
+ setPassword(e.target.value || '')} + required + style={{ width: '100%' }} + /> +
+ +
+ setConfirmPassword(e.target.value || '')} + required + style={{ width: '100%' }} + /> +
+ + {/* Реферальный код — сворачиваемый блок */} +
+ +
+ { + const v = (e.target?.value ?? '') as string; + setReferralCode(v); + if (typeof window !== 'undefined') localStorage.setItem(REFERRAL_STORAGE_KEY, v); + }} + style={{ width: '100%' }} + /> +
+
+ +
+ setConsent(!!e.target?.checked)} + style={{ flexShrink: 0, marginTop: '4px' }} + /> + +
+ + {error && ( +
+ {error} +
+ )} + + + {loading ? 'Регистрация...' : 'Зарегистрироваться'} + + +
+ router.push('/login')} style={{ fontSize: '14px' }}> + Уже есть аккаунт? Войти + +
+ +
+ ); +} diff --git a/front_material/app/(protected)/layout.tsx b/front_material/app/(protected)/layout.tsx index c5cf416..3fbee5e 100644 --- a/front_material/app/(protected)/layout.tsx +++ b/front_material/app/(protected)/layout.tsx @@ -1,151 +1,151 @@ -'use client'; - -import { useEffect, useState, useCallback, Suspense } from 'react'; -import { useRouter, usePathname } from 'next/navigation'; -import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; -import { TopNavigationBar } from '@/components/navigation/TopNavigationBar'; -import { NotificationBell } from '@/components/notifications/NotificationBell'; -import { useAuth } from '@/contexts/AuthContext'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { NavBadgesProvider } from '@/contexts/NavBadgesContext'; -import { SelectedChildProvider } from '@/contexts/SelectedChildContext'; -import { getNavBadges } from '@/api/navBadges'; -import { getActiveSubscription } from '@/api/subscriptions'; -import type { NavBadges } from '@/api/navBadges'; - -export default function ProtectedLayout({ - children, -}: { - children: React.ReactNode; -}) { - const router = useRouter(); - const pathname = usePathname(); - const { user, loading } = useAuth(); - const [navBadges, setNavBadges] = useState(null); - const [subscriptionChecked, setSubscriptionChecked] = useState(false); - - const refreshNavBadges = useCallback(async () => { - try { - const next = await getNavBadges(); - setNavBadges(next); - } catch { - setNavBadges(null); - } - }, []); - - useEffect(() => { - if (!user) return; - refreshNavBadges(); - }, [user, refreshNavBadges]); - - // Для ментора: редирект на /payment, если нет активной подписки (кроме самой страницы /payment) - useEffect(() => { - if (!user || user.role !== 'mentor' || pathname === '/payment') { - if (user?.role === 'mentor' && pathname === '/payment') setSubscriptionChecked(true); - return; - } - let cancelled = false; - setSubscriptionChecked(false); - getActiveSubscription() - .then((sub) => { - if (cancelled) return; - setSubscriptionChecked(true); - if (!sub) router.replace('/payment'); - }) - .catch(() => { - if (!cancelled) setSubscriptionChecked(true); - }); - return () => { cancelled = true; }; - }, [user, pathname, router]); - - useEffect(() => { - // Проверяем токен в localStorage напрямую, чтобы избежать race condition - const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; - - console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname }); - - if (!loading && !user && !token) { - console.log('[ProtectedLayout] Redirecting to login'); - router.push('/login'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, loading]); - - if (loading) { - return ( -
- -
- ); - } - - if (!user) { - return null; - } - - // Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом - const isMentorCheckingSubscription = - user.role === 'mentor' && pathname !== '/payment' && !subscriptionChecked; - if (isMentorCheckingSubscription) { - return ( -
- -
- ); - } - - // Не показываем навигацию на страницах авторизации - if (pathname?.startsWith('/login') || pathname?.startsWith('/register')) { - return <>{children}; - } - - // Для dashboard, schedule, chat, students, materials не показываем header и используем полную ширину - const isDashboard = pathname === '/dashboard'; - const isSchedule = pathname === '/schedule'; - const isChat = pathname === '/chat'; - const isStudents = pathname === '/students'; - const isMaterials = pathname === '/materials'; - const isProfile = pathname === '/profile'; - const isPayment = pathname === '/payment'; - const isAnalytics = pathname === '/analytics'; - const isReferrals = pathname === '/referrals'; - const isFeedback = pathname === '/feedback'; - const isHomework = pathname === '/homework'; - const isLiveKit = pathname?.startsWith('/livekit'); - const isMyProgress = pathname === '/my-progress'; - const isRequestMentor = pathname === '/request-mentor'; - const isFullWidthPage = isDashboard || isSchedule || isChat || isStudents || isMaterials || isProfile || isPayment || isAnalytics || isReferrals || isFeedback || isHomework || isLiveKit || isMyProgress || isRequestMentor; - - return ( - - - {!isFullWidthPage && } -
- {children} -
- {!isLiveKit && ( - - - - )} - {!isLiveKit && user && ( - - )} -
-
- ); -} +'use client'; + +import { useEffect, useState, useCallback, Suspense } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; +import { TopNavigationBar } from '@/components/navigation/TopNavigationBar'; +import { NotificationBell } from '@/components/notifications/NotificationBell'; +import { useAuth } from '@/contexts/AuthContext'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { NavBadgesProvider } from '@/contexts/NavBadgesContext'; +import { SelectedChildProvider } from '@/contexts/SelectedChildContext'; +import { getNavBadges } from '@/api/navBadges'; +import { getActiveSubscription } from '@/api/subscriptions'; +import type { NavBadges } from '@/api/navBadges'; + +export default function ProtectedLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const { user, loading } = useAuth(); + const [navBadges, setNavBadges] = useState(null); + const [subscriptionChecked, setSubscriptionChecked] = useState(false); + + const refreshNavBadges = useCallback(async () => { + try { + const next = await getNavBadges(); + setNavBadges(next); + } catch { + setNavBadges(null); + } + }, []); + + useEffect(() => { + if (!user) return; + refreshNavBadges(); + }, [user, refreshNavBadges]); + + // Для ментора: редирект на /payment, если нет активной подписки (кроме самой страницы /payment) + useEffect(() => { + if (!user || user.role !== 'mentor' || pathname === '/payment') { + if (user?.role === 'mentor' && pathname === '/payment') setSubscriptionChecked(true); + return; + } + let cancelled = false; + setSubscriptionChecked(false); + getActiveSubscription() + .then((sub) => { + if (cancelled) return; + setSubscriptionChecked(true); + if (!sub) router.replace('/payment'); + }) + .catch(() => { + if (!cancelled) setSubscriptionChecked(true); + }); + return () => { cancelled = true; }; + }, [user, pathname, router]); + + useEffect(() => { + // Проверяем токен в localStorage напрямую, чтобы избежать race condition + const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; + + console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname }); + + if (!loading && !user && !token) { + console.log('[ProtectedLayout] Redirecting to login'); + router.push('/login'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, loading]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return null; + } + + // Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом + const isMentorCheckingSubscription = + user.role === 'mentor' && pathname !== '/payment' && !subscriptionChecked; + if (isMentorCheckingSubscription) { + return ( +
+ +
+ ); + } + + // Не показываем навигацию на страницах авторизации + if (pathname?.startsWith('/login') || pathname?.startsWith('/register')) { + return <>{children}; + } + + // Для dashboard, schedule, chat, students, materials не показываем header и используем полную ширину + const isDashboard = pathname === '/dashboard'; + const isSchedule = pathname === '/schedule'; + const isChat = pathname === '/chat'; + const isStudents = pathname === '/students'; + const isMaterials = pathname === '/materials'; + const isProfile = pathname === '/profile'; + const isPayment = pathname === '/payment'; + const isAnalytics = pathname === '/analytics'; + const isReferrals = pathname === '/referrals'; + const isFeedback = pathname === '/feedback'; + const isHomework = pathname === '/homework'; + const isLiveKit = pathname?.startsWith('/livekit'); + const isMyProgress = pathname === '/my-progress'; + const isRequestMentor = pathname === '/request-mentor'; + const isFullWidthPage = isDashboard || isSchedule || isChat || isStudents || isMaterials || isProfile || isPayment || isAnalytics || isReferrals || isFeedback || isHomework || isLiveKit || isMyProgress || isRequestMentor; + + return ( + + + {!isFullWidthPage && } +
+ {children} +
+ {!isLiveKit && ( + + + + )} + {!isLiveKit && user && ( + + )} +
+
+ ); +} diff --git a/front_material/app/(protected)/students/page.tsx b/front_material/app/(protected)/students/page.tsx index ea4905d..155a35e 100644 --- a/front_material/app/(protected)/students/page.tsx +++ b/front_material/app/(protected)/students/page.tsx @@ -733,19 +733,6 @@ export default function StudentsPage() { }}>
Загрузка студентов...
- ) : filteredStudents.length === 0 ? ( - -

- Нет студентов -

-
) : (
{ + if (loading) return; + if (user) { + router.replace('/dashboard'); + } + }, [user, loading, router]); + + if (loading) { + return ( +
+
+ Загрузка... +
+
+ ); + } + + if (user) { + return null; // редирект уже идёт + } + + return <>{children}; +} diff --git a/front_material/components/board/WhiteboardIframe.tsx b/front_material/components/board/WhiteboardIframe.tsx index 551ce34..ae24042 100644 --- a/front_material/components/board/WhiteboardIframe.tsx +++ b/front_material/components/board/WhiteboardIframe.tsx @@ -1,125 +1,140 @@ -'use client'; - -import React, { useEffect, useRef } from 'react'; - -interface WhiteboardIframeProps { - boardId: string; - username?: string; - token?: string; - /** Только ментор может очищать холст. */ - isMentor?: boolean; - onToggleView?: () => void; - showingBoard?: boolean; - className?: string; - style?: React.CSSProperties; -} - -/** - * Интерактивная доска Excalidraw + Yjs. - * Iframe создаётся императивно один раз — React не пересоздаёт его при переключении. - */ -export function WhiteboardIframe({ - boardId, - username = 'Пользователь', - token: tokenProp, - isMentor = false, - showingBoard = true, - className = '', - style = {}, -}: WhiteboardIframeProps) { - const containerRef = useRef(null); - const iframeRef = useRef(null); - const createdRef = useRef(false); - - useEffect(() => { - if (typeof window === 'undefined' || !containerRef.current || !boardId) return; - if (createdRef.current) return; - - const token = tokenProp ?? localStorage.getItem('access_token') ?? ''; - const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api'; - const apiUrl = apiBase.replace(/\/api\/?$/, '') || 'http://127.0.0.1:8123'; - const excalidrawHost = `${window.location.protocol}//${window.location.hostname}:3001`; - - const url = new URL('/', excalidrawHost); - url.searchParams.set('boardId', boardId); - url.searchParams.set('apiUrl', apiUrl); - if (token) url.searchParams.set('token', token); - if (isMentor) url.searchParams.set('isMentor', '1'); - - const iframe = document.createElement('iframe'); - iframe.src = url.toString(); - iframe.style.cssText = 'width:100%;height:100%;border:none;display:block'; - iframe.title = 'Интерактивная доска (Excalidraw)'; - iframe.setAttribute('allow', 'camera; microphone; fullscreen'); - iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals'); - - const decodeName = (raw: string): string => { - if (!raw || typeof raw !== 'string') return raw; - try { - if (/%[0-9A-Fa-f]{2}/.test(raw)) return decodeURIComponent(raw); - return raw; - } catch { - return raw; - } - }; - - const sendUsername = () => { - if (iframe.contentWindow) { - try { - iframe.contentWindow.postMessage( - { type: 'excalidraw-username', username: decodeName(username) }, - url.origin - ); - } catch (_) {} - } - }; - - iframe.onload = () => { - sendUsername(); - setTimeout(sendUsername, 500); - }; - - containerRef.current.appendChild(iframe); - iframeRef.current = iframe; - createdRef.current = true; - - setTimeout(sendUsername, 300); - return () => { - createdRef.current = false; - iframeRef.current = null; - iframe.remove(); - }; - }, [boardId, tokenProp, isMentor]); - - useEffect(() => { - if (!iframeRef.current?.contentWindow || !createdRef.current) return; - const token = tokenProp ?? localStorage.getItem('access_token') ?? ''; - const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api'; - const apiUrl = apiBase.replace(/\/api\/?$/, '') || 'http://127.0.0.1:8123'; - const excalidrawHost = `${window.location.protocol}//${window.location.hostname}:3001`; - const url = new URL('/', excalidrawHost); - - const decodeName = (raw: string): string => { - if (!raw || typeof raw !== 'string') return raw; - try { - if (/%[0-9A-Fa-f]{2}/.test(raw)) return decodeURIComponent(raw); - return raw; - } catch { - return raw; - } - }; - - iframeRef.current.contentWindow.postMessage( - { type: 'excalidraw-username', username: decodeName(username) }, - url.origin - ); - }, [username]); - - return ( -
- ); -} +'use client'; + +import React, { useEffect, useRef } from 'react'; + +interface WhiteboardIframeProps { + boardId: string; + username?: string; + token?: string; + /** Только ментор может очищать холст. */ + isMentor?: boolean; + onToggleView?: () => void; + showingBoard?: boolean; + className?: string; + style?: React.CSSProperties; +} + +/** + * Интерактивная доска Excalidraw + Yjs. + * Iframe создаётся императивно один раз — React не пересоздаёт его при переключении. + */ +export function WhiteboardIframe({ + boardId, + username = 'Пользователь', + token: tokenProp, + isMentor = false, + showingBoard = true, + className = '', + style = {}, +}: WhiteboardIframeProps) { + const containerRef = useRef(null); + const iframeRef = useRef(null); + const createdRef = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined' || !containerRef.current || !boardId) return; + if (createdRef.current) return; + + const token = tokenProp ?? localStorage.getItem('access_token') ?? ''; + const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api'; + const apiUrl = apiBase.replace(/\/api\/?$/, '') || 'http://127.0.0.1:8123'; + // Вариант 1: отдельный URL доски (как LiveKit) — если app.uchill.online отдаёт один Next без nginx по путям + const excalidrawBaseUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, ''); + const url: URL = excalidrawBaseUrl + ? new URL(excalidrawBaseUrl.startsWith('http') ? excalidrawBaseUrl + '/' : `${window.location.origin}/${excalidrawBaseUrl}/`) + : (() => { + const path = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || ''; + if (path) { + return new URL((path.startsWith('/') ? path : '/' + path) + '/', window.location.origin); + } + return new URL('/', `${window.location.protocol}//${window.location.hostname}:${process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001'}`); + })(); + url.searchParams.set('boardId', boardId); + url.searchParams.set('apiUrl', apiUrl); + if (token) url.searchParams.set('token', token); + if (isMentor) url.searchParams.set('isMentor', '1'); + + const iframeSrc = excalidrawBaseUrl && excalidrawBaseUrl.startsWith('http') + ? url.toString() + : `${url.origin}${url.pathname.replace(/\/?$/, '/')}?${url.searchParams.toString()}`; + + const iframe = document.createElement('iframe'); + iframe.src = iframeSrc; + iframe.style.cssText = 'width:100%;height:100%;border:none;display:block'; + iframe.title = 'Интерактивная доска (Excalidraw)'; + iframe.setAttribute('allow', 'camera; microphone; fullscreen'); + iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals'); + + const decodeName = (raw: string): string => { + if (!raw || typeof raw !== 'string') return raw; + try { + if (/%[0-9A-Fa-f]{2}/.test(raw)) return decodeURIComponent(raw); + return raw; + } catch { + return raw; + } + }; + + const sendUsername = () => { + if (iframe.contentWindow) { + try { + iframe.contentWindow.postMessage( + { type: 'excalidraw-username', username: decodeName(username) }, + url.origin + ); + } catch (_) {} + } + }; + + iframe.onload = () => { + sendUsername(); + setTimeout(sendUsername, 500); + }; + + containerRef.current.appendChild(iframe); + iframeRef.current = iframe; + createdRef.current = true; + + setTimeout(sendUsername, 300); + return () => { + createdRef.current = false; + iframeRef.current = null; + iframe.remove(); + }; + }, [boardId, tokenProp, isMentor]); + + useEffect(() => { + if (!iframeRef.current?.contentWindow || !createdRef.current) return; + const excalidrawBaseUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, ''); + const targetOrigin = excalidrawBaseUrl.startsWith('http') + ? new URL(excalidrawBaseUrl).origin + : (() => { + const path = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || ''; + if (path) return window.location.origin; + return `${window.location.protocol}//${window.location.hostname}:${process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001'}`; + })(); + const decodeName = (raw: string): string => { + if (!raw || typeof raw !== 'string') return raw; + try { + if (/%[0-9A-Fa-f]{2}/.test(raw)) return decodeURIComponent(raw); + return raw; + } catch { + return raw; + } + }; + try { + iframeRef.current.contentWindow.postMessage( + { type: 'excalidraw-username', username: decodeName(username) }, + targetOrigin + ); + } catch (_) {} + }, [username]); + + return ( +
+ ); +} diff --git a/front_material/components/profile/ProfilePaymentTab.tsx b/front_material/components/profile/ProfilePaymentTab.tsx index e0dae99..db95c1d 100644 --- a/front_material/components/profile/ProfilePaymentTab.tsx +++ b/front_material/components/profile/ProfilePaymentTab.tsx @@ -1,212 +1,212 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import apiClient from '@/lib/api-client'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import Link from 'next/link'; -import { activateFreeSubscription, getActiveSubscription } from '@/api/subscriptions'; - -/** Подписи преимуществ из plan.features (API) */ -const FEATURE_LABELS: Record = { - video_calls: 'Видеозвонки', - screen_sharing: 'Демонстрация экрана', - whiteboard: 'Интерактивная доска', - homework: 'Домашние задания', - materials: 'Материалы', - analytics: 'Аналитика', - telegram_bot: 'Telegram-бот', - api_access: 'API', -}; - -/** Стандартные преимущества по типу тарифа (если API не вернул features) */ -const DEFAULT_FEATURES: string[] = [ - 'Видеозвонки', - 'Демонстрация экрана', - 'Интерактивная доска', - 'Домашние задания', - 'Материалы', - 'Аналитика', - 'Telegram-бот', -]; - -function getBenefitList(plan: any): string[] { - const fromApi = plan.features && typeof plan.features === 'object' - ? Object.entries(plan.features) - .filter(([, enabled]) => enabled) - .map(([key]) => FEATURE_LABELS[key] || key) - : []; - if (fromApi.length > 0) return fromApi; - // Fallback: все стандартные функции для месячных, для "за ученика" добавляем "Гибкая оплата" - if (plan.subscription_type === 'per_student') { - return ['Гибкая оплата за каждого ученика', ...DEFAULT_FEATURES]; - } - return ['Безлимит учеников', ...DEFAULT_FEATURES]; -} - -function getPlanDescription(plan: any): string { - if (plan.description) return plan.description; - if (plan.subscription_type === 'per_student') { - return 'Оплата за каждого ученика. Гибкая система в зависимости от количества.'; - } - return 'Ежемесячная подписка без ограничений по количеству учеников. Все функции доступны.'; -} - -function isFreePlan(plan: any): boolean { - const price = Number(plan.price) || 0; - const pricePerStudent = Number(plan.price_per_student) ?? 0; - if (plan.subscription_type === 'per_student') { - return pricePerStudent === 0; - } - return price === 0; -} - -const CheckIcon = () => ( - - - -); - -export function ProfilePaymentTab() { - const [plans, setPlans] = useState([]); - const [loading, setLoading] = useState(true); - const [subscription, setSubscription] = useState(null); - const [activatingPlanId, setActivatingPlanId] = useState(null); - const [activateError, setActivateError] = useState(null); - - const loadData = useCallback(async () => { - try { - const [plansRes, subRes] = await Promise.all([ - apiClient.get('/subscriptions/plans/').then((r) => r.data?.results || r.data || []), - getActiveSubscription(), - ]); - setPlans(Array.isArray(plansRes) ? plansRes : []); - setSubscription(subRes); - } catch { - setPlans([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - loadData(); - }, [loadData]); - - const handleActivateFree = async (plan: any) => { - setActivateError(null); - setActivatingPlanId(plan.id); - const body = { - plan_id: plan.id, - duration_days: 30, - student_count: plan.subscription_type === 'per_student' ? 1 : undefined, - }; - if (typeof window !== 'undefined') { - console.log('[Subscription] Отправка запроса activate_free:', body); - } - try { - await activateFreeSubscription(body); - if (typeof window !== 'undefined') { - console.log('[Subscription] activate_free успешно'); - } - await loadData(); - } catch (err: any) { - if (typeof window !== 'undefined') { - console.log('[Subscription] activate_free ошибка:', err.response?.status, err.response?.data); - } - const data = err.response?.data; - const message = - (typeof data?.error === 'string' && data.error) || - (typeof data?.detail === 'string' && data.detail) || - (Array.isArray(data?.detail) ? data.detail[0] : null) || - 'Не удалось активировать подписку'; - setActivateError(message); - } finally { - setActivatingPlanId(null); - } - }; - - if (loading) { - return ; - } - - if (plans.length === 0) { - return ( -

- Нет доступных тарифов -

- ); - } - - return ( -
- {subscription && ( -
- Текущая подписка -

{subscription.plan?.name || 'Активна'}

- {subscription.end_date && ( -

- До {new Date(subscription.end_date).toLocaleDateString('ru-RU')} -

- )} -
- )} - {activateError && ( -

- {activateError} -

- )} - ТАРИФНЫЕ ПЛАНЫ -
- {plans.slice(0, 5).map((plan: any) => { - const benefits = getBenefitList(plan); - const description = getPlanDescription(plan); - const free = isFreePlan(plan); - const priceText = plan.price_per_student - ? `${Math.round(plan.price_per_student || 0).toLocaleString('ru-RU')} ₽/уч.` - : `${Math.round(plan.price || 0).toLocaleString('ru-RU')} ₽/мес`; - return ( -
- {plan.name} -
- - {plan.price_per_student - ? Math.round(plan.price_per_student || 0).toLocaleString('ru-RU') - : Math.round(plan.price || 0).toLocaleString('ru-RU')} - - - {plan.price_per_student ? 'за ученика' : '/мес'} - -
-

{description}

-
    - {benefits.slice(0, 5).map((label) => ( -
  • - - {label} -
  • - ))} -
-
- {free ? ( - - ) : ( - - Подробнее и оплатить - - )} -
-
- ); - })} -
-
- ); -} +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import apiClient from '@/lib/api-client'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import Link from 'next/link'; +import { activateFreeSubscription, getActiveSubscription } from '@/api/subscriptions'; + +/** Подписи преимуществ из plan.features (API) */ +const FEATURE_LABELS: Record = { + video_calls: 'Видеозвонки', + screen_sharing: 'Демонстрация экрана', + whiteboard: 'Интерактивная доска', + homework: 'Домашние задания', + materials: 'Материалы', + analytics: 'Аналитика', + telegram_bot: 'Telegram-бот', + api_access: 'API', +}; + +/** Стандартные преимущества по типу тарифа (если API не вернул features) */ +const DEFAULT_FEATURES: string[] = [ + 'Видеозвонки', + 'Демонстрация экрана', + 'Интерактивная доска', + 'Домашние задания', + 'Материалы', + 'Аналитика', + 'Telegram-бот', +]; + +function getBenefitList(plan: any): string[] { + const fromApi = plan.features && typeof plan.features === 'object' + ? Object.entries(plan.features) + .filter(([, enabled]) => enabled) + .map(([key]) => FEATURE_LABELS[key] || key) + : []; + if (fromApi.length > 0) return fromApi; + // Fallback: все стандартные функции для месячных, для "за ученика" добавляем "Гибкая оплата" + if (plan.subscription_type === 'per_student') { + return ['Гибкая оплата за каждого ученика', ...DEFAULT_FEATURES]; + } + return ['Безлимит учеников', ...DEFAULT_FEATURES]; +} + +function getPlanDescription(plan: any): string { + if (plan.description) return plan.description; + if (plan.subscription_type === 'per_student') { + return 'Оплата за каждого ученика. Гибкая система в зависимости от количества.'; + } + return 'Ежемесячная подписка без ограничений по количеству учеников. Все функции доступны.'; +} + +function isFreePlan(plan: any): boolean { + const price = Number(plan.price) || 0; + const pricePerStudent = Number(plan.price_per_student) ?? 0; + if (plan.subscription_type === 'per_student') { + return pricePerStudent === 0; + } + return price === 0; +} + +const CheckIcon = () => ( + + + +); + +export function ProfilePaymentTab() { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [subscription, setSubscription] = useState(null); + const [activatingPlanId, setActivatingPlanId] = useState(null); + const [activateError, setActivateError] = useState(null); + + const loadData = useCallback(async () => { + try { + const [plansRes, subRes] = await Promise.all([ + apiClient.get('/subscriptions/plans/').then((r) => r.data?.results || r.data || []), + getActiveSubscription(), + ]); + setPlans(Array.isArray(plansRes) ? plansRes : []); + setSubscription(subRes); + } catch { + setPlans([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleActivateFree = async (plan: any) => { + setActivateError(null); + setActivatingPlanId(plan.id); + const body = { + plan_id: plan.id, + duration_days: 30, + student_count: plan.subscription_type === 'per_student' ? 1 : undefined, + }; + if (typeof window !== 'undefined') { + console.log('[Subscription] Отправка запроса activate_free:', body); + } + try { + await activateFreeSubscription(body); + if (typeof window !== 'undefined') { + console.log('[Subscription] activate_free успешно'); + } + await loadData(); + } catch (err: any) { + if (typeof window !== 'undefined') { + console.log('[Subscription] activate_free ошибка:', err.response?.status, err.response?.data); + } + const data = err.response?.data; + const message = + (typeof data?.error === 'string' && data.error) || + (typeof data?.detail === 'string' && data.detail) || + (Array.isArray(data?.detail) ? data.detail[0] : null) || + 'Не удалось активировать подписку'; + setActivateError(message); + } finally { + setActivatingPlanId(null); + } + }; + + if (loading) { + return ; + } + + if (plans.length === 0) { + return ( +

+ Нет доступных тарифов +

+ ); + } + + return ( +
+ {subscription && ( +
+ Текущая подписка +

{subscription.plan?.name || 'Активна'}

+ {subscription.end_date && ( +

+ До {new Date(subscription.end_date).toLocaleDateString('ru-RU')} +

+ )} +
+ )} + {activateError && ( +

+ {activateError} +

+ )} + ТАРИФНЫЕ ПЛАНЫ +
+ {plans.slice(0, 5).map((plan: any) => { + const benefits = getBenefitList(plan); + const description = getPlanDescription(plan); + const free = isFreePlan(plan); + const priceText = plan.price_per_student + ? `${Math.round(plan.price_per_student || 0).toLocaleString('ru-RU')} ₽/уч.` + : `${Math.round(plan.price || 0).toLocaleString('ru-RU')} ₽/мес`; + return ( +
+ {plan.name} +
+ + {plan.price_per_student + ? Math.round(plan.price_per_student || 0).toLocaleString('ru-RU') + : Math.round(plan.price || 0).toLocaleString('ru-RU')} + + + {plan.price_per_student ? 'за ученика' : '/мес'} + +
+

{description}

+
    + {benefits.slice(0, 5).map((label) => ( +
  • + + {label} +
  • + ))} +
+
+ {free ? ( + + ) : ( + + Подробнее и оплатить + + )} +
+
+ ); + })} +
+
+ ); +}