288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
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 { 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 { refreshUser, isAuthenticated } = useAuthStore();
|
|
const { balance, loadBalance } = useBillingStore();
|
|
const { setMetrics } = useHeaderMetrics();
|
|
const { addError } = useErrorHandler('AppLayout');
|
|
const hasLoadedSite = useRef(false);
|
|
const isLoadingSite = useRef(false);
|
|
const [debugEnabled, setDebugEnabled] = useState(false);
|
|
const lastUserRefresh = useRef<number>(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
|
|
|
|
// Sector loading moved to PageHeader component
|
|
// This ensures sectors are only loaded when content pages (Planner/Writer/Optimizer) mount
|
|
// Account/billing pages don't use PageHeader with site/sector selector, so they won't trigger sector loading
|
|
// This prevents unnecessary 404 errors on /account/plans and similar routes
|
|
|
|
// 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 (
|
|
<div className="min-h-screen xl:flex">
|
|
<div>
|
|
<AppSidebar />
|
|
<Backdrop />
|
|
</div>
|
|
<div
|
|
className={`flex-1 transition-all duration-300 ease-in-out ${
|
|
isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
|
} ${isMobileOpen ? "ml-0" : ""} w-full max-w-full min-[1440px]:max-w-[90%]`}
|
|
>
|
|
<AppHeader />
|
|
<div className="p-4 pb-20 md:p-6 md:pb-24">
|
|
<Outlet />
|
|
</div>
|
|
{/* Resource Debug Overlay - Only visible when enabled by admin */}
|
|
<ResourceDebugOverlay enabled={debugEnabled} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AppLayout: React.FC = () => {
|
|
return (
|
|
<SidebarProvider>
|
|
<LayoutContent />
|
|
</SidebarProvider>
|
|
);
|
|
};
|
|
|
|
export default AppLayout;
|