← back to Norma
components/donations/DonationInsights.tsx
426 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
TrendingUp,
Lightbulb,
AlertTriangle,
Trophy,
RefreshCw,
ChevronDown,
ChevronUp,
X,
DollarSign,
} from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Insight {
id: string;
insight_type: 'trend' | 'recommendation' | 'warning' | 'milestone';
/** Alias: some API responses may return 'type' from heuristic generator */
type?: 'trend' | 'recommendation' | 'warning' | 'milestone';
title: string;
description: string;
confidence: number;
data_points: Record<string, unknown>;
is_actionable: boolean;
is_dismissed: boolean;
created_at: string;
}
/* ─── Icon + color config by type ───────────────────────────────────────── */
const TYPE_CONFIG: Record<
string,
{ Icon: React.ElementType; color: string; bg: string; border: string; label: string }
> = {
trend: {
Icon: TrendingUp,
color: '#34d399',
bg: 'rgba(52,211,153,0.1)',
border: 'rgba(52,211,153,0.25)',
label: 'Trend',
},
recommendation: {
Icon: Lightbulb,
color: '#f59e0b',
bg: 'rgba(245,158,11,0.1)',
border: 'rgba(245,158,11,0.25)',
label: 'Recommendation',
},
warning: {
Icon: AlertTriangle,
color: '#ef4444',
bg: 'rgba(239,68,68,0.1)',
border: 'rgba(239,68,68,0.25)',
label: 'Warning',
},
milestone: {
Icon: Trophy,
color: '#22c55e',
bg: 'rgba(34,197,94,0.1)',
border: 'rgba(34,197,94,0.25)',
label: 'Milestone',
},
};
/* ─── Format a data_points value for display ─────────────────────────────── */
function formatDataValue(val: unknown): string {
if (val == null) return '—';
if (typeof val === 'number') {
if (val > 1000) return '$' + val.toLocaleString('en-US', { minimumFractionDigits: 0 });
if (val < 1 && val > 0) return (val * 100).toFixed(0) + '%';
return val.toLocaleString('en-US');
}
return String(val);
}
function humanizeKey(key: string): string {
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/* ─── Individual insight card ───────────────────────────────────────────── */
function InsightCard({
insight,
index,
onDismiss,
}: {
insight: Insight;
index: number;
onDismiss: (id: string) => void;
}) {
const [dismissing, setDismissing] = useState(false);
const insightType = insight.insight_type ?? insight.type ?? 'trend';
const cfg = TYPE_CONFIG[insightType] ?? TYPE_CONFIG.trend;
const { Icon, color, bg, border, label } = cfg;
const confidencePct = Math.round((insight.confidence ?? 0.8) * 100);
const dataEntries = Object.entries(insight.data_points ?? {}).filter(
([, v]) => v != null
);
async function handleDismiss() {
setDismissing(true);
try {
// PATCH dismissal — route is /api/donations/insights/[id]
await fetch(`/api/donations/insights/${insight.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_dismissed: true }),
});
onDismiss(insight.id);
} catch (err) {
console.error('[DonationInsights] dismiss error:', err);
setDismissing(false);
}
}
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2, delay: Math.min(index, 4) * 0.05 }}
style={{
backgroundColor: bg,
border: `1px solid ${insight.is_actionable ? color : border}`,
borderRadius: 'var(--radius-md)',
padding: '0.75rem',
position: 'relative',
boxShadow: insight.is_actionable
? `0 0 0 1px ${color}20, 0 2px 8px rgba(0,0,0,0.15)`
: undefined,
}}
aria-label={`${label} insight: ${insight.title}`}
>
{/* Dismiss button */}
<button
type="button"
onClick={handleDismiss}
disabled={dismissing}
className="btn btn-ghost btn-sm"
aria-label={`Dismiss insight: ${insight.title}`}
style={{
position: 'absolute', top: '0.5rem', right: '0.5rem',
padding: '0.15rem', minWidth: 0,
}}
>
{dismissing ? (
<span className="spinner" style={{ width: 12, height: 12, borderWidth: 1.5 }} aria-hidden="true" />
) : (
<X size={12} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
)}
</button>
<div className="flex items-start gap-2.5 pr-6">
{/* Icon */}
<div
style={{
width: 28, height: 28, borderRadius: 6,
backgroundColor: `${color}20`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
aria-hidden="true"
>
<Icon size={14} style={{ color }} />
</div>
<div className="flex-1 min-w-0">
{/* Title + badge row */}
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-xs font-semibold"
style={{ color: 'var(--color-text)' }}
>
{insight.title}
</span>
<span
className="badge"
style={{ color, backgroundColor: `${color}18`, border: `1px solid ${color}40` }}
>
{label}
</span>
{insight.is_actionable && (
<span
className="badge"
style={{
color: '#a78bfa',
backgroundColor: 'rgba(167,139,250,0.12)',
border: '1px solid rgba(167,139,250,0.3)',
}}
>
Actionable
</span>
)}
</div>
{/* Description */}
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
{insight.description}
</p>
{/* Data points */}
{dataEntries.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{dataEntries.map(([key, val]) => (
<div
key={key}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded"
style={{
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
}}
aria-label={`${humanizeKey(key)}: ${formatDataValue(val)}`}
>
<DollarSign size={10} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} aria-hidden="true" />
<span style={{ color: 'var(--color-text-muted)' }}>{humanizeKey(key)}:</span>
<span className="font-medium" style={{ color: 'var(--color-text)' }}>
{formatDataValue(val)}
</span>
</div>
))}
</div>
)}
{/* Confidence */}
<div className="flex items-center gap-1.5 mt-2">
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Confidence:
</span>
<div
style={{ width: 48, height: 3, borderRadius: 2, backgroundColor: 'var(--color-border)', overflow: 'hidden' }}
role="meter"
aria-valuenow={confidencePct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Confidence: ${confidencePct}%`}
>
<div
style={{
width: `${confidencePct}%`, height: '100%',
backgroundColor: color, borderRadius: 2,
}}
/>
</div>
<span className="text-xs font-medium" style={{ color }}>
{confidencePct}%
</span>
</div>
</div>
</div>
</motion.div>
);
}
/* ─── Main component ─────────────────────────────────────────────────────── */
export default function DonationInsights() {
const [insights, setInsights] = useState<Insight[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
const fetchInsights = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/donations/insights');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// Filter out already-dismissed
setInsights((data.insights ?? []).filter((i: Insight) => !i.is_dismissed));
} catch (err) {
setError((err as Error).message || 'Failed to load donation insights.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchInsights(); }, [fetchInsights]);
function handleDismiss(id: string) {
setInsights((prev) => prev.filter((i) => i.id !== id));
}
const visibleInsights = insights;
return (
<section aria-labelledby="donation-insights-heading">
{/* Section header */}
<div className="flex items-center justify-between mb-3 gap-2">
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex items-center gap-2 min-w-0"
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: 'var(--color-text)',
}}
aria-expanded={!collapsed}
aria-controls="donation-insights-list"
>
<div
style={{
width: 28, height: 28, borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(34,197,94,0.25), rgba(245,158,11,0.25))',
border: '1px solid rgba(34,197,94,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
aria-hidden="true"
>
<TrendingUp size={14} style={{ color: '#4ade80' }} />
</div>
<div className="text-left min-w-0">
<h3
id="donation-insights-heading"
className="text-sm font-semibold"
style={{ color: 'var(--color-text)' }}
>
Donation Insights
</h3>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{loading ? 'Loading...' : `${visibleInsights.length} insight${visibleInsights.length !== 1 ? 's' : ''}`}
</p>
</div>
{collapsed
? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} aria-hidden="true" />
: <ChevronUp size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} aria-hidden="true" />
}
</button>
<button
type="button"
onClick={fetchInsights}
disabled={loading}
className="btn btn-ghost btn-sm"
aria-label="Refresh donation insights"
>
<RefreshCw
size={13}
aria-hidden="true"
style={{ animation: loading ? 'spin 0.7s linear infinite' : 'none' }}
/>
</button>
</div>
{/* Collapsible body */}
<AnimatePresence initial={false}>
{!collapsed && (
<motion.div
key="insights-body"
id="donation-insights-list"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.22 }}
style={{ overflow: 'hidden' }}
>
{/* Loading */}
{loading && (
<div className="flex items-center gap-2 py-4" aria-live="polite" aria-busy="true">
<span className="spinner" aria-label="Loading insights" />
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Analyzing donation data...</p>
</div>
)}
{/* Error */}
{!loading && error && (
<div
role="alert"
className="rounded-lg px-3 py-2 text-xs flex items-center justify-between gap-2"
style={{
backgroundColor: 'rgba(239,68,68,0.07)',
border: '1px solid rgba(239,68,68,0.25)',
color: 'var(--color-error)',
}}
>
{error}
<button type="button" onClick={fetchInsights} className="btn btn-secondary btn-sm text-xs">
Retry
</button>
</div>
)}
{/* Empty */}
{!loading && !error && visibleInsights.length === 0 && (
<div
className="rounded-lg flex flex-col items-center py-8 gap-2"
style={{ backgroundColor: 'var(--color-surface)', border: '1px dashed var(--color-border)' }}
>
<TrendingUp size={24} style={{ color: 'rgba(34,197,94,0.4)' }} aria-hidden="true" />
<p className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
No active insights
</p>
<p className="text-xs text-center max-w-xs" style={{ color: 'var(--color-text-muted)' }}>
Insights appear as you record donation activity and connect fundraising platforms.
</p>
</div>
)}
{/* Insights list */}
{!loading && !error && visibleInsights.length > 0 && (
<div
className="flex flex-col gap-2"
role="list"
aria-label="Donation insights"
>
<AnimatePresence>
{visibleInsights.map((insight, i) => (
<InsightCard
key={insight.id}
insight={insight}
index={i}
onDismiss={handleDismiss}
/>
))}
</AnimatePresence>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</section>
);
}