224 lines
7.8 KiB
TypeScript
224 lines
7.8 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Bell } from 'lucide-react';
|
|
import { toast } from 'react-toastify';
|
|
import notificationService, { Notification } from '../../services/api/notificationService';
|
|
import { formatDate } from '../../utils/format';
|
|
import useAuthStore from '../../store/useAuthStore';
|
|
|
|
const InAppNotificationBell: React.FC = () => {
|
|
const { isAuthenticated, token, isLoading } = useAuthStore();
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
|
|
// Wait for auth to initialize before checking
|
|
React.useEffect(() => {
|
|
// Small delay to ensure auth store is initialized
|
|
const timer = setTimeout(() => {
|
|
setIsInitialized(true);
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
// Helper to check if user is actually authenticated (has valid token)
|
|
const isUserAuthenticated = (): boolean => {
|
|
// Don't check if still initializing
|
|
if (isLoading || !isInitialized) {
|
|
return false;
|
|
}
|
|
// Check both store state and localStorage to ensure consistency
|
|
const hasToken = !!(token || localStorage.getItem('token'));
|
|
return !!(isAuthenticated && hasToken);
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Clear any existing interval first
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
|
|
// Early return if not authenticated - don't set up polling at all
|
|
if (!isUserAuthenticated()) {
|
|
setNotifications([]);
|
|
setUnreadCount(0);
|
|
return;
|
|
}
|
|
|
|
// Only proceed if we have both authentication state and token
|
|
const currentToken = token || localStorage.getItem('token');
|
|
if (!currentToken) {
|
|
setNotifications([]);
|
|
setUnreadCount(0);
|
|
return;
|
|
}
|
|
|
|
// Load notifications immediately
|
|
loadNotifications();
|
|
|
|
// Poll for new notifications every 30 seconds, but only if authenticated
|
|
intervalRef.current = setInterval(() => {
|
|
// Re-check authentication on each poll
|
|
const stillAuthenticated = isUserAuthenticated();
|
|
const stillHasToken = !!(token || localStorage.getItem('token'));
|
|
|
|
if (stillAuthenticated && stillHasToken) {
|
|
loadNotifications();
|
|
} else {
|
|
// Clear interval and state if user becomes unauthenticated
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
setNotifications([]);
|
|
setUnreadCount(0);
|
|
}
|
|
}, 30000);
|
|
|
|
return () => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
};
|
|
}, [isAuthenticated, token]);
|
|
|
|
const loadNotifications = async () => {
|
|
// Don't make API call if user is not authenticated or doesn't have a token
|
|
// Double-check both store state and localStorage
|
|
const hasToken = !!(token || localStorage.getItem('token'));
|
|
if (!isAuthenticated || !hasToken) {
|
|
// Clear state if not authenticated
|
|
setNotifications([]);
|
|
setUnreadCount(0);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await notificationService.getMyNotifications({
|
|
status: 'delivered',
|
|
limit: 10,
|
|
});
|
|
const notifs = response.data.data || [];
|
|
setNotifications(notifs);
|
|
setUnreadCount(notifs.filter(n => !n.read_at).length);
|
|
} catch (error) {
|
|
// Silently fail
|
|
}
|
|
};
|
|
|
|
const handleMarkAsRead = async (notificationId: number) => {
|
|
try {
|
|
await notificationService.markAsRead(notificationId);
|
|
setNotifications(notifications.map(n =>
|
|
n.id === notificationId ? { ...n, status: 'read' as any, read_at: new Date().toISOString() } : n
|
|
));
|
|
setUnreadCount(Math.max(0, unreadCount - 1));
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to mark as read');
|
|
}
|
|
};
|
|
|
|
const handleMarkAllAsRead = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const unread = notifications.filter(n => !n.read_at);
|
|
await Promise.all(unread.map(n => notificationService.markAsRead(n.id)));
|
|
setNotifications(notifications.map(n => ({ ...n, status: 'read' as any, read_at: new Date().toISOString() })));
|
|
setUnreadCount(0);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to mark all as read');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Don't render if still initializing, not authenticated, or doesn't have a token
|
|
if (isLoading || !isInitialized || !isUserAuthenticated()) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowDropdown(!showDropdown)}
|
|
className="relative p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
<Bell className="w-6 h-6" />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute top-0 right-0 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
|
{unreadCount > 9 ? '9+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{showDropdown && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-40"
|
|
onClick={() => setShowDropdown(false)}
|
|
/>
|
|
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-2xl border border-gray-200 z-50 max-h-96 overflow-y-auto">
|
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
|
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={handleMarkAllAsRead}
|
|
disabled={loading}
|
|
className="text-xs text-indigo-600 hover:text-indigo-700 font-medium"
|
|
>
|
|
Mark all read
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="divide-y divide-gray-200">
|
|
{notifications.length === 0 ? (
|
|
<div className="p-4 text-center text-sm text-gray-500">
|
|
No notifications
|
|
</div>
|
|
) : (
|
|
notifications.map((notification) => (
|
|
<div
|
|
key={notification.id}
|
|
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
|
|
!notification.read_at ? 'bg-blue-50' : ''
|
|
}`}
|
|
onClick={() => {
|
|
if (!notification.read_at) {
|
|
handleMarkAsRead(notification.id);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<p className="text-sm font-semibold text-gray-900">
|
|
{notification.subject || notification.notification_type.replace('_', ' ')}
|
|
</p>
|
|
<p className="text-xs text-gray-600 mt-1 line-clamp-2">
|
|
{notification.content}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
{formatDate(new Date(notification.created_at), 'short')}
|
|
</p>
|
|
</div>
|
|
{!notification.read_at && (
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full ml-2 mt-1" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InAppNotificationBell;
|
|
|