- Update all frontend branding (Login, Splash, Layout, Navbar, etc.) - Update page titles and breadcrumbs - Update visitor components (Badge, CheckIn) - Update deploy.sh and README - Update test fixtures with new email domain
263 lines
13 KiB
TypeScript
263 lines
13 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { Link, useLocation } from 'react-router-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Menu,
|
|
ChevronDown,
|
|
Command,
|
|
Bell,
|
|
LogOut,
|
|
Search,
|
|
Settings,
|
|
} from 'lucide-react';
|
|
import { usePermissions } from '../../hooks/usePermissions';
|
|
import { getFilteredNavSections, type NavSection } from '../../lib/navigation';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import ThemeToggle from '../ThemeToggle';
|
|
import { cn } from '../../lib/utils';
|
|
|
|
interface NavbarProps {
|
|
onOpenMobileMenu?: () => void;
|
|
}
|
|
|
|
export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|
const { role } = usePermissions();
|
|
const sections = getFilteredNavSections(role);
|
|
const location = useLocation();
|
|
const [scrolled, setScrolled] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const handleScroll = () => setScrolled(window.scrollY > 20);
|
|
window.addEventListener('scroll', handleScroll);
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
return (
|
|
<header
|
|
className={`
|
|
sticky top-0 z-40 w-full border-b backdrop-blur-xl transition-all duration-500
|
|
${scrolled
|
|
? 'bg-white/80 border-slate-200 dark:bg-[#050505]/80 dark:border-slate-800 shadow-2xl shadow-black/5'
|
|
: 'bg-transparent border-transparent'
|
|
}
|
|
`}
|
|
>
|
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-10">
|
|
<div className="flex items-center justify-between h-16">
|
|
{/* Left: Branding & Navigation */}
|
|
<div className="flex items-center gap-12">
|
|
<Link to="/dashboard" className="flex items-center gap-3 group relative z-50">
|
|
<div className="relative">
|
|
<img
|
|
src="/assets/logo-veridian.jpg"
|
|
alt="Veridian"
|
|
className="w-9 h-9 rounded-lg shadow-md ring-1 ring-slate-900/5 group-hover:scale-105 transition-transform duration-500"
|
|
/>
|
|
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
|
|
</div>
|
|
<div className="hidden sm:block">
|
|
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tighter uppercase italic">
|
|
Veridian
|
|
</h1>
|
|
<p className="text-[9px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-[0.3em] leading-none">
|
|
Cultivation Platform
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
|
|
{/* Desktop Navigation */}
|
|
<nav className="hidden lg:flex items-center gap-2">
|
|
{sections.map(section => (
|
|
<NavDropdown
|
|
key={section.id}
|
|
section={section}
|
|
currentPath={location.pathname}
|
|
/>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Right: Actions */}
|
|
<div className="flex items-center gap-4">
|
|
{/* Search Mock */}
|
|
<button
|
|
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
|
|
className="hidden md:flex items-center gap-3 px-4 py-2 text-xs font-bold text-slate-500 bg-slate-100/50 hover:bg-white dark:bg-slate-900/50 dark:hover:bg-slate-800 transition-all rounded-full border border-slate-200/50 dark:border-slate-800 group"
|
|
>
|
|
<Search size={12} className="group-hover:text-indigo-500 transition-colors" />
|
|
<span className="uppercase tracking-widest">Search...</span>
|
|
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded bg-slate-200 dark:bg-slate-800 px-1.5 font-mono text-[9px] text-slate-500">
|
|
⌘K
|
|
</kbd>
|
|
</button>
|
|
|
|
<div className="w-px h-6 bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
|
|
|
|
<button className="relative p-2 text-slate-400 hover:text-indigo-500 transition-colors rounded-xl hover:bg-slate-100 dark:hover:bg-slate-900">
|
|
<Bell size={18} />
|
|
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-rose-500 rounded-full ring-2 ring-white dark:ring-[#050505]" />
|
|
</button>
|
|
|
|
<ThemeToggle />
|
|
|
|
<div className="pl-1">
|
|
<UserDropdown />
|
|
</div>
|
|
|
|
<button
|
|
onClick={onOpenMobileMenu}
|
|
className="lg:hidden p-2 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
|
>
|
|
<Menu size={24} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function UserDropdown() {
|
|
const { user, logout } = useAuth();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="flex items-center gap-2 p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors"
|
|
>
|
|
<div className="w-8 h-8 rounded-xl bg-slate-900 text-white dark:bg-white dark:text-slate-900 flex items-center justify-center font-bold text-xs ring-2 ring-transparent hover:ring-indigo-500/20 transition-all">
|
|
{user?.email?.[0]?.toUpperCase() || 'E'}
|
|
</div>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
transition={{ duration: 0.1 }}
|
|
className="absolute top-full right-0 mt-3 w-64 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50 ring-1 ring-black/5"
|
|
>
|
|
<div className="px-3 py-3 border-b border-slate-100 dark:border-slate-800 mb-2">
|
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Authenticated</p>
|
|
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">
|
|
{user?.name || 'Administrator'}
|
|
</p>
|
|
<p className="text-[10px] text-slate-500 truncate font-mono mt-1">
|
|
{user?.email}
|
|
</p>
|
|
</div>
|
|
|
|
<Link
|
|
to="/settings"
|
|
onClick={() => setIsOpen(false)}
|
|
className="flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 dark:text-slate-400 dark:hover:text-indigo-400 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
|
|
>
|
|
<Settings size={14} />
|
|
Settings
|
|
</Link>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
logout();
|
|
}}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/20 rounded-xl transition-all"
|
|
>
|
|
<LogOut size={14} />
|
|
Sign Out
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NavDropdown({ section, currentPath }: { section: NavSection, currentPath: string }) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const isActive = section.items.some(item => item.path === currentPath);
|
|
|
|
return (
|
|
<div
|
|
className="relative px-0.5"
|
|
onMouseEnter={() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setIsOpen(true); }}
|
|
onMouseLeave={() => { timeoutRef.current = setTimeout(() => setIsOpen(false), 150); }}
|
|
>
|
|
<button
|
|
className={`
|
|
flex items-center gap-2 px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.15em] rounded-lg transition-all
|
|
${isActive || isOpen
|
|
? 'text-indigo-600 bg-indigo-500/5 dark:text-indigo-400 dark:bg-indigo-500/10'
|
|
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
|
|
}
|
|
`}
|
|
>
|
|
{section.label}
|
|
<ChevronDown
|
|
size={10}
|
|
className={`transition-transform duration-300 ${isOpen ? 'rotate-180 text-indigo-500' : 'opacity-40'}`}
|
|
/>
|
|
</button>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
|
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute top-full left-0 mt-2 w-72 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50"
|
|
>
|
|
<div className="grid gap-1">
|
|
<p className="px-3 py-1.5 text-[9px] font-bold text-slate-400 uppercase tracking-[0.2em]">{section.label}</p>
|
|
{section.items.map(item => (
|
|
<Link
|
|
key={item.id}
|
|
to={item.path}
|
|
className={`
|
|
group flex items-center gap-4 px-3 py-3 rounded-xl transition-all
|
|
${item.path === currentPath
|
|
? 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
|
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
|
}
|
|
`}
|
|
>
|
|
<div className={cn(
|
|
"p-2 rounded-lg transition-all group-hover:scale-110",
|
|
item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400"
|
|
)}>
|
|
<item.icon size={14} />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-xs font-bold leading-none tracking-tight">{item.label}</div>
|
|
</div>
|
|
{item.path === currentPath && (
|
|
<div className="w-1 h-1 rounded-full bg-indigo-500" />
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Navbar;
|