229 lines
9.5 KiB
TypeScript
229 lines
9.5 KiB
TypeScript
import React, { memo, useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { Bell, Check, ExternalLink, AlertCircle, LogIn, Kanban } from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { useNotifications } from '@/hooks/use-notifications';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useSession, signIn } from 'next-auth/react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { SafeHTML } from '@/components/safe-html';
|
|
|
|
interface NotificationBadgeProps {
|
|
className?: string;
|
|
}
|
|
|
|
// Use React.memo to prevent unnecessary re-renders
|
|
export const NotificationBadge = memo(function NotificationBadge({ className }: NotificationBadgeProps) {
|
|
const { data: session, status } = useSession();
|
|
const { notifications, notificationCount, markAsRead, markAllAsRead, fetchNotifications, loading, error } = useNotifications();
|
|
const hasUnread = notificationCount.unread > 0;
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [manualFetchAttempted, setManualFetchAttempted] = useState(false);
|
|
|
|
console.log('[NOTIFICATION_BADGE] Auth status:', status);
|
|
console.log('[NOTIFICATION_BADGE] Session:', session ? 'exists' : 'null');
|
|
console.log('[NOTIFICATION_BADGE] Current notification count:', notificationCount);
|
|
console.log('[NOTIFICATION_BADGE] Current notifications:', notifications.length > 0 ? `${notifications.length} loaded` : 'none loaded');
|
|
console.log('[NOTIFICATION_BADGE] Loading state:', loading);
|
|
console.log('[NOTIFICATION_BADGE] Error state:', error);
|
|
|
|
// Manual fetch function with error handling
|
|
const manualFetch = async () => {
|
|
console.log('[NOTIFICATION_BADGE] Manual fetch initiated');
|
|
setManualFetchAttempted(true);
|
|
|
|
try {
|
|
// Direct fetch to debug
|
|
const response = await fetch('/api/notifications', {
|
|
credentials: 'include'
|
|
});
|
|
|
|
console.log('[NOTIFICATION_BADGE] Manual fetch response:', response.status);
|
|
|
|
if (!response.ok) {
|
|
console.error('[NOTIFICATION_BADGE] Manual fetch failed:', response.status, await response.text());
|
|
} else {
|
|
const data = await response.json();
|
|
console.log('[NOTIFICATION_BADGE] Manual fetch success:', data);
|
|
}
|
|
} catch (err) {
|
|
console.error('[NOTIFICATION_BADGE] Manual fetch error:', err);
|
|
}
|
|
|
|
// Then try the normal way
|
|
fetchNotifications(1, 10);
|
|
};
|
|
|
|
// Fetch notifications when dropdown is opened
|
|
useEffect(() => {
|
|
if (isOpen && status === 'authenticated') {
|
|
console.log('[NOTIFICATION_BADGE] Dropdown opened, fetching notifications');
|
|
manualFetch();
|
|
}
|
|
}, [isOpen, status]);
|
|
|
|
const handleMarkAsRead = async (notificationId: string) => {
|
|
await markAsRead(notificationId);
|
|
};
|
|
|
|
const handleMarkAllAsRead = async () => {
|
|
await markAllAsRead();
|
|
setIsOpen(false);
|
|
};
|
|
|
|
// Force fetch when component mounts
|
|
useEffect(() => {
|
|
if (status === 'authenticated') {
|
|
console.log('[NOTIFICATION_BADGE] Component mounted and authenticated, fetching initial notifications');
|
|
manualFetch();
|
|
}
|
|
}, [status]);
|
|
|
|
// Take the latest 10 notifications for the dropdown
|
|
const recentNotifications = notifications.slice(0, 10);
|
|
|
|
const handleOpenChange = (open: boolean) => {
|
|
setIsOpen(open);
|
|
if (open && status === 'authenticated') {
|
|
// Fetch fresh notifications when dropdown opens
|
|
console.log('[NOTIFICATION_BADGE] Dropdown opened via handleOpenChange, fetching notifications');
|
|
manualFetch();
|
|
}
|
|
};
|
|
|
|
// Special case for auth error
|
|
const isAuthError = error?.includes('Not authenticated') || error?.includes('401');
|
|
|
|
return (
|
|
<div className={`relative ${className || ''}`}>
|
|
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="text-white/80 hover:text-white relative p-0">
|
|
<Bell className='w-5 h-5' />
|
|
{hasUnread && (
|
|
<Badge
|
|
variant="notification"
|
|
size="notification"
|
|
className="absolute -top-2 -right-2 z-50"
|
|
>
|
|
{notificationCount.unread > 99 ? '99+' : notificationCount.unread}
|
|
</Badge>
|
|
)}
|
|
<span className="sr-only">Notifications</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 max-h-[80vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between p-4">
|
|
<h3 className="font-medium">Notifications</h3>
|
|
{notificationCount.unread > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead}>
|
|
<Check className="h-4 w-4 mr-2" />
|
|
Mark all read
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
|
|
{loading ? (
|
|
<div className="py-8 px-4 text-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
|
|
<p className="text-sm text-muted-foreground">Loading notifications...</p>
|
|
</div>
|
|
) : isAuthError ? (
|
|
<div className="py-8 px-4 text-center">
|
|
<LogIn className="h-8 w-8 text-orange-500 mx-auto mb-2" />
|
|
<p className="text-sm text-muted-foreground mb-2">Authentication required</p>
|
|
<Button variant="outline" size="sm" onClick={() => signIn()}>
|
|
Sign in
|
|
</Button>
|
|
</div>
|
|
) : error ? (
|
|
<div className="py-8 px-4 text-center">
|
|
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-2" />
|
|
<p className="text-sm text-red-500 mb-2">{error}</p>
|
|
<Button variant="outline" size="sm" onClick={manualFetch}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
) : notifications.length === 0 && manualFetchAttempted ? (
|
|
<div className="py-8 px-4 text-center">
|
|
<p className="text-sm text-muted-foreground">No notifications found</p>
|
|
<Button variant="outline" size="sm" className="mt-2" onClick={manualFetch}>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="py-8 px-4 text-center">
|
|
<p className="text-sm text-muted-foreground">No notifications</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{recentNotifications.map((notification) => (
|
|
<DropdownMenuItem key={notification.id} className="px-4 py-3 cursor-default">
|
|
<div className="w-full">
|
|
<div className="flex items-start justify-between">
|
|
<div className="max-w-[90%]">
|
|
<div className="text-sm font-medium">
|
|
{notification.title}
|
|
{!notification.isRead && (
|
|
<Badge variant="secondary" className="ml-2 bg-blue-500 text-white">New</Badge>
|
|
)}
|
|
{notification.source === 'leantime' && (
|
|
<Badge variant="outline" className="ml-2 text-[10px] py-0 px-1.5 bg-amber-50 text-amber-700 border-amber-200 flex items-center">
|
|
<Kanban className="mr-1 h-2.5 w-2.5" />
|
|
Agilité
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })}
|
|
{notification.source && (
|
|
<span className="ml-1 opacity-75">
|
|
• {notification.source === 'leantime' ? 'Leantime' : notification.source}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex space-x-1 ml-2">
|
|
{!notification.isRead && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={() => handleMarkAsRead(notification.id)}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
<span className="sr-only">Mark as read</span>
|
|
</Button>
|
|
)}
|
|
{notification.link && (
|
|
<Link href={notification.link} target="_blank">
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
<span className="sr-only">Open</span>
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<SafeHTML
|
|
html={notification.message}
|
|
className="text-xs mt-1 notification-message"
|
|
/>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}); |