Files
blackcanyontickets/reactrebuild0825/src/components/layout/Header.tsx
dzinesco aa81eb5adb feat: add advanced analytics and territory management system
- Add comprehensive analytics components with export functionality
- Implement territory management with manager performance tracking
- Add seatmap components for venue layout management
- Create customer management features with modal interface
- Add advanced hooks for dashboard flags and territory data
- Implement seat selection and venue management utilities
- Add type definitions for ticketing and seatmap systems

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 09:25:10 -06:00

334 lines
13 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, ChevronRight, Settings, LogOut, Sun, Moon, Building2 } from 'lucide-react';
import { useCurrentOrg } from '../../stores/currentOrg';
import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../hooks/useTheme';
import { Button } from '../ui/Button';
export interface HeaderProps {
onToggleSidebar: () => void;
}
export function Header({ onToggleSidebar }: HeaderProps) {
const [userMenuOpen, setUserMenuOpen] = useState(false);
const { theme, toggleTheme } = useTheme();
const { user, logout, isLoading } = useAuth();
const { org, loading: orgLoading } = useCurrentOrg();
const location = useLocation();
const navigate = useNavigate();
const userMenuRef = useRef<HTMLDivElement>(null);
// Close user menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close user menu on escape
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && userMenuOpen) {
setUserMenuOpen(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [userMenuOpen]);
// Generate breadcrumbs from current path
const generateBreadcrumbs = () => {
const pathSegments = location.pathname.split('/').filter(Boolean);
if (pathSegments.length === 0) {
return [{ label: 'Dashboard', path: '/' }];
}
const breadcrumbs = [{ label: 'Dashboard', path: '/' }];
pathSegments.forEach((segment, index) => {
const path = `/${ pathSegments.slice(0, index + 1).join('/')}`;
const label = segment.charAt(0).toUpperCase() + segment.slice(1);
breadcrumbs.push({ label, path });
});
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs();
const handleLogout = async () => {
try {
setUserMenuOpen(false);
await logout();
navigate('/login');
} catch (error) {
console.error('Logout failed:', error);
// Force navigation to login even if logout fails
navigate('/login');
}
};
const getInitials = (name: string) => name
.split(' ')
.map(part => part.charAt(0).toUpperCase())
.join('')
.slice(0, 2);
return (
<div className="h-16 border-b border-org-default bg-org-surface backdrop-blur-md">
<div className="h-full px-4 lg:px-6 flex items-center justify-between">
{/* Left section: Organization branding + Mobile menu button + Breadcrumbs */}
<div className="flex items-center space-x-4">
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
onClick={onToggleSidebar}
className="lg:hidden"
aria-label="Toggle sidebar"
>
<Menu className="h-5 w-5" />
</Button>
{/* Organization branding */}
{!orgLoading && org ? (
<div className="flex items-center space-x-3">
{org.branding?.logoUrl ? (
<img
src={org.branding.logoUrl}
alt={`${org.name} logo`}
className="h-8 w-auto max-w-32 object-contain"
onError={(e) => {
// Hide image on error and show fallback
const img = e.target as HTMLImageElement;
img.style.display = 'none';
// Show monogram fallback
const fallback = img.parentElement?.querySelector('.org-fallback');
if (fallback) fallback.classList.remove('hidden');
}}
/>
) : null}
{/* Fallback monogram when no logo */}
{!org.branding?.logoUrl && (
<div className="h-8 w-8 rounded-lg bg-org-accent flex items-center justify-center text-org-canvas text-sm font-bold">
{org.name.charAt(0).toUpperCase()}
</div>
)}
{/* Hidden fallback for logo error */}
<div className="org-fallback hidden h-8 w-8 rounded-lg bg-org-accent flex items-center justify-center text-org-canvas text-sm font-bold">
{org.name.charAt(0).toUpperCase()}
</div>
<div className="hidden md:block">
<h1 className="text-sm font-semibold text-org-primary truncate max-w-48">
{org.name}
</h1>
{org.domains?.length && (
<p className="text-xs text-org-secondary truncate max-w-48">
{org.domains.find(d => d.primary)?.host || org.domains[0]?.host}
</p>
)}
</div>
</div>
) : orgLoading ? (
// Loading skeleton
<div className="flex items-center space-x-3">
<div className="h-8 w-8 rounded-lg bg-org-surface animate-pulse" />
<div className="hidden md:block space-y-1">
<div className="h-4 w-24 bg-org-surface animate-pulse rounded" />
<div className="h-3 w-16 bg-org-surface animate-pulse rounded" />
</div>
</div>
) : (
// No organization fallback
<div className="flex items-center space-x-3">
<div className="h-8 w-8 rounded-lg bg-gray-400 flex items-center justify-center">
<Building2 className="h-4 w-4 text-white" />
</div>
<div className="hidden md:block">
<h1 className="text-sm font-semibold text-org-primary">
Black Canyon Tickets
</h1>
</div>
</div>
)}
{/* Separator */}
{org && (
<div className="hidden lg:block w-px h-6 bg-org-primary/20" />
)}
{/* Breadcrumbs */}
<nav aria-label="Breadcrumb" className="hidden sm:flex">
<ol className="flex items-center space-x-2 text-sm">
{breadcrumbs.map((crumb, index) => (
<li key={crumb.path} className="flex items-center">
{index > 0 && (
<ChevronRight className="h-4 w-4 text-org-secondary mx-2" aria-hidden="true" />
)}
{index === breadcrumbs.length - 1 ? (
<span className="text-org-primary font-medium">
{crumb.label}
</span>
) : (
<Link
to={crumb.path}
className="text-org-secondary hover:text-org-primary
transition-colors duration-200"
>
{crumb.label}
</Link>
)}
</li>
))}
</ol>
</nav>
</div>
{/* Right section: Theme toggle + User menu */}
<div className="flex items-center space-x-4">
{/* Theme toggle */}
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
className="text-org-secondary hover:text-org-primary"
>
{theme === 'light' ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
{/* User menu */}
{user && (
<div className="relative" ref={userMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={() => setUserMenuOpen(!userMenuOpen)}
aria-expanded={userMenuOpen}
aria-haspopup="true"
data-testid="user-menu-button"
className="flex items-center space-x-2 text-org-secondary
hover:text-org-primary"
disabled={isLoading}
>
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="h-8 w-8 rounded-full object-cover"
onError={(e) => {
// Hide image on error and show initials fallback
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="h-8 w-8 rounded-full bg-org-accent
flex items-center justify-center text-org-canvas text-sm font-medium">
{getInitials(user.name)}
</div>
)}
<span className="hidden sm:block text-sm font-medium">
{user.name}
</span>
</Button>
{/* User dropdown menu */}
{userMenuOpen && (
<div className="absolute right-0 mt-2 w-56 bg-org-surface rounded-lg
shadow-lg border border-org-default py-1 z-50 org-glass">
<div className="px-4 py-3 border-b border-org-default">
<p className="text-sm font-medium text-org-primary">
{user.name}
</p>
<p className="text-xs text-org-secondary">
{user.email}
</p>
<div className="flex items-center mt-1">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
bg-org-accent text-org-canvas">
{user.role}
</span>
{org && (
<span className="ml-2 text-xs text-org-secondary truncate">
{org.name}
</span>
)}
</div>
</div>
<Link
to="/settings"
className="flex items-center px-4 py-2 text-sm text-org-secondary
hover:bg-org-accent-light transition-colors duration-200"
onClick={() => setUserMenuOpen(false)}
>
<Settings className="h-4 w-4 mr-3" />
Account Settings
</Link>
{org && (
<>
<div className="border-t border-org-default my-1" />
<div className="px-4 py-2">
<p className="text-xs font-medium text-org-secondary uppercase tracking-wide">
Organization
</p>
</div>
<Link
to={`/admin/branding`}
className="flex items-center px-4 py-2 text-sm text-org-secondary
hover:bg-org-accent-light transition-colors duration-200"
onClick={() => setUserMenuOpen(false)}
>
<div className="h-4 w-4 mr-3 rounded bg-org-accent" />
Branding Settings
</Link>
<Link
to={`/admin/domains`}
className="flex items-center px-4 py-2 text-sm text-org-secondary
hover:bg-org-accent-light transition-colors duration-200"
onClick={() => setUserMenuOpen(false)}
>
<div className="h-4 w-4 mr-3 rounded bg-gradient-to-br from-green-400 to-teal-500" />
Domains
</Link>
</>
)}
<button
className="w-full flex items-center px-4 py-2 text-sm text-org-secondary
hover:bg-org-accent-light transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleLogout}
disabled={isLoading}
>
<LogOut className="h-4 w-4 mr-3" />
{isLoading ? 'Signing out...' : 'Sign out'}
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}