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
156 lines
5 KiB
TypeScript
156 lines
5 KiB
TypeScript
/**
|
|
* NotificationItem Component
|
|
* Single notification display with actions
|
|
*/
|
|
|
|
import React from 'react';
|
|
|
|
interface Notification {
|
|
id: string;
|
|
type: string;
|
|
title: string;
|
|
message: string;
|
|
actionUrl?: string;
|
|
read: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface NotificationItemProps {
|
|
notification: Notification;
|
|
onMarkAsRead: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
compact?: boolean;
|
|
}
|
|
|
|
const typeIcons: Record<string, { icon: string; bgColor: string }> = {
|
|
welcome: { icon: '👋', bgColor: 'bg-blue-100' },
|
|
plant_registered: { icon: '🌱', bgColor: 'bg-green-100' },
|
|
plant_reminder: { icon: '🌿', bgColor: 'bg-green-100' },
|
|
transport_alert: { icon: '🚚', bgColor: 'bg-yellow-100' },
|
|
farm_alert: { icon: '🏭', bgColor: 'bg-orange-100' },
|
|
harvest_ready: { icon: '🎉', bgColor: 'bg-green-100' },
|
|
demand_match: { icon: '🤝', bgColor: 'bg-purple-100' },
|
|
weekly_digest: { icon: '📊', bgColor: 'bg-blue-100' },
|
|
system_alert: { icon: '⚙️', bgColor: 'bg-gray-100' }
|
|
};
|
|
|
|
export function NotificationItem({
|
|
notification,
|
|
onMarkAsRead,
|
|
onDelete,
|
|
compact = false
|
|
}: NotificationItemProps) {
|
|
const { icon, bgColor } = typeIcons[notification.type] || typeIcons.system_alert;
|
|
|
|
function formatTimeAgo(dateString: string): string {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
|
|
if (seconds < 60) return 'Just now';
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
|
|
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
function handleClick() {
|
|
if (!notification.read) {
|
|
onMarkAsRead(notification.id);
|
|
}
|
|
if (notification.actionUrl) {
|
|
window.location.href = notification.actionUrl;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`relative group ${compact ? 'p-3' : 'p-4'} hover:bg-gray-50 transition-colors ${
|
|
!notification.read ? 'bg-green-50/30' : ''
|
|
}`}
|
|
>
|
|
<div
|
|
className="flex items-start cursor-pointer"
|
|
onClick={handleClick}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyPress={e => e.key === 'Enter' && handleClick()}
|
|
>
|
|
{/* Icon */}
|
|
<div
|
|
className={`flex-shrink-0 ${compact ? 'w-8 h-8' : 'w-10 h-10'} ${bgColor} rounded-full flex items-center justify-center`}
|
|
>
|
|
<span className={compact ? 'text-sm' : 'text-lg'}>{icon}</span>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className={`flex-1 ${compact ? 'ml-3' : 'ml-4'}`}>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<p
|
|
className={`font-medium text-gray-900 ${compact ? 'text-sm' : ''} ${
|
|
!notification.read ? 'font-semibold' : ''
|
|
}`}
|
|
>
|
|
{notification.title}
|
|
</p>
|
|
<p className={`text-gray-600 mt-0.5 ${compact ? 'text-xs line-clamp-2' : 'text-sm'}`}>
|
|
{notification.message}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Unread indicator */}
|
|
{!notification.read && (
|
|
<div className="flex-shrink-0 ml-2">
|
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center mt-2">
|
|
<span className={`text-gray-400 ${compact ? 'text-xs' : 'text-xs'}`}>
|
|
{formatTimeAgo(notification.createdAt)}
|
|
</span>
|
|
|
|
{notification.actionUrl && (
|
|
<span className={`ml-2 text-green-600 ${compact ? 'text-xs' : 'text-xs'}`}>
|
|
View details →
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions (visible on hover) */}
|
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1">
|
|
{!notification.read && (
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onMarkAsRead(notification.id);
|
|
}}
|
|
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded"
|
|
title="Mark as read"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onDelete(notification.id);
|
|
}}
|
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
|
title="Delete"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|