← back to Norma
components/donations/DonationSummary.tsx
350 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { DollarSign, RefreshCw, Users, TrendingUp, Repeat } from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface DonationRow {
id: string;
amount: number | string;
source: string;
recurring: boolean;
donated_at: string;
}
interface SourceRow {
source: string;
total: number;
}
interface ApiSummary {
total: number;
count: number;
avg: number;
recurring_count: number;
}
interface DonationSummaryData {
donations: DonationRow[];
summary: ApiSummary;
/** computed client-side */
by_source: SourceRow[];
}
function computeBySource(donations: DonationRow[]): SourceRow[] {
const map: Record<string, number> = {};
for (const d of donations) {
const src = d.source || 'other';
map[src] = (map[src] ?? 0) + Number(d.amount);
}
return Object.entries(map)
.map(([source, total]) => ({ source, total }))
.sort((a, b) => b.total - a.total);
}
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function fmtCurrency(n: number | string): string {
const v = Number(n);
if (isNaN(v)) return '$0.00';
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtCount(n: number | string): string {
return Number(n).toLocaleString('en-US');
}
function currentMonthFrom(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
}
const SOURCE_COLORS: Record<string, string> = {
actblue: '#6366f1',
direct: '#22c55e',
moveon: '#a855f7',
actionnetwork: '#f97316',
check: '#f59e0b',
other: '#71717a',
};
const SOURCE_LABELS: Record<string, string> = {
actblue: 'ActBlue',
direct: 'Direct',
moveon: 'MoveOn',
actionnetwork: 'Action Network',
check: 'Check',
other: 'Other',
};
/* ─── Stat tile ──────────────────────────────────────────────────────────── */
function StatTile({
icon: Icon, label, value, sub, color, index,
}: {
icon: React.ElementType;
label: string;
value: string;
sub?: string;
color: string;
index: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '0.625rem 0.75rem',
flex: '1 1 0',
minWidth: 0,
}}
aria-label={`${label}: ${value}`}
>
<div className="flex items-center gap-1.5 mb-1">
<div
style={{
width: 20, height: 20, borderRadius: 5,
backgroundColor: `${color}20`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
aria-hidden="true"
>
<Icon size={11} style={{ color }} />
</div>
<span className="text-xs" style={{ color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{label}
</span>
</div>
<div className="font-semibold text-sm truncate" style={{ color: 'var(--color-text)' }}>
{value}
</div>
{sub && (
<div className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
{sub}
</div>
)}
</motion.div>
);
}
/* ─── Component ──────────────────────────────────────────────────────────── */
export default function DonationSummary() {
const [data, setData] = useState<DonationSummaryData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const from = currentMonthFrom();
const res = await fetch(`/api/donations?from=${from}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
// Compute by_source client-side from raw donations
const bySource = computeBySource(json.donations ?? []);
setData({ ...json, by_source: bySource });
} catch (err) {
setError((err as Error).message || 'Failed to load donation summary.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const summary = data?.summary;
const bySrc = data?.by_source ?? [];
const maxTotal = Math.max(...bySrc.map((r) => Number(r.total || 0)), 1);
const monthLabel = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
return (
<section aria-labelledby="donation-summary-heading">
<div className="flex items-center justify-between mb-3 gap-2">
<div className="flex items-center gap-2">
<div
style={{
width: 28, height: 28, borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(16,185,129,0.25), rgba(34,197,94,0.25))',
border: '1px solid rgba(16,185,129,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
aria-hidden="true"
>
<DollarSign size={14} style={{ color: '#34d399' }} />
</div>
<div>
<h3
id="donation-summary-heading"
className="text-sm font-semibold"
style={{ color: 'var(--color-text)' }}
>
Donations
</h3>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{monthLabel}
</p>
</div>
</div>
<button
type="button"
onClick={fetchData}
disabled={loading}
className="btn btn-ghost btn-sm"
aria-label="Refresh donation summary"
>
<RefreshCw
size={13}
aria-hidden="true"
style={{ animation: loading ? 'spin 0.7s linear infinite' : 'none' }}
/>
</button>
</div>
{/* Loading */}
{loading && (
<div className="flex items-center gap-2 py-4" aria-live="polite" aria-busy="true">
<span className="spinner" aria-label="Loading donations" />
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Loading donations...</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={fetchData} className="btn btn-secondary btn-sm text-xs">
Retry
</button>
</div>
)}
{/* Content */}
{!loading && !error && (
<>
{/* Stats row */}
<div className="flex gap-2 flex-wrap mb-4">
<StatTile
icon={DollarSign}
label="Total"
value={fmtCurrency(summary?.total ?? 0)}
color="#34d399"
index={0}
/>
<StatTile
icon={Users}
label="Donations"
value={fmtCount(summary?.count ?? 0)}
color="#a78bfa"
index={1}
/>
<StatTile
icon={TrendingUp}
label="Average"
value={fmtCurrency(summary?.avg ?? 0)}
color="#f59e0b"
index={2}
/>
<StatTile
icon={Repeat}
label="Recurring"
value={fmtCount(summary?.recurring_count ?? 0)}
sub="donors"
color="#22c55e"
index={3}
/>
</div>
{/* By-source mini bar chart */}
{bySrc.length > 0 && (
<div
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '0.625rem 0.75rem',
}}
aria-label="Donations by source"
>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>
By Source
</p>
<div className="flex flex-col gap-1.5" role="list">
{bySrc.map((row) => {
const total = Number(row.total ?? 0);
const pct = Math.round((total / maxTotal) * 100);
const color = SOURCE_COLORS[row.source] ?? '#71717a';
const label = SOURCE_LABELS[row.source] ?? row.source;
return (
<div key={row.source} role="listitem" className="flex items-center gap-2">
<span
className="text-xs shrink-0"
style={{ color: 'var(--color-text-muted)', minWidth: 88 }}
aria-label={label}
>
{label}
</span>
<div
style={{
flex: 1, height: 6, borderRadius: 3,
backgroundColor: 'var(--color-surface)',
overflow: 'hidden',
}}
role="meter"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${label}: ${pct}%`}
>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{ height: '100%', backgroundColor: color, borderRadius: 3 }}
/>
</div>
<span
className="text-xs shrink-0 font-medium"
style={{ color: 'var(--color-text-secondary)', minWidth: 64, textAlign: 'right' }}
>
{fmtCurrency(total)}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Empty state */}
{bySrc.length === 0 && (
<div
className="rounded-lg flex flex-col items-center py-6 gap-2"
style={{ backgroundColor: 'var(--color-surface-el)', border: '1px dashed var(--color-border)' }}
>
<DollarSign size={20} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
No donations recorded this month.
</p>
</div>
)}
</>
)}
</section>
);
}