← back to Norma
components/drafts/AlertsFeed.tsx
402 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Bell,
Newspaper,
Award,
Megaphone,
Vote,
DollarSign,
Users,
Building2,
ChevronDown,
ChevronUp,
Settings,
AlertCircle,
} from 'lucide-react';
import { useOrg } from '@/components/OrgProvider';
/* ─── Types ────────────────────────────────────────────────────────────────── */
interface AlertItem {
id: string;
type: 'news' | 'grant' | 'petition' | 'congress' | 'donation' | 'community' | 'organization';
title: string;
summary: string;
date: string;
source: string;
}
interface AlertsFeedProps {
onNavigateToSettings?: () => void;
}
/* ─── Constants ────────────────────────────────────────────────────────────── */
// NOTE: These color values are intentionally hardcoded hex strings, NOT CSS variables.
// They are used in template literal rgba() calculations (e.g. `${color}18` for the icon
// background tint), which requires a hex string — CSS variable references like
// `var(--color-warning)` cannot be concatenated in template literals to produce valid CSS.
// If a CSS variable is ever needed here, it must be resolved to its hex value first.
const TYPE_META: Record<
AlertItem['type'],
{ icon: typeof Newspaper; color: string; label: string }
> = {
news: { icon: Newspaper, color: '#06b6d4', label: 'News' }, // cyan — no matching token
grant: { icon: Award, color: '#f59e0b', label: 'Grant' }, // matches --color-warning
petition: { icon: Megaphone, color: '#8b5cf6', label: 'Petition' }, // purple — no token
congress: { icon: Vote, color: '#6366f1', label: 'Congress' }, // matches --color-lane-b / --color-info
donation: { icon: DollarSign, color: '#22c55e', label: 'Donation' }, // matches --color-success (approx)
community: { icon: Users, color: '#f97316', label: 'Community' }, // orange — no token
organization: { icon: Building2, color: '#34d399', label: 'Organization' }, // emerald tint — no token
};
const DEFAULT_CATEGORIES = 'news_media,grants,petitions,donations,community,organizations';
const VISIBLE_LIMIT = 8;
const COLLAPSED_KEY = 'alerts-feed-collapsed';
/* ─── Helpers ──────────────────────────────────────────────────────────────── */
function relativeTime(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
if (isNaN(then)) return '';
const diffMs = now - then;
if (diffMs < 0) return 'just now';
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return 'Yesterday';
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
/* ─── Component ────────────────────────────────────────────────────────────── */
export default function AlertsFeed({ onNavigateToSettings }: AlertsFeedProps) {
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem(COLLAPSED_KEY) === '1';
});
const [showAll, setShowAll] = useState(false);
const { org } = useOrg();
const orgId = org?.id;
/* Fetch alerts — filtered by the active org's keywords */
const fetchAlerts = useCallback(async () => {
if (!org) return; // No org selected, don't fetch
setLoading(true);
setError(false);
try {
let categories = DEFAULT_CATEGORIES;
const cached = typeof window !== 'undefined' ? localStorage.getItem('alert-categories') : null;
if (cached) {
categories = cached;
}
// Build keyword filter from org's search_keywords
const kwParam = org.search_keywords?.length
? `&keywords=${encodeURIComponent(org.search_keywords.join(','))}`
: '';
const alertsPromise = fetch(`/api/alerts?categories=${categories}&limit=30${kwParam}`);
// Refresh settings in background
fetch('/api/settings').then(async (res) => {
if (!res.ok) return;
try {
const data = await res.json();
const prefs = data?.settings?.alert_preferences;
if (prefs) {
const parsed = typeof prefs === 'string' ? JSON.parse(prefs) : prefs;
const CATEGORY_KEYS = ['news_media', 'grants', 'petitions', 'congress', 'donations', 'community', 'organizations'];
const enabled = CATEGORY_KEYS.filter((k) => parsed[k] === true);
if (enabled.length > 0) {
const newCategories = enabled.join(',');
localStorage.setItem('alert-categories', newCategories);
if (newCategories !== categories) {
const newRes = await fetch(`/api/alerts?categories=${newCategories}&limit=30${kwParam}`);
if (newRes.ok) {
const newData = await newRes.json();
setAlerts(newData.alerts ?? []);
}
}
}
}
} catch { /* ignore settings parse errors */ }
}).catch(() => { /* ignore settings fetch errors */ });
const res = await alertsPromise;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setAlerts(data.alerts ?? []);
} catch {
setError(true);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orgId]);
useEffect(() => {
fetchAlerts();
}, [fetchAlerts]);
/* Persist collapsed state */
function toggleCollapsed() {
setCollapsed((prev) => {
const next = !prev;
localStorage.setItem(COLLAPSED_KEY, next ? '1' : '0');
return next;
});
}
/* Don't render if error or no data after loading */
if (!loading && error) return null;
if (!loading && alerts.length === 0) return null;
const visible = showAll ? alerts : alerts.slice(0, VISIBLE_LIMIT);
const hasMore = alerts.length > VISIBLE_LIMIT;
return (
<div
className="card"
style={{
marginBottom: 20,
padding: 0,
overflow: 'hidden',
}}
>
{/* ── Header ──────────────────────────────────────────────────────────── */}
<button
type="button"
onClick={toggleCollapsed}
className="btn-ghost"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '10px 14px',
borderRadius: 0,
cursor: 'pointer',
border: 'none',
borderBottom: collapsed ? 'none' : '1px solid var(--color-border)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Bell size={15} style={{ color: 'var(--color-primary)' }} />
<span
style={{
fontSize: '0.8125rem',
fontWeight: 600,
color: 'var(--color-text)',
}}
>
Alerts
</span>
{!loading && alerts.length > 0 && (
<span
className="badge"
style={{
fontSize: '0.6875rem',
padding: '1px 7px',
minWidth: 20,
textAlign: 'center',
backgroundColor: 'var(--color-primary)',
color: '#fff',
}}
>
{alerts.length}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{onNavigateToSettings && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onNavigateToSettings();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
onNavigateToSettings();
}
}}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: '0.6875rem',
color: 'var(--color-text-muted)',
cursor: 'pointer',
padding: '2px 6px',
borderRadius: 4,
}}
>
<Settings size={12} />
Settings
</span>
)}
{collapsed ? (
<ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />
) : (
<ChevronUp size={14} style={{ color: 'var(--color-text-muted)' }} />
)}
</div>
</button>
{/* ── Body ────────────────────────────────────────────────────────────── */}
{!collapsed && (
<div style={{ padding: '6px 10px 10px' }}>
{/* Skeleton loading */}
{loading && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
className="skeleton"
style={{ height: 36, borderRadius: 6 }}
/>
))}
</div>
)}
{/* Alert list */}
{!loading && visible.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{visible.map((alert) => {
const meta = TYPE_META[alert.type];
const Icon = meta?.icon ?? AlertCircle;
const color = meta?.color ?? 'var(--color-text-muted)';
return (
<div
key={`${alert.type}-${alert.id}`}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '6px 4px',
borderRadius: 6,
transition: 'background-color var(--transition)',
cursor: 'default',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{/* Type icon */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: `${color}18`,
flexShrink: 0,
marginTop: 1,
}}
>
<Icon size={13} style={{ color }} />
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.8rem',
fontWeight: 500,
color: 'var(--color-text)',
lineHeight: 1.35,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={alert.title}
>
{alert.title}
</div>
{alert.summary && (
<div
style={{
fontSize: '0.7rem',
color: 'var(--color-text-muted)',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: 1,
}}
title={alert.summary}
>
{alert.summary}
</div>
)}
</div>
{/* Time */}
<span
style={{
fontSize: '0.65rem',
color: 'var(--color-text-muted)',
whiteSpace: 'nowrap',
flexShrink: 0,
marginTop: 3,
}}
>
{relativeTime(alert.date)}
</span>
</div>
);
})}
</div>
)}
{/* Show more / Show less */}
{!loading && hasMore && (
<button
type="button"
onClick={() => setShowAll((p) => !p)}
className="btn btn-ghost btn-sm"
style={{
width: '100%',
marginTop: 6,
fontSize: '0.7rem',
justifyContent: 'center',
color: 'var(--color-text-secondary)',
}}
>
{showAll ? 'Show less' : `Show ${alerts.length - VISIBLE_LIMIT} more`}
</button>
)}
</div>
)}
</div>
);
}