'use client'; import React, { useState, useEffect, useCallback, useRef } from 'react'; import Link from 'next/link'; import { useAuth } from '@/contexts/AuthContext'; import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; // ─── Types ──────────────────────────────────────────────────────────────────── type AlertType = 'pending_approval' | 'failed_import' | 'scheduled_publish' | 'info'; type AlertSeverity = 'critical' | 'warning' | 'info' | 'success'; interface Notification { id: string; type: AlertType; severity: AlertSeverity; title: string; message: string; articleId?: string; articleTitle?: string; author?: string; timestamp: string; read: boolean; actionUrl?: string; actionLabel?: string; meta?: Record; } interface NotificationStats { total: number; unread: number; pendingApprovals: number; failedImports: number; scheduledPublishes: number; } // ─── Helpers ────────────────────────────────────────────────────────────────── function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 7) return `${days}d ago`; return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function formatScheduledTime(dateStr: string): string { const d = new Date(dateStr); return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } const SEVERITY_CONFIG: Record = { critical: { bg: 'bg-red-500/5', border: 'border-red-500/30', badge: 'text-red-400 bg-red-400/10', dot: 'bg-red-400', }, warning: { bg: 'bg-yellow-500/5', border: 'border-yellow-500/30', badge: 'text-yellow-400 bg-yellow-400/10', dot: 'bg-yellow-400', }, info: { bg: 'bg-blue-500/5', border: 'border-blue-500/30', badge: 'text-blue-400 bg-blue-400/10', dot: 'bg-blue-400', }, success: { bg: 'bg-green-500/5', border: 'border-green-500/30', badge: 'text-green-400 bg-green-400/10', dot: 'bg-green-400', }, }; const TYPE_LABELS: Record = { pending_approval: 'Pending Approval', failed_import: 'Failed Import', scheduled_publish: 'Scheduled Publish', info: 'Info', }; // ─── Icons ──────────────────────────────────────────────────────────────────── function BellIcon({ className = '' }: { className?: string }) { return ( ); } function ClockIcon({ className = '' }: { className?: string }) { return ( ); } function AlertIcon({ className = '' }: { className?: string }) { return ( ); } function CheckCircleIcon({ className = '' }: { className?: string }) { return ( ); } function ImportIcon({ className = '' }: { className?: string }) { return ( ); } function RefreshIcon({ className = '' }: { className?: string }) { return ( ); } function TypeIcon({ type }: { type: AlertType }) { const cls = 'w-4 h-4'; if (type === 'pending_approval') return ; if (type === 'failed_import') return ; if (type === 'scheduled_publish') return ; return ; } // ─── Stat Card ──────────────────────────────────────────────────────────────── interface StatCardProps { label: string; value: number; icon: React.ReactNode; color: string; active: boolean; onClick: () => void; } function StatCard({ label, value, icon, color, active, onClick }: StatCardProps) { return ( ); } // ─── Notification Row ───────────────────────────────────────────────────────── interface NotificationRowProps { notification: Notification; onMarkRead: (id: string) => void; onDismiss: (id: string) => void; } function NotificationRow({ notification: n, onMarkRead, onDismiss }: NotificationRowProps) { const cfg = SEVERITY_CONFIG[n.severity]; return (
{/* Unread dot */} {!n.read && ( )}
{/* Icon */}
{/* Content */}
{TYPE_LABELS[n.type]} {n.severity.toUpperCase()} {timeAgo(n.timestamp)}

{n.title}

{n.message}

