Загрузка студентов...
- ) : 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 ? (
+
+ ) : (
+
+ Подробнее и оплатить
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}