- 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>
334 lines
13 KiB
TypeScript
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>
|
|
);
|
|
} |