{/* Article info */} {n.articleTitle && (

Article: {n.articleTitle} {n.author && · by {n.author}}

)} {/* Scheduled time meta */} {n.type === 'scheduled_publish' && n.meta?.scheduledAt && (

Scheduled for: {formatScheduledTime(String(n.meta.scheduledAt))}

)} {/* Actions */}
{n.actionUrl && ( {n.actionLabel || 'View →'} )} {!n.read && ( )}
); } // ─── Main Page ──────────────────────────────────────────────────────────────── export default function NotificationsPage() { const { user } = useAuth(); const router = useRouter(); const supabase = createClient(); const [notifications, setNotifications] = useState([]); const [stats, setStats] = useState({ total: 0, unread: 0, pendingApprovals: 0, failedImports: 0, scheduledPublishes: 0, }); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [filter, setFilter] = useState('all'); const [lastRefreshed, setLastRefreshed] = useState(new Date()); const intervalRef = useRef(null); // Auth guard useEffect(() => { if (user === null) router.push('/login?next=' + encodeURIComponent(window.location.pathname)); }, [user, router]); // Build notifications from live DB data const fetchNotifications = useCallback(async () => { try { const built: Notification[] = []; // 1. Pending approvals const { data: pending } = await supabase .from('articles') .select('id, title, author_name, created_at, source_type') .eq('status', 'pending') .order('created_at', { ascending: false }) .limit(20); (pending || []).forEach((a) => { built.push({ id: `pending-${a.id}`, type: 'pending_approval', severity: 'warning', title: 'Article Awaiting Approval', message: `"${a.title}" has been submitted and is waiting for editorial review.`, articleId: a.id, articleTitle: a.title, author: a.author_name, timestamp: a.created_at, read: false, actionUrl: '/admin/articles', actionLabel: 'Review Articles →', }); }); // 2. Failed imports — articles imported with errors (source_type=imported, status=draft older than 1h) const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const { data: failedImports } = await supabase .from('articles') .select('id, title, author_name, created_at, source_type') .eq('source_type', 'imported') .eq('status', 'draft') .lt('created_at', oneHourAgo) .order('created_at', { ascending: false }) .limit(10); (failedImports || []).forEach((a) => { built.push({ id: `import-${a.id}`, type: 'failed_import', severity: 'critical', title: 'Import Stuck in Draft', message: `Imported article "${a.title}" has been in draft for over 1 hour and may require manual review.`, articleId: a.id, articleTitle: a.title, author: a.author_name, timestamp: a.created_at, read: false, actionUrl: '/admin/import', actionLabel: 'Go to Import →', }); }); // 3. Scheduled publishes — articles with scheduled_at in the future const { data: scheduled } = await supabase .from('articles') .select('id, title, author_name, scheduled_at, created_at') .eq('status', 'scheduled') .order('scheduled_at', { ascending: true }) .limit(15); (scheduled || []).forEach((a) => { const scheduledAt = a.scheduled_at || a.created_at; const isImminent = new Date(scheduledAt).getTime() - Date.now() < 2 * 60 * 60 * 1000; built.push({ id: `scheduled-${a.id}`, type: 'scheduled_publish', severity: isImminent ? 'warning' : 'info', title: isImminent ? 'Publish Imminent' : 'Scheduled Publish', message: isImminent ? `"${a.title}" is scheduled to publish within the next 2 hours.` : `"${a.title}" is queued for scheduled publication.`, articleId: a.id, articleTitle: a.title, author: a.author_name, timestamp: a.created_at, read: false, actionUrl: '/admin/articles', actionLabel: 'Manage Articles →', meta: { scheduledAt }, }); }); // Sort by timestamp desc built.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); // Restore read state from sessionStorage const readIds: string[] = JSON.parse(sessionStorage.getItem('bb_notif_read') || '[]'); const dismissed: string[] = JSON.parse(sessionStorage.getItem('bb_notif_dismissed') || '[]'); const filtered = built .filter((n) => !dismissed.includes(n.id)) .map((n) => ({ ...n, read: readIds.includes(n.id) })); setNotifications(filtered); setStats({ total: filtered.length, unread: filtered.filter((n) => !n.read).length, pendingApprovals: filtered.filter((n) => n.type === 'pending_approval').length, failedImports: filtered.filter((n) => n.type === 'failed_import').length, scheduledPublishes: filtered.filter((n) => n.type === 'scheduled_publish').length, }); setLastRefreshed(new Date()); } catch (err) { console.error('Failed to fetch notifications:', err); } finally { setLoading(false); setRefreshing(false); } }, [supabase]); useEffect(() => { fetchNotifications(); // Real-time polling every 30 seconds intervalRef.current = setInterval(fetchNotifications, 30000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, [fetchNotifications]); // Real-time Supabase subscription for articles table changes useEffect(() => { const channel = supabase .channel('notifications-articles') .on('postgres_changes', { event: '*', schema: 'public', table: 'articles' }, () => { fetchNotifications(); }) .subscribe(); return () => { supabase.removeChannel(channel); }; }, [supabase, fetchNotifications]); const handleMarkRead = (id: string) => { const readIds: string[] = JSON.parse(sessionStorage.getItem('bb_notif_read') || '[]'); if (!readIds.includes(id)) { sessionStorage.setItem('bb_notif_read', JSON.stringify([...readIds, id])); } setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n)); setStats((prev) => ({ ...prev, unread: Math.max(0, prev.unread - 1) })); }; const handleDismiss = (id: string) => { const dismissed: string[] = JSON.parse(sessionStorage.getItem('bb_notif_dismissed') || '[]'); if (!dismissed.includes(id)) { sessionStorage.setItem('bb_notif_dismissed', JSON.stringify([...dismissed, id])); } const n = notifications.find((x) => x.id === id); setNotifications((prev) => prev.filter((x) => x.id !== id)); setStats((prev) => ({ ...prev, total: prev.total - 1, unread: n && !n.read ? Math.max(0, prev.unread - 1) : prev.unread, pendingApprovals: n?.type === 'pending_approval' ? prev.pendingApprovals - 1 : prev.pendingApprovals, failedImports: n?.type === 'failed_import' ? prev.failedImports - 1 : prev.failedImports, scheduledPublishes: n?.type === 'scheduled_publish' ? prev.scheduledPublishes - 1 : prev.scheduledPublishes, })); }; const handleMarkAllRead = () => { const ids = notifications.map((n) => n.id); sessionStorage.setItem('bb_notif_read', JSON.stringify(ids)); setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); setStats((prev) => ({ ...prev, unread: 0 })); }; const handleRefresh = async () => { setRefreshing(true); await fetchNotifications(); }; const filtered = notifications.filter((n) => { if (filter === 'all') return true; if (filter === 'unread') return !n.read; return n.type === filter; }); if (loading) { return (

Loading notifications…

); } return (
{/* Header */}
Dashboard / Notifications

Notifications

{stats.unread > 0 && ( {stats.unread} unread )}

Real-time alerts · Last updated {lastRefreshed.toLocaleTimeString()}

{stats.unread > 0 && ( )}
{/* Stat Cards */}
} color="text-yellow-400 bg-yellow-400/5 border-yellow-400/30" active={filter === 'pending_approval'} onClick={() => setFilter(filter === 'pending_approval' ? 'all' : 'pending_approval')} /> } color="text-red-400 bg-red-400/5 border-red-400/30" active={filter === 'failed_import'} onClick={() => setFilter(filter === 'failed_import' ? 'all' : 'failed_import')} /> } color="text-blue-400 bg-blue-400/5 border-blue-400/30" active={filter === 'scheduled_publish'} onClick={() => setFilter(filter === 'scheduled_publish' ? 'all' : 'scheduled_publish')} /> } color="text-[#F0A623] bg-[#F0A623]/5 border-[#F0A623]/30" active={filter === 'unread'} onClick={() => setFilter(filter === 'unread' ? 'all' : 'unread')} />
{/* Filter Tabs */}
{(['all', 'unread', 'pending_approval', 'failed_import', 'scheduled_publish'] as const).map((f) => { const labels: Record = { all: `All (${stats.total})`, unread: `Unread (${stats.unread})`, pending_approval: 'Pending', failed_import: 'Failed Imports', scheduled_publish: 'Scheduled', }; return ( ); })}
{/* Notification List */} {filtered.length === 0 ? (

No notifications

{filter === 'all' ? 'Everything looks good!' : `No ${filter.replace('_', ' ')} alerts`}

) : (
{filtered.map((n) => ( ))}
)} {/* Footer info */} {filtered.length > 0 && (

Auto-refreshes every 30 seconds · Real-time updates via Supabase

)}
); }