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
229 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|