prod: доска board, nginx, WhiteboardIframe EXCALIDRAW_URL
Deploy to Production / deploy-production (push) Successful in 26s
Details
Deploy to Production / deploy-production (push) Successful in 26s
Details
This commit is contained in:
parent
d722ff49bd
commit
083fd4d826
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { AuthRedirect } from '@/components/auth/AuthRedirect';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthRedirect>
|
||||
<div
|
||||
data-no-nav
|
||||
style={{
|
||||
|
|
@ -56,5 +59,6 @@ export default function AuthLayout({
|
|||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</AuthRedirect>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<NavBadges | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
||||
const isMentorCheckingSubscription =
|
||||
user.role === 'mentor' && pathname !== '/payment' && !subscriptionChecked;
|
||||
if (isMentorCheckingSubscription) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Не показываем навигацию на страницах авторизации
|
||||
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 (
|
||||
<NavBadgesProvider refreshNavBadges={refreshNavBadges}>
|
||||
<SelectedChildProvider>
|
||||
{!isFullWidthPage && <TopNavigationBar user={user} />}
|
||||
<main
|
||||
data-no-nav={isLiveKit ? true : undefined}
|
||||
style={{
|
||||
padding: isFullWidthPage ? '0' : '16px',
|
||||
maxWidth: isFullWidthPage ? '100%' : '1200px',
|
||||
margin: isFullWidthPage ? '0' : '0 auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{!isLiveKit && (
|
||||
<Suspense fallback={null}>
|
||||
<BottomNavigationBar userRole={user?.role} user={user} navBadges={navBadges} />
|
||||
</Suspense>
|
||||
)}
|
||||
{!isLiveKit && user && (
|
||||
<NotificationBell />
|
||||
)}
|
||||
</SelectedChildProvider>
|
||||
</NavBadgesProvider>
|
||||
);
|
||||
}
|
||||
'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<NavBadges | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
||||
const isMentorCheckingSubscription =
|
||||
user.role === 'mentor' && pathname !== '/payment' && !subscriptionChecked;
|
||||
if (isMentorCheckingSubscription) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Не показываем навигацию на страницах авторизации
|
||||
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 (
|
||||
<NavBadgesProvider refreshNavBadges={refreshNavBadges}>
|
||||
<SelectedChildProvider>
|
||||
{!isFullWidthPage && <TopNavigationBar user={user} />}
|
||||
<main
|
||||
data-no-nav={isLiveKit ? true : undefined}
|
||||
style={{
|
||||
padding: isFullWidthPage ? '0' : '16px',
|
||||
maxWidth: isFullWidthPage ? '100%' : '1200px',
|
||||
margin: isFullWidthPage ? '0' : '0 auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{!isLiveKit && (
|
||||
<Suspense fallback={null}>
|
||||
<BottomNavigationBar userRole={user?.role} user={user} navBadges={navBadges} />
|
||||
</Suspense>
|
||||
)}
|
||||
{!isLiveKit && user && (
|
||||
<NotificationBell />
|
||||
)}
|
||||
</SelectedChildProvider>
|
||||
</NavBadgesProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -733,19 +733,6 @@ export default function StudentsPage() {
|
|||
}}>
|
||||
<div>Загрузка студентов...</div>
|
||||
</div>
|
||||
) : filteredStudents.length === 0 ? (
|
||||
<md-elevated-card style={{
|
||||
padding: '40px',
|
||||
borderRadius: '20px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--md-sys-color-on-surface-variant)'
|
||||
}}>
|
||||
Нет студентов
|
||||
</p>
|
||||
</md-elevated-card>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* Если пользователь авторизован — редирект на дашборд.
|
||||
* Страницы логина/регистрации и т.д. не должны быть доступны авторизованным.
|
||||
*/
|
||||
export function AuthRedirect({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (user) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return null; // редирект уже идёт
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ width: '100%', height: '100%', position: 'relative', ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
'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<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ width: '100%', height: '100%', position: 'relative', ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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 = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16.7071 5.29289C17.0976 5.68342 17.0976 6.31658 16.7071 6.70711L8.70711 14.7071C8.31658 15.0976 7.68342 15.0976 7.29289 14.7071L3.29289 10.7071C2.90237 10.3166 2.90237 9.68342 3.29289 9.29289C3.68342 8.90237 4.31658 8.90237 4.70711 9.29289L8 12.5858L15.2929 5.29289C15.6834 4.90237 16.3166 4.90237 16.7071 5.29289Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ProfilePaymentTab() {
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [subscription, setSubscription] = useState<any>(null);
|
||||
const [activatingPlanId, setActivatingPlanId] = useState<number | null>(null);
|
||||
const [activateError, setActivateError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [plansRes, subRes] = await Promise.all([
|
||||
apiClient.get<any>('/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 <LoadingSpinner size="medium" />;
|
||||
}
|
||||
|
||||
if (plans.length === 0) {
|
||||
return (
|
||||
<p style={{ color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||
Нет доступных тарифов
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ios26-payment-tab">
|
||||
{subscription && (
|
||||
<div className="ios26-plan-card ios26-plan-card--current">
|
||||
<span className="ios26-plan-card__overline">Текущая подписка</span>
|
||||
<h3 className="ios26-plan-card__headline">{subscription.plan?.name || 'Активна'}</h3>
|
||||
{subscription.end_date && (
|
||||
<p className="ios26-plan-card__supporting">
|
||||
До {new Date(subscription.end_date).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activateError && (
|
||||
<p style={{ color: 'var(--md-sys-color-error)', fontSize: 14, marginBottom: 12 }}>
|
||||
{activateError}
|
||||
</p>
|
||||
)}
|
||||
<span className="ios26-payment-tab__label">ТАРИФНЫЕ ПЛАНЫ</span>
|
||||
<div className="ios26-plan-card-grid">
|
||||
{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 (
|
||||
<article key={plan.id} className="ios26-plan-card ios26-plan-card--elevated">
|
||||
<span className="ios26-plan-card__badge">{plan.name}</span>
|
||||
<div className="ios26-plan-card__price-block">
|
||||
<span className="ios26-plan-card__price-value">
|
||||
{plan.price_per_student
|
||||
? Math.round(plan.price_per_student || 0).toLocaleString('ru-RU')
|
||||
: Math.round(plan.price || 0).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
<span className="ios26-plan-card__price-period">
|
||||
{plan.price_per_student ? 'за ученика' : '/мес'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="ios26-plan-card__supporting">{description}</p>
|
||||
<ul className="ios26-plan-card__benefits" aria-label="Преимущества">
|
||||
{benefits.slice(0, 5).map((label) => (
|
||||
<li key={label} className="ios26-plan-card__benefit">
|
||||
<span className="ios26-plan-card__benefit-icon"><CheckIcon /></span>
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="ios26-plan-card__actions">
|
||||
{free ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ios26-plan-card__action"
|
||||
onClick={() => handleActivateFree(plan)}
|
||||
disabled={!!activatingPlanId}
|
||||
style={{ cursor: activatingPlanId ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{activatingPlanId === plan.id ? 'Активация...' : 'Активировать'}
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/payment" className="ios26-plan-card__action">
|
||||
Подробнее и оплатить
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'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<string, string> = {
|
||||
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 = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16.7071 5.29289C17.0976 5.68342 17.0976 6.31658 16.7071 6.70711L8.70711 14.7071C8.31658 15.0976 7.68342 15.0976 7.29289 14.7071L3.29289 10.7071C2.90237 10.3166 2.90237 9.68342 3.29289 9.29289C3.68342 8.90237 4.31658 8.90237 4.70711 9.29289L8 12.5858L15.2929 5.29289C15.6834 4.90237 16.3166 4.90237 16.7071 5.29289Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ProfilePaymentTab() {
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [subscription, setSubscription] = useState<any>(null);
|
||||
const [activatingPlanId, setActivatingPlanId] = useState<number | null>(null);
|
||||
const [activateError, setActivateError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [plansRes, subRes] = await Promise.all([
|
||||
apiClient.get<any>('/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 <LoadingSpinner size="medium" />;
|
||||
}
|
||||
|
||||
if (plans.length === 0) {
|
||||
return (
|
||||
<p style={{ color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||
Нет доступных тарифов
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ios26-payment-tab">
|
||||
{subscription && (
|
||||
<div className="ios26-plan-card ios26-plan-card--current">
|
||||
<span className="ios26-plan-card__overline">Текущая подписка</span>
|
||||
<h3 className="ios26-plan-card__headline">{subscription.plan?.name || 'Активна'}</h3>
|
||||
{subscription.end_date && (
|
||||
<p className="ios26-plan-card__supporting">
|
||||
До {new Date(subscription.end_date).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activateError && (
|
||||
<p style={{ color: 'var(--md-sys-color-error)', fontSize: 14, marginBottom: 12 }}>
|
||||
{activateError}
|
||||
</p>
|
||||
)}
|
||||
<span className="ios26-payment-tab__label">ТАРИФНЫЕ ПЛАНЫ</span>
|
||||
<div className="ios26-plan-card-grid">
|
||||
{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 (
|
||||
<article key={plan.id} className="ios26-plan-card ios26-plan-card--elevated">
|
||||
<span className="ios26-plan-card__badge">{plan.name}</span>
|
||||
<div className="ios26-plan-card__price-block">
|
||||
<span className="ios26-plan-card__price-value">
|
||||
{plan.price_per_student
|
||||
? Math.round(plan.price_per_student || 0).toLocaleString('ru-RU')
|
||||
: Math.round(plan.price || 0).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
<span className="ios26-plan-card__price-period">
|
||||
{plan.price_per_student ? 'за ученика' : '/мес'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="ios26-plan-card__supporting">{description}</p>
|
||||
<ul className="ios26-plan-card__benefits" aria-label="Преимущества">
|
||||
{benefits.slice(0, 5).map((label) => (
|
||||
<li key={label} className="ios26-plan-card__benefit">
|
||||
<span className="ios26-plan-card__benefit-icon"><CheckIcon /></span>
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="ios26-plan-card__actions">
|
||||
{free ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ios26-plan-card__action"
|
||||
onClick={() => handleActivateFree(plan)}
|
||||
disabled={!!activatingPlanId}
|
||||
style={{ cursor: activatingPlanId ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{activatingPlanId === plan.id ? 'Активация...' : 'Активировать'}
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/payment" className="ios26-plan-card__action">
|
||||
Подробнее и оплатить
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue