Files
igny8/frontend/src/layout/AppLayout.tsx
IGNY8 VPS (Salman) efd7193951 more fixes
2025-12-27 08:56:09 +00:00

194 lines
6.9 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 PendingPaymentBanner from "../components/billing/PendingPaymentBanner";
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 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
// All session refresh logic removed - API interceptor handles token refresh automatically on 401
// 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 content pieces remaining
const remaining = balance.credits_remaining ?? balance.credits;
const total = balance.plan_credits_per_month ?? 0;
const usagePercent = total > 0 ? (remaining / total) * 100 : 100;
let accentColor: 'blue' | 'green' | 'amber' | 'purple' = 'blue';
if (usagePercent > 50) {
accentColor = 'green';
} else if (usagePercent > 20) {
accentColor = 'blue';
} else if (usagePercent > 0) {
accentColor = 'amber';
} else {
accentColor = 'purple';
}
// Format credit value with K/M suffix for large numbers
const formatCredits = (val: number): string => {
if (val >= 1000000) {
const millions = val / 1000000;
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
}
if (val >= 1000) {
const thousands = val / 1000;
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
}
return val.toString();
};
// Set credit balance - show as "used/total Credits"
setMetrics([{
label: 'Credits',
value: total > 0 ? `${formatCredits(remaining)}/${formatCredits(total)}` : formatCredits(remaining),
accentColor,
}]);
}, [balance, isAuthenticated, setMetrics]);
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`}
>
<AppHeader />
{/* Pending Payment Banner - Shows when account status is 'pending_payment' */}
<PendingPaymentBanner className="mx-4 mt-2 md:mx-6 md:mt-2" />
<div className="px-4 pt-1.5 pb-20 md:px-6 md:pt-1.5 md:pb-24">
<Outlet />
</div>
</div>
</div>
);
};
const AppLayout: React.FC = () => {
return (
<SidebarProvider>
<LayoutContent />
</SidebarProvider>
);
};
export default AppLayout;