Implement multi-channel notification system with: - Core notification service with email, push, and in-app channels - Email templates for all notification types (welcome, plant registered, transport alerts, farm alerts, harvest ready, demand matches, weekly digest) - Push notification support with VAPID authentication - In-app notification management with read/unread tracking - Notification scheduler for recurring and scheduled notifications - API endpoints for notifications CRUD, preferences, and subscriptions - UI components (NotificationBell, NotificationList, NotificationItem, PreferencesForm) - Full notifications page with preferences management - Service worker for push notification handling
127 lines
4.1 KiB
TypeScript
127 lines
4.1 KiB
TypeScript
/**
|
|
* NotificationBell Component
|
|
* Header bell icon with unread badge and dropdown
|
|
*/
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { NotificationList } from './NotificationList';
|
|
|
|
interface NotificationBellProps {
|
|
userId?: string;
|
|
}
|
|
|
|
export function NotificationBell({ userId = 'demo-user' }: NotificationBellProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
fetchUnreadCount();
|
|
|
|
// Poll for new notifications every 30 seconds
|
|
const interval = setInterval(fetchUnreadCount, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [userId]);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
async function fetchUnreadCount() {
|
|
try {
|
|
const response = await fetch(`/api/notifications?userId=${userId}&unreadOnly=true&limit=1`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setUnreadCount(data.data.unreadCount);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch unread count:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleNotificationRead() {
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
}
|
|
|
|
function handleAllRead() {
|
|
setUnreadCount(0);
|
|
}
|
|
|
|
return (
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 rounded-full transition-colors"
|
|
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
|
|
>
|
|
<svg
|
|
className="w-6 h-6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
|
/>
|
|
</svg>
|
|
|
|
{!isLoading && unreadCount > 0 && (
|
|
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute right-0 mt-2 w-96 max-h-[80vh] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
|
<div className="p-4 border-b border-gray-100">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={handleAllRead}
|
|
className="text-sm text-green-600 hover:text-green-700"
|
|
>
|
|
Mark all as read
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto max-h-96">
|
|
<NotificationList
|
|
userId={userId}
|
|
onNotificationRead={handleNotificationRead}
|
|
onAllRead={handleAllRead}
|
|
compact
|
|
/>
|
|
</div>
|
|
|
|
<div className="p-3 border-t border-gray-100 bg-gray-50">
|
|
<a
|
|
href="/notifications"
|
|
className="block text-center text-sm text-green-600 hover:text-green-700"
|
|
>
|
|
View all notifications
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|