639 lines
18 KiB
TypeScript

// components/layout/Header.tsx
import React, { useEffect, useState } from 'react';
import { NavigateFunction, useLocation } from 'react-router-dom';
import {
AppBar,
Toolbar,
Tooltip,
Typography,
Button,
IconButton,
Box,
Drawer,
Divider,
Avatar,
Tabs,
Tab,
Container,
Fade,
Popover,
Paper,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Collapse,
List,
ListItem,
ListItemButton,
SxProps,
} from '@mui/material';
import { styled, useTheme } from '@mui/material/styles';
import {
Menu as MenuIcon,
Dashboard,
Person,
Logout,
Settings,
ExpandMore,
ExpandLess,
KeyboardArrowDown,
} from '@mui/icons-material';
import FaceRetouchingNaturalIcon from '@mui/icons-material/FaceRetouchingNatural';
import { getUserMenuItemsByGroup } from 'config/navigationConfig';
import { NavigationItem } from 'types/navigation';
import { Beta } from 'components/ui/Beta';
import { Candidate, Employer } from 'types/types';
import { SetSnackType } from 'components/Snack';
import { CopyBubble } from 'components/CopyBubble';
import 'components/layout/Header.css';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
// Styled components
const StyledAppBar = styled(AppBar, {
shouldForwardProp: (prop) => prop !== 'transparent',
})<{ transparent?: boolean }>(({ theme, transparent }) => ({
backgroundColor: transparent ? 'transparent' : theme.palette.primary.main,
boxShadow: transparent ? 'none' : '',
transition: 'background-color 0.3s ease',
borderRadius: 0,
padding: 0,
}));
const NavLinksContainer = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'center',
flex: 1,
[theme.breakpoints.down('md')]: {
display: 'none',
},
}));
const UserActionsContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const UserButton = styled(Button)(({ theme }) => ({
color: theme.palette.primary.contrastText,
textTransform: 'none',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}));
const MobileDrawer = styled(Drawer)(({ theme }) => ({
'& .MuiDrawer-paper': {
width: 320,
backgroundColor: theme.palette.background.paper,
},
}));
const DropdownButton = styled(Button)(({ theme }) => ({
color: theme.palette.primary.contrastText,
textTransform: 'none',
minHeight: 48,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}));
const UserMenuContainer = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[8],
overflow: 'hidden',
minWidth: 200,
}));
interface HeaderProps {
transparent?: boolean;
className?: string;
navigate: NavigateFunction;
navigationItems: NavigationItem[];
currentPath: string;
sessionId?: string | null;
}
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const { user, logout } = useAuth();
const { setSnack } = useAppState();
const {
transparent = false,
className,
navigate,
navigationItems,
sessionId,
} = props;
const theme = useTheme();
const location = useLocation();
const name = (user?.firstName || user?.email || '');
// State for desktop dropdown menus
const [dropdownAnchors, setDropdownAnchors] = useState<{ [key: string]: HTMLElement | null }>({});
// State for mobile drawer
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileExpanded, setMobileExpanded] = useState<{ [key: string]: boolean }>({});
// State for user menu
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const userMenuOpen = Boolean(userMenuAnchor);
const isAdmin = user?.isAdmin || false;
// Get user menu items from navigation config
const userMenuGroups = getUserMenuItemsByGroup(user?.userType || null, isAdmin);
// Create user menu items array with proper actions
const createUserMenuItems = () => {
const items: Array<{
id: string;
label: string;
icon: React.ReactElement | null;
action: () => void;
group?: string;
}> = [];
// Add profile group items
userMenuGroups.profile.forEach(item => {
if (!item.divider) {
items.push({
id: item.id,
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path),
group: 'profile'
});
}
});
// Add divider if we have items before system group
if (items.length > 0 && userMenuGroups.system.length > 0) {
items.push({
id: 'divider',
label: '',
icon: null,
action: () => { },
group: 'divider'
});
}
// Add account group items
userMenuGroups.account.forEach(item => {
if (!item.divider) {
items.push({
id: item.id,
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path),
group: 'account'
});
}
});
// Add divider if we have items before system group
if (items.length > 0 && userMenuGroups.system.length > 0) {
items.push({
id: 'divider',
label: '',
icon: null,
action: () => { },
group: 'divider'
});
}
// Add admin group items
userMenuGroups.admin.forEach(item => {
if (!item.divider) {
items.push({
id: item.id,
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path),
group: 'admin'
});
}
});
// Add divider if we have items before system group
if (items.length > 0 && userMenuGroups.admin.length > 0) {
items.push({
id: 'divider',
label: '',
icon: null,
action: () => { },
group: 'divider'
});
}
// Add system group items with special handling for logout
userMenuGroups.system.forEach(item => {
if (item.id === 'logout') {
items.push({
id: 'logout',
label: 'Logout',
icon: <Logout fontSize="small" />,
action: () => {
logout();
navigate('/');
},
group: 'system'
});
} else if (!item.divider) {
items.push({
id: item.id,
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path),
group: 'system'
});
}
});
// Add other group items
userMenuGroups.other.forEach(item => {
if (!item.divider) {
items.push({
id: item.id,
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path),
group: 'other'
});
}
});
return items;
};
const userMenuItems = createUserMenuItems();
// Helper function to check if current path matches navigation item
const isCurrentPath = (item: NavigationItem): boolean => {
if (!item.path) return false;
if (item.exact) {
return location.pathname === item.path;
}
return location.pathname.startsWith(item.path);
};
// Helper function to check if any child is current path
const hasActiveChild = (item: NavigationItem): boolean => {
if (!item.children) return false;
return item.children.some(child => isCurrentPath(child) || hasActiveChild(child));
};
// Desktop dropdown handlers
const handleDropdownOpen = (event: React.MouseEvent<HTMLElement>, itemId: string) => {
setDropdownAnchors(prev => ({ ...prev, [itemId]: event.currentTarget }));
};
const handleDropdownClose = (itemId: string) => {
setDropdownAnchors(prev => ({ ...prev, [itemId]: null }));
};
// Mobile accordion handlers
const handleMobileToggle = (itemId: string) => {
setMobileExpanded(prev => ({ ...prev, [itemId]: !prev[itemId] }));
};
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setUserMenuAnchor(event.currentTarget);
};
const handleUserMenuClose = () => {
setUserMenuAnchor(null);
};
const handleUserMenuAction = (item: { id: string; label: string; icon: React.ReactElement | null; action: () => void; group?: string }) => {
if (item.group !== 'divider') {
item.action();
handleUserMenuClose();
}
};
// Navigation handlers
const handleNavigate = (path: string) => {
navigate(path);
setMobileOpen(false);
// Close all dropdowns
setDropdownAnchors({});
};
// Render desktop navigation with dropdowns
const renderDesktopNavigation = () => {
return (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', justifyContent: 'space-between' }}>
{navigationItems.map((item, index) => {
const hasChildren = item.children && item.children.length > 0;
const isActive = isCurrentPath(item) || hasActiveChild(item);
if (hasChildren) {
return (
<Box key={item.id} sx={{
mr: (index === 0 || index === navigationItems.length - 1) ? "auto" : "unset",
}}>
<DropdownButton
onClick={(e) => handleDropdownOpen(e, item.id)}
endIcon={<KeyboardArrowDown />}
sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent',
color: isActive ? 'secondary.main' : 'primary.contrastText',
}}
>
{item.icon && <Box sx={{ mr: 1, display: 'flex' }}>{item.icon}</Box>}
{item.label}
</DropdownButton>
<Menu
anchorEl={dropdownAnchors[item.id]}
open={Boolean(dropdownAnchors[item.id])}
onClose={() => handleDropdownClose(item.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
TransitionComponent={Fade}
>
{item.children?.map(child => (
<MenuItem
key={child.id}
onClick={() => child.path && handleNavigate(child.path)}
selected={isCurrentPath(child)}
disabled={!child.path}
>
{child.icon && <ListItemIcon>{child.icon}</ListItemIcon>}
<ListItemText>{child.label}</ListItemText>
</MenuItem>
))}
</Menu>
</Box>
);
} else {
return (
<DropdownButton
key={item.id}
onClick={() => item.path && handleNavigate(item.path)}
sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent',
color: isActive ? 'secondary.main' : 'primary.contrastText',
mr: (index === 0 || index === navigationItems.length - 1) ? "auto" : "unset",
}}
>
{item.icon && <Box sx={{ mr: 1, display: 'flex' }}>{item.icon}</Box>}
{item.label}
</DropdownButton>
);
}
})}
</Box>
);
};
// Render mobile accordion navigation
const renderMobileNavigation = () => {
const renderNavigationItem = (item: NavigationItem, depth: number = 0) => {
const hasChildren = item.children && item.children.length > 0;
const isActive = isCurrentPath(item) || hasActiveChild(item);
const isExpanded = mobileExpanded[item.id];
return (
<Box key={item.id}>
<ListItem disablePadding sx={{ pl: depth * 2 }}>
<ListItemButton
onClick={() => {
if (hasChildren) {
handleMobileToggle(item.id);
} else if (item.path) {
handleNavigate(item.path);
}
}}
selected={isActive}
sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent',
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'primary.contrastText',
'& .MuiListItemIcon-root': {
color: 'primary.contrastText',
},
},
}}
>
{item.icon && (
<ListItemIcon sx={{ minWidth: 36 }}>
{item.icon}
</ListItemIcon>
)}
<ListItemText
primary={item.label}
sx={{
'& .MuiTypography-root': {
fontSize: depth > 0 ? '0.875rem' : '1rem',
fontWeight: depth === 0 ? 500 : 400,
}
}}
/>
{hasChildren && (
<IconButton size="small">
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
)}
</ListItemButton>
</ListItem>
{hasChildren && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List disablePadding>
{item.children?.map(child => renderNavigationItem(child, depth + 1))}
</List>
</Collapse>
)}
</Box>
);
};
return (
<List sx={{ pt: 0 }}>
{navigationItems.map((item) => renderNavigationItem(item))}
<Divider sx={{ my: 1 }} />
{!user && (
<ListItem disablePadding>
<ListItemButton onClick={() => handleNavigate('/login')}>
<ListItemText primary="Login" />
</ListItemButton>
</ListItem>
)}
</List>
);
};
// Render user menu content
const renderUserMenu = () => {
return (
<UserMenuContainer>
<List dense>
{userMenuItems.map((item, index) => (
item.group === 'divider' ? (
<Divider key={`divider-${index}`} />
) : (
<ListItem key={item.id} disablePadding>
<ListItemButton onClick={() => handleUserMenuAction(item)}>
{item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>}
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
)
))}
</List>
</UserMenuContainer>
);
};
// Render user account section
const renderUserSection = () => {
if (!user) {
return (
<Button
color="info"
variant="contained"
onClick={() => navigate("/login")}
sx={{
display: { xs: 'none', sm: 'block' },
color: theme.palette.primary.contrastText,
}}
>
Login
</Button>
);
}
return (
<>
<UserButton
onClick={handleUserMenuOpen}
aria-controls={userMenuOpen ? 'user-menu' : undefined}
aria-haspopup="true"
aria-expanded={userMenuOpen ? 'true' : undefined}
>
<Avatar sx={{
width: 32,
height: 32,
bgcolor: theme.palette.secondary.main,
}}>
{name.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
{name}
</Box>
<ExpandMore fontSize="small" />
</UserButton>
<Popover
id="user-menu"
open={userMenuOpen}
anchorEl={userMenuAnchor}
onClose={handleUserMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
TransitionComponent={Fade}
>
{renderUserMenu()}
</Popover>
</>
);
};
return (
<StyledAppBar
position="fixed"
transparent={transparent}
className={className}
sx={{ overflow: "hidden" }}
>
<Container maxWidth="xl">
<Toolbar disableGutters>
{/* Navigation Links - Desktop */}
<NavLinksContainer>
{renderDesktopNavigation()}
</NavLinksContainer>
{/* User Actions Section */}
<UserActionsContainer>
{renderUserSection()}
{/* Mobile Menu Button */}
<Tooltip title="Open Menu">
<IconButton
color="inherit"
aria-label="open drawer"
edge="end"
onClick={handleDrawerToggle}
sx={{ display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
</Tooltip>
{sessionId && (
<CopyBubble
tooltip="Copy link"
color="inherit"
aria-label="copy link"
edge="end"
sx={{
width: 36,
height: 36,
opacity: 1,
bgcolor: 'inherit',
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
}}
content={`${window.location.origin}${window.location.pathname}?id=${sessionId}`}
onClick={() => {
navigate(`${window.location.pathname}?id=${sessionId}`);
setSnack("Link copied!");
}}
size="large"
/>
)}
</UserActionsContainer>
{/* Mobile Navigation Drawer */}
<MobileDrawer
variant="temporary"
anchor="right"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true,
}}
>
{renderMobileNavigation()}
</MobileDrawer>
</Toolbar>
</Container>
<Beta
sx={{ left: "-90px", "& .mobile": { right: "-72px" } }}
onClick={() => { navigate('/docs/beta'); }}
/>
</StyledAppBar>
);
};
export { Header };