202 lines
8.0 KiB
TypeScript
202 lines
8.0 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||
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 { OnboardingProvider } from '@/contexts/OnboardingContext';
|
||
import { getNavBadges } from '@/api/navBadges';
|
||
import { getActiveSubscription } from '@/api/subscriptions';
|
||
import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals';
|
||
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 isMobile = useIsMobile();
|
||
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]);
|
||
|
||
// После входа: если в localStorage сохранён реферальный код (переход по ссылке /register?ref=...), привязываем реферера
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
const code = typeof window !== 'undefined' ? localStorage.getItem(REFERRAL_STORAGE_KEY) : null;
|
||
if (!code || !code.trim()) return;
|
||
setReferrer(code.trim())
|
||
.then(() => {
|
||
localStorage.removeItem(REFERRAL_STORAGE_KEY);
|
||
})
|
||
.catch(() => {});
|
||
}, [user]);
|
||
|
||
// Для ментора: редирект на /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 accessToken = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refresh_token') : null;
|
||
|
||
console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasAccessToken: !!accessToken, hasRefreshToken: !!refreshToken, pathname });
|
||
|
||
if (!loading && !user) {
|
||
// Если есть refresh токен, пробуем обновить сессию вместо редиректа
|
||
if (refreshToken) {
|
||
console.log('[ProtectedLayout] User lost but refresh token exists, trying to restore session...');
|
||
import('@/api/auth').then(({ refreshToken: doRefresh }) => {
|
||
doRefresh(refreshToken)
|
||
.then(({ access }) => {
|
||
if (access) {
|
||
localStorage.setItem('access_token', access);
|
||
console.log('[ProtectedLayout] Session restored, reloading user...');
|
||
window.location.reload();
|
||
} else {
|
||
console.log('[ProtectedLayout] Refresh failed, redirecting to login');
|
||
router.replace('/login');
|
||
}
|
||
})
|
||
.catch(() => {
|
||
console.log('[ProtectedLayout] Refresh error, redirecting to login');
|
||
localStorage.removeItem('access_token');
|
||
localStorage.removeItem('refresh_token');
|
||
router.replace('/login');
|
||
});
|
||
});
|
||
} else {
|
||
console.log('[ProtectedLayout] No user and no refresh token, redirecting to login');
|
||
router.replace('/login');
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [user, loading]);
|
||
|
||
// Стабильный loading layout - предотвращает дёрганье
|
||
const loadingLayout = (
|
||
<div className="protected-layout-root">
|
||
<main className="protected-main" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||
<LoadingSpinner size="large" />
|
||
</main>
|
||
</div>
|
||
);
|
||
|
||
if (loading) {
|
||
return loadingLayout;
|
||
}
|
||
|
||
if (!user) {
|
||
return loadingLayout;
|
||
}
|
||
|
||
// Ментор не на /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>
|
||
<OnboardingProvider>
|
||
<div className="protected-layout-root">
|
||
{!isFullWidthPage && <TopNavigationBar user={user} />}
|
||
<main
|
||
className="protected-main"
|
||
data-no-nav={isLiveKit ? true : undefined}
|
||
data-full-width={isFullWidthPage ? 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}
|
||
notificationsSlot={isMobile && user ? <NotificationBell embedded /> : null}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
{!isLiveKit && user && !isMobile && (
|
||
<NotificationBell />
|
||
)}
|
||
</div>
|
||
</OnboardingProvider>
|
||
</SelectedChildProvider>
|
||
</NavBadgesProvider>
|
||
);
|
||
}
|