localgreenchain/components/notifications/NotificationList.tsx
Claude 62c1ded598
Add comprehensive notification system (Agent 8)
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
2025-11-23 03:52:41 +00:00

229 lines
6.7 KiB
TypeScript

/**
* NotificationList Component
* Displays a list of notifications with infinite scroll
*/
import React, { useState, useEffect } from 'react';
import { NotificationItem } from './NotificationItem';
interface Notification {
id: string;
type: string;
title: string;
message: string;
actionUrl?: string;
read: boolean;
createdAt: string;
}
interface NotificationListProps {
userId?: string;
onNotificationRead?: () => void;
onAllRead?: () => void;
compact?: boolean;
showFilters?: boolean;
}
export function NotificationList({
userId = 'demo-user',
onNotificationRead,
onAllRead,
compact = false,
showFilters = false
}: NotificationListProps) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const limit = compact ? 5 : 20;
useEffect(() => {
fetchNotifications(true);
}, [userId, filter]);
async function fetchNotifications(reset = false) {
try {
setIsLoading(true);
const currentOffset = reset ? 0 : offset;
const unreadOnly = filter === 'unread';
const response = await fetch(
`/api/notifications?userId=${userId}&limit=${limit}&offset=${currentOffset}&unreadOnly=${unreadOnly}`
);
const data = await response.json();
if (data.success) {
if (reset) {
setNotifications(data.data.notifications);
} else {
setNotifications(prev => [...prev, ...data.data.notifications]);
}
setHasMore(data.data.pagination.hasMore);
setOffset(currentOffset + limit);
} else {
setError(data.error);
}
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
}
async function handleMarkAsRead(notificationId: string) {
try {
const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true, userId })
});
if (response.ok) {
setNotifications(prev =>
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n))
);
onNotificationRead?.();
}
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
async function handleMarkAllAsRead() {
try {
const response = await fetch('/api/notifications/read-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (response.ok) {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
onAllRead?.();
}
} catch (error) {
console.error('Failed to mark all as read:', error);
}
}
async function handleDelete(notificationId: string) {
try {
const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, {
method: 'DELETE'
});
if (response.ok) {
setNotifications(prev => prev.filter(n => n.id !== notificationId));
}
} catch (error) {
console.error('Failed to delete notification:', error);
}
}
if (error) {
return (
<div className="p-4 text-center text-red-600">
<p>Failed to load notifications</p>
<button
onClick={() => fetchNotifications(true)}
className="mt-2 text-sm text-green-600 hover:text-green-700"
>
Try again
</button>
</div>
);
}
return (
<div className={compact ? '' : 'max-w-2xl mx-auto'}>
{showFilters && (
<div className="flex items-center justify-between mb-4 p-4 bg-white rounded-lg border">
<div className="flex space-x-2">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1 rounded-full text-sm ${
filter === 'all'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
All
</button>
<button
onClick={() => setFilter('unread')}
className={`px-3 py-1 rounded-full text-sm ${
filter === 'unread'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Unread
</button>
</div>
<button
onClick={handleMarkAllAsRead}
className="text-sm text-green-600 hover:text-green-700"
>
Mark all as read
</button>
</div>
)}
{isLoading && notifications.length === 0 ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
<p className="mt-2 text-gray-500">Loading notifications...</p>
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg
className="w-12 h-12 mx-auto mb-4 text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
<p>No notifications yet</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={handleMarkAsRead}
onDelete={handleDelete}
compact={compact}
/>
))}
</div>
)}
{hasMore && !isLoading && (
<div className="p-4 text-center">
<button
onClick={() => fetchNotifications(false)}
className="text-sm text-green-600 hover:text-green-700"
>
Load more
</button>
</div>
)}
{isLoading && notifications.length > 0 && (
<div className="p-4 text-center">
<div className="inline-block animate-spin rounded-full h-4 w-4 border-2 border-gray-200 border-t-green-500"></div>
</div>
)}
</div>
);
}