import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Bell } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const bellButtonStyle = { position: 'relative', background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#94A3B8', transition: 'all 0.15s', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', }; const badgeStyle = { position: 'absolute', top: '-4px', right: '-4px', background: '#EF4444', color: '#fff', fontSize: '0.6rem', fontFamily: "'JetBrains Mono', monospace", fontWeight: '700', minWidth: '16px', height: '16px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 4px', lineHeight: 1, }; const dropdownStyle = { position: 'absolute', top: 'calc(100% + 8px)', right: 0, width: '360px', maxHeight: '420px', overflowY: 'auto', background: 'linear-gradient(135deg, #0F1A2E 0%, #1E293B 100%)', border: '1px solid rgba(14, 165, 233, 0.3)', borderRadius: '0.5rem', boxShadow: '0 16px 48px rgba(0,0,0,0.7)', zIndex: 100, fontFamily: "'JetBrains Mono', monospace", }; const dropdownHeaderStyle = { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.06)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', }; const dropdownTitleStyle = { color: '#E2E8F0', fontSize: '0.8rem', fontWeight: '600', letterSpacing: '0.5px', textTransform: 'uppercase', }; const markAllButtonStyle = { background: 'none', border: 'none', color: '#0EA5E9', fontSize: '0.7rem', cursor: 'pointer', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', transition: 'all 0.15s', }; const notificationItemStyle = { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', transition: 'background 0.15s', }; const notificationMessageStyle = { color: '#CBD5E1', fontSize: '0.75rem', lineHeight: '1.5', marginBottom: '0.35rem', }; const notificationTimeStyle = { color: '#64748B', fontSize: '0.65rem', }; const emptyStyle = { padding: '2rem 1rem', textAlign: 'center', color: '#64748B', fontSize: '0.75rem', }; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- function NotificationBell() { const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); const [open, setOpen] = useState(false); const containerRef = useRef(null); const fetchCount = useCallback(async () => { try { const res = await fetch(`${API_BASE}/notifications/count`, { credentials: 'include' }); if (res.ok) { const data = await res.json(); setUnreadCount(data.unread); } } catch (err) { // Silently ignore — polling will retry } }, []); const fetchNotifications = useCallback(async () => { try { const res = await fetch(`${API_BASE}/notifications`, { credentials: 'include' }); if (res.ok) { const data = await res.json(); setNotifications(data); } } catch (err) { // Silently ignore } }, []); // Poll for unread count every 60 seconds useEffect(() => { fetchCount(); const interval = setInterval(fetchCount, 60000); return () => clearInterval(interval); }, [fetchCount]); // Fetch full list when dropdown opens useEffect(() => { if (open) { fetchNotifications(); } }, [open, fetchNotifications]); // Close dropdown on outside click useEffect(() => { function handleClickOutside(e) { if (containerRef.current && !containerRef.current.contains(e.target)) { setOpen(false); } } if (open) { document.addEventListener('mousedown', handleClickOutside); } return () => document.removeEventListener('mousedown', handleClickOutside); }, [open]); const markAsRead = async (id) => { try { await fetch(`${API_BASE}/notifications/${id}/read`, { method: 'PATCH', credentials: 'include', }); setNotifications(prev => prev.filter(n => n.id !== id)); setUnreadCount(prev => Math.max(0, prev - 1)); } catch (err) { // Silently ignore } }; const markAllRead = async () => { try { await fetch(`${API_BASE}/notifications/read-all`, { method: 'POST', credentials: 'include', }); setNotifications([]); setUnreadCount(0); } catch (err) { // Silently ignore } }; const formatTime = (timestamp) => { const date = new Date(timestamp); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); if (diffMins < 1) return 'just now'; if (diffMins < 60) return `${diffMins}m ago`; const diffHours = Math.floor(diffMins / 60); if (diffHours < 24) return `${diffHours}h ago`; const diffDays = Math.floor(diffHours / 24); if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); }; // Render markdown bold (**text**) as const renderMessage = (msg) => { const parts = msg.split(/\*\*(.*?)\*\*/g); return parts.map((part, i) => i % 2 === 1 ? {part} : part ); }; return (
{open && (
Notifications {notifications.length > 0 && ( )}
{notifications.length === 0 ? (
No unread notifications
) : ( notifications.map(n => (
markAsRead(n.id)} onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'; }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} >
{renderMessage(n.message)}
{formatTime(n.created_at)}
)) )}
)}
); } export default NotificationBell;