165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
import {
|
|
Menu,
|
|
ChevronDown,
|
|
LogOut,
|
|
} from "lucide-react";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { useSidebar } from "../../context/SidebarContext";
|
|
import { useAuth } from "../../context/AuthContext";
|
|
import { useTranslation } from "react-i18next";
|
|
import { authApi } from "../../application/authentication/AuthApi";
|
|
import { clearAuthCookies } from "../../lib/authCookies";
|
|
import NotificationBell from "../../application/notifications/NotificationBell";
|
|
|
|
|
|
const AppHeader: React.FC = () => {
|
|
const { t } = useTranslation(['profile', 'common']);
|
|
const { user } = useAuth();
|
|
const { toggleMobileSidebar } = useSidebar();
|
|
const navigate = useNavigate();
|
|
|
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await authApi.logout();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
clearAuthCookies();
|
|
navigate("/signin");
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (!userMenuRef.current?.contains(event.target as Node)) {
|
|
setIsUserMenuOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const fullName = user
|
|
? `${user.first_name || ""} ${user.last_name || ""}`.trim() || t('common:common.loading')
|
|
: t('common:common.loading');
|
|
|
|
const initials =
|
|
user?.first_name && user?.last_name
|
|
? `${user.first_name[0]}${user.last_name[0]}`
|
|
: "U";
|
|
|
|
return (
|
|
<header className="sticky top-0 z-40 flex w-full bg-white/80 backdrop-blur-md border-b border-gray-200 transition-all duration-200">
|
|
<div className="flex flex-grow items-center gap-4 px-4 py-3 lg:px-6">
|
|
{/* LEFT: Mobile Toggle & Brand (Mobile only) */}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
className="flex items-center justify-center p-2 text-gray-500 hover:bg-gray-100 rounded-lg lg:hidden transition-colors"
|
|
onClick={toggleMobileSidebar}
|
|
aria-label="Toggle Sidebar"
|
|
>
|
|
<Menu size={24} />
|
|
</button>
|
|
|
|
<Link to="/" className="lg:hidden flex items-center gap-2">
|
|
<span className="font-bold text-gray-900 text-lg">SaaS</span>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* RIGHT: Actions */}
|
|
<div className="flex items-center gap-2 sm:gap-4 ltr:ml-auto rtl:mr-auto">
|
|
|
|
{/* Before the user menu: this is the thing that changes, and the one
|
|
somebody is looking for when they arrive. */}
|
|
<NotificationBell />
|
|
|
|
{/* User Menu */}
|
|
<div className="relative" ref={userMenuRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsUserMenuOpen((prev) => !prev)}
|
|
className="
|
|
flex items-center gap-2
|
|
p-1 pr-3
|
|
rounded-full border border-gray-200
|
|
hover:border-gray-300 hover:bg-gray-50
|
|
transition-all bg-white/50
|
|
"
|
|
aria-expanded={isUserMenuOpen}
|
|
>
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 text-sm font-semibold">
|
|
{initials}
|
|
</div>
|
|
|
|
<div className="hidden md:block ltr:text-left rtl:text-right ltr:mr-1 rtl:ml-1 leading-tight">
|
|
<p className="text-sm font-semibold text-gray-700">{fullName}</p>
|
|
</div>
|
|
|
|
<ChevronDown
|
|
size={14}
|
|
className={`text-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
|
|
}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Dropdown */}
|
|
{isUserMenuOpen && (
|
|
<div
|
|
className="
|
|
absolute ltr:right-0 rtl:left-0 mt-2 w-64
|
|
rounded-xl border border-gray-100
|
|
bg-white
|
|
shadow-xl
|
|
overflow-hidden
|
|
animate-in fade-in zoom-in-95 duration-150
|
|
ltr:origin-top-right rtl:origin-top-left
|
|
z-50
|
|
"
|
|
>
|
|
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50">
|
|
<p className="text-sm font-semibold text-gray-900 truncate">
|
|
{user?.email}
|
|
</p>
|
|
<p className="text-xs text-gray-500 truncate mt-0.5 uppercase tracking-wider">
|
|
{user?.role?.role_name || "User"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="py-1">
|
|
<Link
|
|
to="/profile"
|
|
onClick={() => setIsUserMenuOpen(false)}
|
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500">
|
|
<ChevronDown size={16} className="-rotate-90" />
|
|
</div>
|
|
{t('profile:header.profileSettings')}
|
|
</Link>
|
|
|
|
<div className="h-px bg-gray-100 my-1 mx-2"></div>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
|
>
|
|
<div className="w-8 h-8 rounded-lg bg-red-50 flex items-center justify-center text-red-600">
|
|
<LogOut size={16} />
|
|
</div>
|
|
{t('profile:header.signOut')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
};
|
|
|
|
export default AppHeader;
|