166 lines
7.6 KiB
TypeScript
166 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useTheme } from 'next-themes';
|
|
import { logout } from '@/lib/auth';
|
|
import { useAuthStore } from '@/stores/auth.store';
|
|
import { api } from '@/lib/api';
|
|
|
|
function timeAgo(date: string) {
|
|
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
|
|
if (seconds < 60) return 'just now';
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
interface Notification {
|
|
id: string;
|
|
title: string;
|
|
message: string;
|
|
isRead: boolean;
|
|
createdAt: string;
|
|
channel: string;
|
|
}
|
|
|
|
export function Header({ onSupportOpen }: { onSupportOpen: () => void }) {
|
|
const router = useRouter();
|
|
const { theme, setTheme } = useTheme();
|
|
const logoutStore = useAuthStore((s) => s.logout);
|
|
const [unread, setUnread] = useState(0);
|
|
const [showNotifs, setShowNotifs] = useState(false);
|
|
const [notifs, setNotifs] = useState<Notification[]>([]);
|
|
|
|
const fetchUnread = useCallback(() => {
|
|
api.get('/notifications/unread-count').then((r) => setUnread(r.data.data.count)).catch(() => {});
|
|
}, []);
|
|
|
|
// Auto-refresh unread count every 30s and on window focus
|
|
useEffect(() => {
|
|
fetchUnread();
|
|
const interval = setInterval(fetchUnread, 30_000);
|
|
const onFocus = () => fetchUnread();
|
|
window.addEventListener('focus', onFocus);
|
|
return () => { clearInterval(interval); window.removeEventListener('focus', onFocus); };
|
|
}, [fetchUnread]);
|
|
|
|
async function toggleNotifs() {
|
|
const next = !showNotifs;
|
|
setShowNotifs(next);
|
|
if (next) {
|
|
const res = await api.get('/notifications');
|
|
setNotifs(res.data.data);
|
|
}
|
|
}
|
|
|
|
async function markAllRead() {
|
|
await api.patch('/notifications/read-all');
|
|
setUnread(0);
|
|
setNotifs(notifs.map((n) => ({ ...n, isRead: true })));
|
|
}
|
|
|
|
async function markOneRead(id: string) {
|
|
await api.patch(`/notifications/${id}/read`);
|
|
setNotifs(notifs.map((n) => n.id === id ? { ...n, isRead: true } : n));
|
|
setUnread((u) => Math.max(0, u - 1));
|
|
}
|
|
|
|
function handleLogout() {
|
|
logout();
|
|
logoutStore();
|
|
router.push('/login');
|
|
}
|
|
|
|
return (
|
|
<header className="h-14 bg-white/80 dark:bg-surface-900/80 backdrop-blur-sm border-b border-surface-200/60 dark:border-surface-700/60 flex items-center justify-end gap-3 px-6 shrink-0 z-10">
|
|
{/* Notification bell */}
|
|
<div className="relative">
|
|
<button onClick={toggleNotifs}
|
|
className="relative p-2 text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700">
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<path d="M13.73 12.73A14.65 14.65 0 0014.5 9V7.5a5.5 5.5 0 10-11 0V9c0 1.3.26 2.56.77 3.73L3 14h12l-1.27-1.27z" />
|
|
<path d="M7 14v.5a2 2 0 004 0V14" />
|
|
</svg>
|
|
{unread > 0 && (
|
|
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
|
{unread > 9 ? '9+' : unread}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{showNotifs && (
|
|
<div className="absolute right-0 top-12 w-80 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl shadow-lg shadow-surface-200/50 dark:shadow-surface-900/50 overflow-hidden z-50">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-100 dark:border-surface-700">
|
|
<span className="text-sm font-semibold text-surface-800 dark:text-surface-200">Notifications</span>
|
|
{unread > 0 && (
|
|
<button onClick={markAllRead} className="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 cursor-pointer">Mark all read</button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{notifs.length > 0 ? notifs.map((n) => (
|
|
<button
|
|
key={n.id}
|
|
onClick={() => { if (!n.isRead) markOneRead(n.id); }}
|
|
className={`w-full text-left px-4 py-3 border-b border-surface-50 dark:border-surface-700/50 transition-colors ${
|
|
!n.isRead ? 'bg-primary-50/30 dark:bg-primary-900/20 hover:bg-primary-50/50 dark:hover:bg-primary-900/30' : 'hover:bg-surface-50 dark:hover:bg-surface-700/50'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<p className="text-sm text-surface-800 dark:text-surface-200">{n.title}</p>
|
|
{!n.isRead && <span className="mt-1.5 w-2 h-2 rounded-full bg-primary-500 shrink-0" />}
|
|
</div>
|
|
<p className="text-xs text-surface-400 dark:text-surface-500 mt-0.5">{n.message}</p>
|
|
<p className="text-[10px] text-surface-300 dark:text-surface-600 mt-1">{timeAgo(n.createdAt)}</p>
|
|
</button>
|
|
)) : (
|
|
<div className="px-4 py-6 text-center text-sm text-surface-400 dark:text-surface-500">No notifications</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dark/Light mode toggle */}
|
|
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
className="p-2 text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700"
|
|
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
|
|
{theme === 'dark' ? (
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<circle cx="9" cy="9" r="3.5" />
|
|
<path d="M9 1.5v1M9 15.5v1M1.5 9h1M15.5 9h1M3.4 3.4l.7.7M13.9 13.9l.7.7M3.4 14.6l.7-.7M13.9 4.1l.7-.7" />
|
|
</svg>
|
|
) : (
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 10.7A6 6 0 017.3 4 6 6 0 1014 10.7z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Support icon */}
|
|
<button onClick={onSupportOpen}
|
|
className="p-2 text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700"
|
|
title="Support">
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="9" cy="9" r="7.5" />
|
|
<path d="M6.75 7.5a2.25 2.25 0 014.5 0c0 1.5-2.25 1.875-2.25 3" />
|
|
<circle cx="9" cy="13.125" r="0.375" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
|
|
<div className="w-px h-5 bg-surface-200 dark:bg-surface-700" />
|
|
|
|
<button onClick={handleLogout}
|
|
className="flex items-center gap-2 text-sm text-surface-500 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3M11 11l3-3-3-3M14 8H6" />
|
|
</svg>
|
|
Sign out
|
|
</button>
|
|
</header>
|
|
);
|
|
}
|