2025-08-28 10:41:04 +02:00
|
|
|
import React, { useState, useCallback, forwardRef } from 'react';
|
|
|
|
|
import { clsx } from 'clsx';
|
|
|
|
|
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
|
|
|
|
import { useTheme } from '../../../contexts/ThemeContext';
|
2025-09-05 17:49:48 +02:00
|
|
|
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
|
2025-10-03 14:09:34 +02:00
|
|
|
import { useHasAccess } from '../../../hooks/useAccessControl';
|
2025-08-28 10:41:04 +02:00
|
|
|
import { Header } from '../Header';
|
|
|
|
|
import { Sidebar } from '../Sidebar';
|
|
|
|
|
import { Footer } from '../Footer';
|
2025-10-03 14:09:34 +02:00
|
|
|
import { DemoBanner } from '../DemoBanner';
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
export interface AppShellProps {
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
/**
|
|
|
|
|
* Control sidebar visibility
|
|
|
|
|
*/
|
|
|
|
|
showSidebar?: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Control header visibility
|
|
|
|
|
*/
|
|
|
|
|
showHeader?: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Control footer visibility
|
|
|
|
|
*/
|
|
|
|
|
showFooter?: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Make the layout fullscreen (hide header and footer)
|
|
|
|
|
*/
|
|
|
|
|
fullScreen?: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Add padding to the main content area
|
|
|
|
|
*/
|
|
|
|
|
padded?: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Initial sidebar collapsed state
|
|
|
|
|
*/
|
|
|
|
|
initialSidebarCollapsed?: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Custom loading component
|
|
|
|
|
*/
|
|
|
|
|
loadingComponent?: React.ReactNode;
|
|
|
|
|
/**
|
|
|
|
|
* Custom error boundary
|
|
|
|
|
*/
|
|
|
|
|
errorBoundary?: React.ComponentType<{ children: React.ReactNode; error?: Error }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AppShellRef {
|
|
|
|
|
toggleSidebar: () => void;
|
|
|
|
|
collapseSidebar: () => void;
|
|
|
|
|
expandSidebar: () => void;
|
|
|
|
|
isSidebarOpen: boolean;
|
|
|
|
|
isSidebarCollapsed: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* AppShell - Main application shell that combines header, sidebar, and main content area
|
|
|
|
|
*
|
|
|
|
|
* Features:
|
|
|
|
|
* - Responsive layout with collapsible sidebar
|
|
|
|
|
* - Mobile-friendly with drawer overlay
|
|
|
|
|
* - Content area that adjusts to sidebar state
|
|
|
|
|
* - Supports fullscreen mode for special pages
|
|
|
|
|
* - Integrated with authentication and theming
|
|
|
|
|
*/
|
|
|
|
|
export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
|
|
|
|
children,
|
|
|
|
|
className,
|
|
|
|
|
showSidebar = true,
|
|
|
|
|
showHeader = true,
|
|
|
|
|
showFooter = true,
|
|
|
|
|
fullScreen = false,
|
|
|
|
|
padded = true,
|
|
|
|
|
initialSidebarCollapsed = false,
|
|
|
|
|
loadingComponent,
|
|
|
|
|
errorBoundary: ErrorBoundary,
|
|
|
|
|
}, ref) => {
|
|
|
|
|
const authLoading = false; // Since we're in a protected route, auth loading should be false
|
|
|
|
|
const { resolvedTheme } = useTheme();
|
2025-10-03 14:09:34 +02:00
|
|
|
const hasAccess = useHasAccess(); // Check both authentication and demo mode
|
|
|
|
|
|
2025-09-03 18:29:56 +02:00
|
|
|
// Initialize tenant data for authenticated users
|
|
|
|
|
useTenantInitializer();
|
|
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
|
|
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
|
|
|
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
|
|
|
|
|
|
// Sidebar control functions
|
|
|
|
|
const toggleSidebar = useCallback(() => {
|
|
|
|
|
if (window.innerWidth < 1024) {
|
|
|
|
|
// Mobile: toggle drawer
|
|
|
|
|
setIsSidebarOpen(prev => !prev);
|
|
|
|
|
} else {
|
|
|
|
|
// Desktop: toggle collapsed state
|
|
|
|
|
setIsSidebarCollapsed(prev => !prev);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const collapseSidebar = useCallback(() => {
|
|
|
|
|
if (window.innerWidth < 1024) {
|
|
|
|
|
setIsSidebarOpen(false);
|
|
|
|
|
} else {
|
|
|
|
|
setIsSidebarCollapsed(true);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const expandSidebar = useCallback(() => {
|
|
|
|
|
if (window.innerWidth < 1024) {
|
|
|
|
|
setIsSidebarOpen(true);
|
|
|
|
|
} else {
|
|
|
|
|
setIsSidebarCollapsed(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Close mobile sidebar when clicking outside
|
|
|
|
|
const handleOverlayClick = useCallback(() => {
|
|
|
|
|
if (window.innerWidth < 1024) {
|
|
|
|
|
setIsSidebarOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Expose ref methods
|
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
|
|
|
toggleSidebar,
|
|
|
|
|
collapseSidebar,
|
|
|
|
|
expandSidebar,
|
|
|
|
|
isSidebarOpen,
|
|
|
|
|
isSidebarCollapsed,
|
|
|
|
|
}), [toggleSidebar, collapseSidebar, expandSidebar, isSidebarOpen, isSidebarCollapsed]);
|
|
|
|
|
|
2025-08-28 23:40:44 +02:00
|
|
|
// Handle responsive sidebar state and prevent body scroll on mobile
|
2025-08-28 10:41:04 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
if (window.innerWidth >= 1024) {
|
|
|
|
|
// Desktop: close mobile drawer
|
|
|
|
|
setIsSidebarOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-28 23:40:44 +02:00
|
|
|
// Prevent body scroll when mobile sidebar is open
|
|
|
|
|
if (isSidebarOpen && window.innerWidth < 1024) {
|
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
|
document.body.style.position = 'fixed';
|
|
|
|
|
document.body.style.width = '100%';
|
|
|
|
|
} else {
|
|
|
|
|
document.body.style.overflow = '';
|
|
|
|
|
document.body.style.position = '';
|
|
|
|
|
document.body.style.width = '';
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
window.addEventListener('resize', handleResize);
|
2025-08-28 23:40:44 +02:00
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('resize', handleResize);
|
|
|
|
|
// Cleanup on unmount
|
|
|
|
|
document.body.style.overflow = '';
|
|
|
|
|
document.body.style.position = '';
|
|
|
|
|
document.body.style.width = '';
|
|
|
|
|
};
|
|
|
|
|
}, [isSidebarOpen]);
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
// Error boundary handling
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const handleError = (event: ErrorEvent) => {
|
|
|
|
|
setError(new Error(event.message));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
|
|
|
setError(new Error(event.reason?.message || 'Promise rejection'));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('error', handleError);
|
|
|
|
|
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('error', handleError);
|
|
|
|
|
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Show loading state
|
|
|
|
|
if (authLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
|
|
|
|
{loadingComponent || (
|
|
|
|
|
<div className="flex flex-col items-center gap-4">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)]"></div>
|
|
|
|
|
<p className="text-[var(--text-secondary)]">Cargando aplicación...</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Show error state
|
|
|
|
|
if (error && ErrorBoundary) {
|
|
|
|
|
return <ErrorBoundary error={error}>{children}</ErrorBoundary>;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-03 14:09:34 +02:00
|
|
|
const shouldShowSidebar = showSidebar && hasAccess && !fullScreen;
|
2025-08-28 10:41:04 +02:00
|
|
|
const shouldShowHeader = showHeader && !fullScreen;
|
|
|
|
|
const shouldShowFooter = showFooter && !fullScreen;
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-03 14:09:34 +02:00
|
|
|
<div
|
2025-08-28 10:41:04 +02:00
|
|
|
className={clsx(
|
|
|
|
|
'min-h-screen bg-[var(--bg-primary)] flex flex-col',
|
|
|
|
|
resolvedTheme,
|
|
|
|
|
className
|
|
|
|
|
)}
|
|
|
|
|
data-testid="app-shell"
|
|
|
|
|
>
|
2025-10-03 14:09:34 +02:00
|
|
|
{/* Demo Banner */}
|
|
|
|
|
<DemoBanner />
|
|
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
{/* Header */}
|
|
|
|
|
{shouldShowHeader && (
|
|
|
|
|
<Header
|
2025-09-24 19:40:51 +02:00
|
|
|
onMenuClick={toggleSidebar}
|
|
|
|
|
sidebarCollapsed={isSidebarCollapsed}
|
|
|
|
|
className="z-[var(--z-fixed)]"
|
|
|
|
|
/>
|
2025-08-28 10:41:04 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-1 relative">
|
|
|
|
|
{/* Sidebar */}
|
|
|
|
|
{shouldShowSidebar && (
|
|
|
|
|
<>
|
|
|
|
|
<Sidebar
|
|
|
|
|
isOpen={isSidebarOpen}
|
|
|
|
|
isCollapsed={isSidebarCollapsed}
|
|
|
|
|
onClose={() => setIsSidebarOpen(false)}
|
2025-08-28 18:07:16 +02:00
|
|
|
onToggleCollapse={toggleSidebar}
|
2025-08-28 10:41:04 +02:00
|
|
|
className="z-[var(--z-fixed)]"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Mobile overlay */}
|
|
|
|
|
{isSidebarOpen && (
|
|
|
|
|
<div
|
2025-08-28 23:40:44 +02:00
|
|
|
className="fixed inset-0 bg-black/50 z-[var(--z-modal-backdrop)] lg:hidden backdrop-blur-sm"
|
2025-08-28 10:41:04 +02:00
|
|
|
onClick={handleOverlayClick}
|
2025-08-28 23:40:44 +02:00
|
|
|
onTouchStart={handleOverlayClick}
|
2025-08-28 10:41:04 +02:00
|
|
|
aria-hidden="true"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Main content */}
|
|
|
|
|
<main
|
|
|
|
|
className={clsx(
|
|
|
|
|
'flex-1 flex flex-col transition-all duration-300 ease-in-out',
|
2025-08-30 19:21:15 +02:00
|
|
|
'overflow-x-hidden', // Prevent horizontal overflow
|
2025-08-28 10:41:04 +02:00
|
|
|
// Add header offset
|
|
|
|
|
shouldShowHeader && 'pt-[var(--header-height)]',
|
2025-08-28 18:07:16 +02:00
|
|
|
// Adjust margins based on sidebar state
|
2025-10-03 14:09:34 +02:00
|
|
|
shouldShowSidebar && hasAccess && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
|
|
|
|
shouldShowSidebar && hasAccess && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]',
|
2025-08-28 10:41:04 +02:00
|
|
|
// Add padding to content
|
2025-08-28 18:07:16 +02:00
|
|
|
padded && 'px-4 lg:px-6 pb-4 lg:pb-6'
|
2025-08-28 10:41:04 +02:00
|
|
|
)}
|
|
|
|
|
role="main"
|
|
|
|
|
aria-label="Contenido principal"
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
|
|
|
{shouldShowFooter && (
|
|
|
|
|
<Footer
|
|
|
|
|
compact={true}
|
|
|
|
|
showPrivacyLinks={true}
|
|
|
|
|
className={clsx(
|
|
|
|
|
'transition-all duration-300 ease-in-out',
|
2025-10-03 14:09:34 +02:00
|
|
|
shouldShowSidebar && hasAccess && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
|
|
|
|
shouldShowSidebar && hasAccess && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]'
|
2025-08-28 10:41:04 +02:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
AppShell.displayName = 'AppShell';
|