217 lines
8.3 KiB
TypeScript
217 lines
8.3 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Bell,
|
|
Mail,
|
|
CheckCheck,
|
|
ChevronRight,
|
|
Inbox,
|
|
Clock,
|
|
Sparkles,
|
|
} from 'lucide-react';
|
|
import { useInAppNotifications } from '../../hooks/useInAppNotifications';
|
|
import { formatDate } from '../../lib/formatters';
|
|
import { stripMarkdown } from '../../lib/markdownUtils';
|
|
|
|
export const NotificationBell: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { top5Unread, unreadCount, markAsRead, markAllAsRead } = useInAppNotifications();
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const leaveTimerRef = useRef<number | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Clear timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (leaveTimerRef.current) {
|
|
window.clearTimeout(leaveTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleMouseEnter = () => {
|
|
if (leaveTimerRef.current) {
|
|
window.clearTimeout(leaveTimerRef.current);
|
|
leaveTimerRef.current = null;
|
|
}
|
|
setIsOpen(true);
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
leaveTimerRef.current = window.setTimeout(() => {
|
|
setIsOpen(false);
|
|
}, 250);
|
|
};
|
|
|
|
const handleNotificationClick = async (id: number | string) => {
|
|
await markAsRead(id);
|
|
setIsOpen(false);
|
|
navigate(`/inbox?id=${id}`);
|
|
};
|
|
|
|
const handleViewAll = () => {
|
|
setIsOpen(false);
|
|
navigate('/inbox');
|
|
};
|
|
|
|
// Format relative timestamp
|
|
const getRelativeTime = (dateStr?: string) => {
|
|
if (!dateStr) return '';
|
|
try {
|
|
const now = new Date();
|
|
const date = new Date(dateStr);
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return formatDate(dateStr);
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="relative"
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{/* Bell Button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(prev => !prev)}
|
|
id="header-notification-bell-btn"
|
|
className={`relative h-9 w-9 rounded-full flex items-center justify-center transition cursor-pointer ${
|
|
isOpen
|
|
? 'bg-blue-600/30 text-white border border-blue-400/50 shadow-md shadow-blue-500/20'
|
|
: 'bg-slate-900/80 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 hover:border-slate-600 shadow-xs'
|
|
}`}
|
|
title={`Notifications • ${unreadCount} unread`}
|
|
aria-label="In-App Notifications"
|
|
>
|
|
<Bell className="w-4 h-4" />
|
|
|
|
{/* Unread Count Badge */}
|
|
{unreadCount > 0 && (
|
|
<span
|
|
id="notification-unread-count-badge"
|
|
className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-rose-600 text-[10px] font-extrabold text-white shadow-lg ring-2 ring-[#0d121f] animate-pulse"
|
|
>
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Floating Hover Popover */}
|
|
{isOpen && (
|
|
<div
|
|
id="notification-bell-dropdown"
|
|
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl p-0 z-50 backdrop-blur-xl animate-in fade-in slide-in-from-top-2 overflow-hidden"
|
|
>
|
|
{/* Popover Header */}
|
|
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/40 via-slate-900/40 to-slate-900/30">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="w-4 h-4 text-blue-400" />
|
|
<h4 className="text-xs font-bold text-white tracking-tight">In-App Notifications</h4>
|
|
{unreadCount > 0 && (
|
|
<span className="text-[10px] font-semibold text-rose-300 bg-rose-500/20 border border-rose-500/30 px-1.5 py-0.2 rounded-full">
|
|
{unreadCount} unread
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{unreadCount > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={markAllAsRead}
|
|
className="flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 transition cursor-pointer"
|
|
title="Mark all notifications as read"
|
|
>
|
|
<CheckCheck className="w-3.5 h-3.5" />
|
|
<span>Mark read</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top 5 Notifications List */}
|
|
<div className="max-h-80 overflow-y-auto divide-y divide-slate-800/60">
|
|
{top5Unread.length === 0 ? (
|
|
<div className="py-8 px-4 text-center">
|
|
<div className="w-10 h-10 mx-auto rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 mb-2">
|
|
<Sparkles className="w-5 h-5" />
|
|
</div>
|
|
<p className="text-xs font-semibold text-slate-200">All caught up!</p>
|
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
|
No unread in-app notifications at this time.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
top5Unread.map(item => {
|
|
const relativeTime = getRelativeTime(item.createdAt);
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleNotificationClick(item.id)}
|
|
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex items-start gap-3 relative"
|
|
>
|
|
{/* Unread Accent Indicator */}
|
|
<div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 shrink-0 shadow-sm shadow-blue-500" />
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-1 mb-0.5">
|
|
<p className="text-xs font-semibold text-white group-hover:text-blue-400 transition truncate">
|
|
{item.subject || 'In-App Notification'}
|
|
</p>
|
|
{relativeTime && (
|
|
<span className="text-[10px] text-slate-400 shrink-0 font-mono flex items-center gap-1">
|
|
<Clock className="w-2.5 h-2.5" />
|
|
{relativeTime}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
|
{stripMarkdown(item.message || 'No additional message content.')}
|
|
</p>
|
|
|
|
{item.destination && (
|
|
<p className="text-[10px] text-slate-400 font-mono mt-1 truncate">
|
|
{item.destination}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-white group-hover:translate-x-0.5 transition shrink-0 mt-1" />
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Popover Footer: Open Inbox */}
|
|
<div className="p-2.5 border-t border-slate-800 bg-[#090d16]/90 flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={handleViewAll}
|
|
id="view-mailbox-btn"
|
|
className="w-full flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-xl bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 hover:text-white border border-blue-500/30 text-xs font-semibold transition cursor-pointer shadow-xs"
|
|
>
|
|
<Inbox className="w-3.5 h-3.5" />
|
|
<span>Go to Mailbox ({unreadCount} unread)</span>
|
|
<ChevronRight className="w-3 h-3 ml-0.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|