import { useEffect, useRef, useState } from "react"; import { SidebarProvider, useSidebar } from "../context/SidebarContext"; import { Outlet } from "react-router-dom"; import AppHeader from "./AppHeader"; import Backdrop from "./Backdrop"; import AppSidebar from "./AppSidebar"; import { useSiteStore } from "../store/siteStore"; import { useSectorStore } from "../store/sectorStore"; import { useAuthStore } from "../store/authStore"; import { useBillingStore } from "../store/billingStore"; import { useHeaderMetrics } from "../context/HeaderMetricsContext"; import { useErrorHandler } from "../hooks/useErrorHandler"; import { trackLoading } from "../components/common/LoadingStateMonitor"; import ResourceDebugOverlay from "../components/debug/ResourceDebugOverlay"; const LayoutContent: React.FC = () => { const { isExpanded, isHovered, isMobileOpen } = useSidebar(); const { loadActiveSite, activeSite } = useSiteStore(); const { loadSectorsForSite } = useSectorStore(); const { refreshUser, isAuthenticated } = useAuthStore(); const { balance, loadBalance } = useBillingStore(); const { setMetrics } = useHeaderMetrics(); const { addError } = useErrorHandler('AppLayout'); const hasLoadedSite = useRef(false); const lastSiteId = useRef(null); const isLoadingSite = useRef(false); const isLoadingSector = useRef(false); const [debugEnabled, setDebugEnabled] = useState(false); const lastUserRefresh = useRef(0); // Initialize site store on mount - only once, but only if authenticated useEffect(() => { // Only load sites if user is authenticated AND has a token if (!isAuthenticated) return; // Check if token exists - if not, wait a bit for Zustand persist to write it const checkTokenAndLoad = () => { const authState = useAuthStore.getState(); if (!authState?.token) { // Token not available yet - wait a bit and retry (Zustand persist might still be writing) setTimeout(() => { const retryAuthState = useAuthStore.getState(); if (retryAuthState?.token && !hasLoadedSite.current && !isLoadingSite.current) { loadSites(); } }, 100); // Wait 100ms for persist to write return; } loadSites(); }; const loadSites = () => { if (!hasLoadedSite.current && !isLoadingSite.current) { hasLoadedSite.current = true; isLoadingSite.current = true; trackLoading('site-loading', true); // Add timeout to prevent infinite loading // Match API timeout (30s) + buffer for network delays const timeoutId = setTimeout(() => { if (isLoadingSite.current) { console.error('AppLayout: Site loading timeout after 35 seconds'); trackLoading('site-loading', false); isLoadingSite.current = false; addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite'); } }, 35000); // 35 seconds to match API timeout (30s) + buffer loadActiveSite() .catch((error) => { // Don't log 403 errors as they're expected when not authenticated if (error.status !== 403) { console.error('AppLayout: Error loading active site:', error); addError(error, 'AppLayout.loadActiveSite'); } }) .finally(() => { clearTimeout(timeoutId); trackLoading('site-loading', false); isLoadingSite.current = false; }); } }; checkTokenAndLoad(); }, [isAuthenticated]); // Run when authentication state changes // Load sectors when active site changes (by ID, not object reference) useEffect(() => { const currentSiteId = activeSite?.id ?? null; // Only load if: // 1. We have a site ID // 2. The site is active (inactive sites can't have accessible sectors) // 3. It's different from the last one we loaded // 4. We're not already loading if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) { lastSiteId.current = currentSiteId; isLoadingSector.current = true; trackLoading('sector-loading', true); // Add timeout to prevent infinite loading // Match API timeout (30s) + buffer for network delays const timeoutId = setTimeout(() => { if (isLoadingSector.current) { console.error('AppLayout: Sector loading timeout after 35 seconds'); trackLoading('sector-loading', false); isLoadingSector.current = false; addError(new Error('Sector loading timeout - check network connection'), 'AppLayout.loadSectorsForSite'); } }, 35000); // 35 seconds to match API timeout (30s) + buffer loadSectorsForSite(currentSiteId) .catch((error) => { // Don't log 403/404 errors as they're expected for inactive sites if (error.status !== 403 && error.status !== 404) { console.error('AppLayout: Error loading sectors:', error); addError(error, 'AppLayout.loadSectorsForSite'); } }) .finally(() => { clearTimeout(timeoutId); trackLoading('sector-loading', false); isLoadingSector.current = false; }); } else if (currentSiteId && !activeSite?.is_active) { // Site is inactive - clear sectors and reset lastSiteId lastSiteId.current = null; const { useSectorStore } = require('../store/sectorStore'); useSectorStore.getState().clearActiveSector(); } }, [activeSite?.id, activeSite?.is_active]); // Depend on both ID and is_active // Refresh user data on mount and when app version changes (after code updates) // This ensures changes are reflected immediately without requiring re-login useEffect(() => { if (!isAuthenticated) return; const APP_VERSION = import.meta.env.VITE_APP_VERSION || '2.0.2'; const VERSION_STORAGE_KEY = 'igny8-app-version'; const refreshUserData = async (force = false) => { const now = Date.now(); // Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced) if (!force && now - lastUserRefresh.current < 30000) return; // Check if token exists before making API call const authState = useAuthStore.getState(); if (!authState?.token) { // Token not available yet - wait a bit for Zustand persist to write it setTimeout(() => { const retryAuthState = useAuthStore.getState(); if (retryAuthState?.token && retryAuthState?.isAuthenticated) { refreshUserData(force); } }, 100); // Wait 100ms for persist to write return; } try { lastUserRefresh.current = now; await refreshUser(); // Store current version after successful refresh if (force) { localStorage.setItem(VERSION_STORAGE_KEY, APP_VERSION); } } catch (error) { // Silently fail - user might still be authenticated console.debug('User data refresh failed (non-critical):', error); } }; // Check if app version changed (indicates code update) const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY); if (storedVersion !== APP_VERSION) { // Force refresh on version change refreshUserData(true); } else { // Normal refresh on mount refreshUserData(); } // Refresh when window becomes visible (user switches back to tab) const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { refreshUserData(); } }; // Refresh on window focus const handleFocus = () => { refreshUserData(); }; // Proactive token refresh - refresh token every 12 minutes (before 15-minute expiry) // This prevents 401 errors and ensures seamless user experience const tokenRefreshInterval = setInterval(async () => { const authState = useAuthStore.getState(); const refreshToken = authState?.refreshToken; if (refreshToken && authState?.isAuthenticated) { try { await authState.refreshToken(); console.debug('Token proactively refreshed'); } catch (error) { console.debug('Proactive token refresh failed (will retry on next API call):', error); } } }, 720000); // 12 minutes = 720000ms // Periodic user data refresh every 2 minutes const intervalId = setInterval(() => refreshUserData(), 120000); document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('focus', handleFocus); return () => { clearInterval(tokenRefreshInterval); clearInterval(intervalId); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleFocus); }; }, [isAuthenticated, refreshUser]); // Load credit balance and set in header metrics useEffect(() => { if (!isAuthenticated) { setMetrics([]); return; } const billingState = useBillingStore.getState(); // Load balance if not already loaded and not currently loading if (!balance && !billingState.loading) { loadBalance().catch((error) => { console.error('AppLayout: Error loading credit balance:', error); // Don't show error to user - balance is not critical for app functionality // But retry after a delay setTimeout(() => { if (!useBillingStore.getState().balance && !useBillingStore.getState().loading) { loadBalance().catch(() => { // Silently fail on retry too }); } }, 5000); }); } }, [isAuthenticated, balance, loadBalance, setMetrics]); // Update header metrics when balance changes // This sets credit balance which will be merged with page metrics by HeaderMetricsContext useEffect(() => { if (!isAuthenticated) { // Only clear metrics when not authenticated (user logged out) setMetrics([]); return; } // If balance is null, don't clear metrics - let page metrics stay visible // Only set credit metrics when balance is loaded if (!balance) { return; } // Determine accent color based on credit level let accentColor: 'blue' | 'green' | 'amber' | 'purple' = 'blue'; if (balance.credits > 1000) { accentColor = 'green'; } else if (balance.credits > 100) { accentColor = 'blue'; } else if (balance.credits > 0) { accentColor = 'amber'; } else { accentColor = 'purple'; } // Set credit balance (single metric with label "Credits" - HeaderMetricsContext will merge it) setMetrics([{ label: 'Credits', value: balance.credits, accentColor, }]); }, [balance, isAuthenticated, setMetrics]); // Listen for debug toggle changes useEffect(() => { const saved = localStorage.getItem('debug_resource_tracking_enabled'); setDebugEnabled(saved === 'true'); const handleToggle = (e: Event) => { const customEvent = e as CustomEvent; setDebugEnabled(customEvent.detail); }; window.addEventListener('debug-resource-tracking-toggle', handleToggle); return () => { window.removeEventListener('debug-resource-tracking-toggle', handleToggle); }; }, []); return (
{/* Resource Debug Overlay - Only visible when enabled by admin */}
); }; const AppLayout: React.FC = () => { return ( ); }; export default AppLayout;