Implemented mailbox in ConsoleUI
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import {
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
FolderKanban,
|
||||
@@ -121,6 +123,12 @@ const navItems: NavItem[] = [
|
||||
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
||||
claim: 'tenant_management',
|
||||
},
|
||||
{
|
||||
name: 'Mailbox & Inbox',
|
||||
path: '/inbox',
|
||||
icon: Inbox,
|
||||
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
|
||||
},
|
||||
{
|
||||
name: 'Settings & Communications',
|
||||
path: '/settings',
|
||||
@@ -525,6 +533,8 @@ export const AppLayout: React.FC = () => {
|
||||
|
||||
{/* User Profile and Controls */}
|
||||
<div className="relative flex items-center gap-3">
|
||||
{/* Notification Bell in close proximity to username */}
|
||||
<NotificationBell />
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
|
||||
@@ -562,6 +572,14 @@ export const AppLayout: React.FC = () => {
|
||||
<p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/inbox"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition"
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>In-App Mailbox</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user