← back to Norma
components/congress/CongressTab.tsx
1404 lines
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
RefreshCw,
Phone,
Globe,
Twitter,
Youtube,
Facebook,
ChevronDown,
ChevronUp,
Users,
MapPin,
School,
Inbox,
ExternalLink,
Linkedin,
ArrowUpDown,
AlertTriangle,
Star,
TrendingUp,
Map,
BarChart3,
} from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface SocialMedia {
twitter?: string | null;
facebook?: string | null;
youtube?: string | null;
}
interface Staffer {
full_name: string;
title: string;
role_category: string;
email: string | null;
phone: string | null;
office_location: string | null;
is_dc_office: boolean;
linkedin_url: string | null;
twitter_handle: string | null;
handles_education: boolean;
handles_finance: boolean;
is_key_contact: boolean;
}
interface CommunityCollege {
school_name: string;
city: string;
state: string;
zip: string;
enrollment: number | null;
median_debt: number | null;
pell_grant_rate: number | null;
completion_rate: number | null;
retention_rate: number | null;
tuition_in_state: number | null;
avg_net_price: number | null;
median_earnings_6yr: number | null;
school_url: string | null;
}
interface CongressMember {
name: string;
title: string;
party: string;
state: string;
district: string | null;
congressional_district: string | null;
phone: string | null;
website: string | null;
email: string | null;
social_media: SocialMedia | null;
photo_url: string | null;
bioguide_id: string;
votes_with_party_pct: number | null;
missed_votes_pct: number | null;
committees: string[] | null;
position_student_debt: string | null;
zip_count: number | null;
community_college_count: number | null;
total_cc_enrollment: number | null;
avg_cc_median_debt: number | null;
avg_cc_pell_rate: number | null;
highest_debt_college: string | null;
total_complaints: number | null;
avg_distress_score: number | null;
opportunity_score: number | null;
opportunity_factors: Record<string, unknown> | null;
staffers: Staffer[];
community_colleges: CommunityCollege[];
}
interface ApiStats {
total_members: number;
avg_opportunity_score: number | null;
total_cc_enrollment: number;
high_priority_count: number;
}
interface ApiResponse {
members: CongressMember[];
total: number;
stats?: ApiStats;
limit: number;
offset: number;
}
/* ─── Constants ──────────────────────────────────────────────────────────── */
const US_STATES = [
{ value: '', label: 'All States' },
{ value: 'AL', label: 'Alabama' }, { value: 'AK', label: 'Alaska' },
{ value: 'AZ', label: 'Arizona' }, { value: 'AR', label: 'Arkansas' },
{ value: 'CA', label: 'California' }, { value: 'CO', label: 'Colorado' },
{ value: 'CT', label: 'Connecticut' }, { value: 'DE', label: 'Delaware' },
{ value: 'FL', label: 'Florida' }, { value: 'GA', label: 'Georgia' },
{ value: 'HI', label: 'Hawaii' }, { value: 'ID', label: 'Idaho' },
{ value: 'IL', label: 'Illinois' }, { value: 'IN', label: 'Indiana' },
{ value: 'IA', label: 'Iowa' }, { value: 'KS', label: 'Kansas' },
{ value: 'KY', label: 'Kentucky' }, { value: 'LA', label: 'Louisiana' },
{ value: 'ME', label: 'Maine' }, { value: 'MD', label: 'Maryland' },
{ value: 'MA', label: 'Massachusetts' }, { value: 'MI', label: 'Michigan' },
{ value: 'MN', label: 'Minnesota' }, { value: 'MS', label: 'Mississippi' },
{ value: 'MO', label: 'Missouri' }, { value: 'MT', label: 'Montana' },
{ value: 'NE', label: 'Nebraska' }, { value: 'NV', label: 'Nevada' },
{ value: 'NH', label: 'New Hampshire' }, { value: 'NJ', label: 'New Jersey' },
{ value: 'NM', label: 'New Mexico' }, { value: 'NY', label: 'New York' },
{ value: 'NC', label: 'North Carolina' }, { value: 'ND', label: 'North Dakota' },
{ value: 'OH', label: 'Ohio' }, { value: 'OK', label: 'Oklahoma' },
{ value: 'OR', label: 'Oregon' }, { value: 'PA', label: 'Pennsylvania' },
{ value: 'RI', label: 'Rhode Island' }, { value: 'SC', label: 'South Carolina' },
{ value: 'SD', label: 'South Dakota' }, { value: 'TN', label: 'Tennessee' },
{ value: 'TX', label: 'Texas' }, { value: 'UT', label: 'Utah' },
{ value: 'VT', label: 'Vermont' }, { value: 'VA', label: 'Virginia' },
{ value: 'WA', label: 'Washington' }, { value: 'WV', label: 'West Virginia' },
{ value: 'WI', label: 'Wisconsin' }, { value: 'WY', label: 'Wyoming' },
{ value: 'DC', label: 'D.C.' },
];
const HIGH_DEBT_THRESHOLD = 30000;
type CollegeSort = {
key: keyof CommunityCollege;
dir: 'asc' | 'desc';
};
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function fmt$(n: number | null | undefined): string {
if (n == null) return '—';
return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 });
}
function fmtPct(n: number | null | undefined): string {
if (n == null) return '—';
return (n * 100).toFixed(1) + '%';
}
function fmtNum(n: number | null | undefined): string {
if (n == null) return '—';
return n.toLocaleString('en-US');
}
/** Returns party colors: background tint, text color, border color */
function partyColors(party: string): { bg: string; text: string; border: string; leftBorder: string } {
switch (party?.toUpperCase()) {
case 'D':
return {
bg: 'rgba(37,99,235,0.06)',
text: '#60a5fa',
border: 'rgba(37,99,235,0.25)',
leftBorder: '#2563eb',
};
case 'R':
return {
bg: 'rgba(239,68,68,0.06)',
text: '#f87171',
border: 'rgba(239,68,68,0.25)',
leftBorder: '#ef4444',
};
default:
return {
bg: 'rgba(168,85,247,0.06)',
text: '#c084fc',
border: 'rgba(168,85,247,0.25)',
leftBorder: '#a855f7',
};
}
}
function partyLabel(party: string): string {
switch (party?.toUpperCase()) {
case 'D': return 'Democrat';
case 'R': return 'Republican';
case 'I': return 'Independent';
default: return party ?? '—';
}
}
/**
* Opportunity score gauge: 0-100 where higher = higher priority (red).
* Returns gradient stop color from green (low) to red (high).
*/
function scoreColor(score: number | null): string {
if (score == null) return 'var(--color-text-muted)';
if (score >= 75) return '#ef4444';
if (score >= 50) return '#f59e0b';
if (score >= 25) return '#eab308';
return '#22c55e';
}
function scoreBg(score: number | null): string {
if (score == null) return 'rgba(113,113,122,0.15)';
if (score >= 75) return 'rgba(239,68,68,0.12)';
if (score >= 50) return 'rgba(245,158,11,0.12)';
if (score >= 25) return 'rgba(234,179,8,0.12)';
return 'rgba(34,197,94,0.12)';
}
/* ─── OpportunityGauge ───────────────────────────────────────────────────── */
function OpportunityGauge({ score }: { score: number | null }) {
const pct = score ?? 0;
const color = scoreColor(score);
const bg = scoreBg(score);
const label = score == null ? '—' : score >= 75 ? 'High' : score >= 50 ? 'Med' : score >= 25 ? 'Low' : 'Min';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Priority
</span>
<span
className="badge"
style={{
backgroundColor: bg,
color,
border: `1px solid ${color}40`,
fontSize: '0.6rem',
padding: '1px 6px',
}}
>
{label} {score != null ? score : ''}
</span>
</div>
{/* Gauge bar */}
<div
style={{
height: 4,
borderRadius: 2,
backgroundColor: 'var(--color-surface-el)',
overflow: 'hidden',
border: '1px solid var(--color-border)',
}}
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Opportunity score: ${pct}`}
>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
style={{ height: '100%', backgroundColor: color, borderRadius: 2 }}
/>
</div>
</div>
);
}
/* ─── StafferRow ─────────────────────────────────────────────────────────── */
function StafferRow({ staffer }: { staffer: Staffer }) {
return (
<tr
style={{
borderBottom: '1px solid var(--color-border)',
backgroundColor: staffer.is_key_contact ? 'rgba(37,99,235,0.04)' : 'transparent',
}}
>
<td style={{ padding: '8px 10px', verticalAlign: 'top' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
<div>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)', lineHeight: 1.3 }}>
{staffer.full_name}
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
{staffer.title}
</div>
</div>
{staffer.is_key_contact && (
<Star size={10} style={{ color: '#eab308', flexShrink: 0, marginTop: 3 }} aria-label="Key contact" />
)}
</div>
</td>
<td style={{ padding: '8px 10px', verticalAlign: 'top' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{staffer.handles_education && (
<span
className="badge"
style={{ backgroundColor: 'rgba(34,197,94,0.1)', color: '#4ade80', border: '1px solid rgba(34,197,94,0.25)', fontSize: '0.6rem' }}
>
Education
</span>
)}
{staffer.handles_finance && (
<span
className="badge"
style={{ backgroundColor: 'rgba(234,179,8,0.1)', color: '#fbbf24', border: '1px solid rgba(234,179,8,0.25)', fontSize: '0.6rem' }}
>
Finance
</span>
)}
{staffer.office_location && (
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)' }}>
{staffer.is_dc_office ? 'DC' : staffer.office_location}
</span>
)}
</div>
</td>
<td style={{ padding: '8px 10px', verticalAlign: 'top' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{staffer.email && (
<a
href={`mailto:${staffer.email}`}
style={{ fontSize: '0.7rem', color: 'var(--color-primary)', textDecoration: 'none' }}
title={staffer.email}
>
{staffer.email.length > 28 ? staffer.email.slice(0, 25) + '…' : staffer.email}
</a>
)}
{staffer.phone && (
<a
href={`tel:${staffer.phone}`}
style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', textDecoration: 'none' }}
>
{staffer.phone}
</a>
)}
</div>
</td>
<td style={{ padding: '8px 10px', verticalAlign: 'middle' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{staffer.linkedin_url && (
<a
href={staffer.linkedin_url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${staffer.full_name} on LinkedIn`}
style={{ color: '#60a5fa', display: 'flex' }}
>
<Linkedin size={13} />
</a>
)}
{staffer.twitter_handle && (
<a
href={`https://twitter.com/${staffer.twitter_handle.replace(/^@/, '')}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`${staffer.full_name} on Twitter`}
style={{ color: '#60a5fa', display: 'flex' }}
>
<Twitter size={13} />
</a>
)}
</div>
</td>
</tr>
);
}
/* ─── CollegeRow ─────────────────────────────────────────────────────────── */
function CollegeRow({ college }: { college: CommunityCollege }) {
const isHighDebt = (college.median_debt ?? 0) >= HIGH_DEBT_THRESHOLD;
return (
<tr
style={{
borderBottom: '1px solid var(--color-border)',
backgroundColor: isHighDebt ? 'rgba(245,158,11,0.05)' : 'transparent',
}}
>
<td style={{ padding: '8px 10px', verticalAlign: 'middle' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
{isHighDebt && (
<AlertTriangle size={11} style={{ color: '#f59e0b', flexShrink: 0 }} aria-label="High debt school" />
)}
<div>
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: isHighDebt ? '#fbbf24' : 'var(--color-text)', lineHeight: 1.3 }}>
{college.school_name}
</div>
<div style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)' }}>
{college.city}, {college.state}
</div>
</div>
</div>
</td>
<td style={{ padding: '8px 10px', textAlign: 'right', fontSize: '0.8rem', color: 'var(--color-text-secondary)' }}>
{fmtNum(college.enrollment)}
</td>
<td style={{ padding: '8px 10px', textAlign: 'right', fontSize: '0.8rem', color: isHighDebt ? '#f59e0b' : 'var(--color-text-secondary)', fontWeight: isHighDebt ? 600 : 400 }}>
{fmt$(college.median_debt)}
</td>
<td style={{ padding: '8px 10px', textAlign: 'right', fontSize: '0.8rem', color: 'var(--color-text-secondary)' }}>
{fmtPct(college.pell_grant_rate)}
</td>
<td style={{ padding: '8px 10px', textAlign: 'right', fontSize: '0.8rem', color: 'var(--color-text-secondary)' }}>
{fmtPct(college.completion_rate)}
</td>
<td style={{ padding: '8px 10px', textAlign: 'right', fontSize: '0.8rem', color: 'var(--color-text-secondary)' }}>
{fmt$(college.tuition_in_state)}
</td>
<td style={{ padding: '8px 10px', textAlign: 'right', fontSize: '0.8rem', color: 'var(--color-text-secondary)' }}>
{fmt$(college.median_earnings_6yr)}
</td>
</tr>
);
}
/* ─── CollegeTable ───────────────────────────────────────────────────────── */
function CollegeTable({ colleges }: { colleges: CommunityCollege[] }) {
const [sort, setSort] = useState<CollegeSort>({ key: 'median_debt', dir: 'desc' });
const sorted = useMemo(() => {
const arr = [...colleges];
arr.sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sort.dir === 'asc' ? cmp : -cmp;
});
return arr;
}, [colleges, sort]);
function toggleSort(key: keyof CommunityCollege) {
setSort((prev) =>
prev.key === key
? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
: { key, dir: 'desc' }
);
}
function SortTh({
col,
children,
align = 'right',
}: {
col: keyof CommunityCollege;
children: React.ReactNode;
align?: 'left' | 'right';
}) {
const active = sort.key === col;
return (
<th
onClick={() => toggleSort(col)}
style={{
padding: '8px 10px',
fontSize: '0.68rem',
fontWeight: 600,
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
cursor: 'pointer',
userSelect: 'none',
textAlign: align,
whiteSpace: 'nowrap',
borderBottom: '1px solid var(--color-border)',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
{children}
<ArrowUpDown size={10} style={{ opacity: active ? 1 : 0.4 }} />
</span>
</th>
);
}
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<SortTh col="school_name" align="left">School</SortTh>
<SortTh col="enrollment">Enrollment</SortTh>
<SortTh col="median_debt">Med. Debt</SortTh>
<SortTh col="pell_grant_rate">Pell %</SortTh>
<SortTh col="completion_rate">Completion</SortTh>
<SortTh col="tuition_in_state">Tuition</SortTh>
<SortTh col="median_earnings_6yr">Earnings 6yr</SortTh>
</tr>
</thead>
<tbody>
{sorted.map((c, i) => (
<CollegeRow key={`${c.school_name}-${c.zip}-${i}`} college={c} />
))}
</tbody>
</table>
</div>
);
}
/* ─── MemberCard ─────────────────────────────────────────────────────────── */
function MemberCard({ member, index }: { member: CongressMember; index: number }) {
const [staffersOpen, setStaffersOpen] = useState(false);
const [collegesOpen, setCollegesOpen] = useState(false);
const colors = partyColors(member.party);
const social = member.social_media ?? {};
const districtLabel = member.title?.toLowerCase().includes('sen')
? `Senator — ${member.state}`
: `${member.state}${member.congressional_district ? `-${member.congressional_district}` : member.district ? ` Dist. ${member.district}` : ''}`;
return (
<motion.article
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, delay: Math.min(index * 0.025, 0.4) }}
className="card"
style={{
borderColor: colors.border,
borderLeftWidth: 3,
borderLeftColor: colors.leftBorder,
backgroundColor: colors.bg,
padding: 0,
overflow: 'hidden',
}}
aria-label={`${member.name}, ${partyLabel(member.party)}, ${districtLabel}`}
>
{/* ── Card Header ───────────────────────────────────────────────── */}
<div style={{ padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{/* Photo */}
<div style={{ flexShrink: 0 }}>
{member.photo_url ? (
<img
src={member.photo_url}
alt={member.name}
width={52}
height={52}
style={{
borderRadius: 'var(--radius-md)',
objectFit: 'cover',
border: `2px solid ${colors.leftBorder}40`,
}}
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div
style={{
width: 52,
height: 52,
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-surface-el)',
border: `2px solid ${colors.leftBorder}30`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-hidden="true"
>
<Users size={22} style={{ color: 'var(--color-text-muted)' }} />
</div>
)}
</div>
{/* Name / meta */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<div>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.3, margin: 0 }}>
{member.name}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
<span
className="badge"
style={{
backgroundColor: colors.bg,
color: colors.text,
border: `1px solid ${colors.border}`,
fontSize: '0.65rem',
}}
>
{partyLabel(member.party)}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
<MapPin size={11} aria-hidden="true" />
{districtLabel}
</span>
</div>
</div>
{/* Opportunity score */}
<div style={{ minWidth: 110 }}>
<OpportunityGauge score={member.opportunity_score} />
</div>
</div>
{/* District stats chips */}
{(member.community_college_count || member.total_cc_enrollment || member.avg_cc_median_debt) && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
{member.community_college_count != null && member.community_college_count > 0 && (
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
<School size={10} aria-hidden="true" />
{member.community_college_count} CCs
</span>
)}
{member.total_cc_enrollment != null && member.total_cc_enrollment > 0 && (
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
<Users size={10} aria-hidden="true" />
{fmtNum(member.total_cc_enrollment)} enrolled
</span>
)}
{member.avg_cc_median_debt != null && (
<span style={{ fontSize: '0.65rem', color: (member.avg_cc_median_debt >= 15000 ? '#f87171' : 'var(--color-text-muted)'), display: 'flex', alignItems: 'center', gap: 3 }}>
Avg Debt: {fmt$(member.avg_cc_median_debt)}
</span>
)}
{member.avg_cc_pell_rate != null && (
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)' }}>
Pell: {fmtPct(member.avg_cc_pell_rate)}
</span>
)}
</div>
)}
{/* Contact row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8, flexWrap: 'wrap' }}>
{member.phone && (
<a
href={`tel:${member.phone}`}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', color: 'var(--color-text-secondary)', textDecoration: 'none' }}
aria-label={`Call ${member.name}`}
>
<Phone size={12} aria-hidden="true" />
{member.phone}
</a>
)}
{member.website && (
<a
href={member.website}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', color: 'var(--color-primary)', textDecoration: 'none' }}
aria-label={`${member.name} official website`}
>
<Globe size={12} aria-hidden="true" />
Website
<ExternalLink size={10} aria-hidden="true" />
</a>
)}
{social.twitter && (
<a
href={`https://twitter.com/${social.twitter.replace(/^@/, '')}`}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: '0.75rem', color: '#60a5fa', textDecoration: 'none' }}
aria-label={`${member.name} on Twitter`}
>
<Twitter size={12} aria-hidden="true" />
{social.twitter}
</a>
)}
{social.facebook && (
<a
href={social.facebook}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#818cf8', display: 'flex', alignItems: 'center' }}
aria-label={`${member.name} on Facebook`}
>
<Facebook size={14} aria-hidden="true" />
</a>
)}
{social.youtube && (
<a
href={social.youtube}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#f87171', display: 'flex', alignItems: 'center' }}
aria-label={`${member.name} on YouTube`}
>
<Youtube size={14} aria-hidden="true" />
</a>
)}
</div>
{/* Stats row */}
<div style={{ display: 'flex', gap: 16, marginTop: 10, flexWrap: 'wrap' }}>
{member.zip_count != null && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<MapPin size={11} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<span style={{ fontSize: '0.72rem', color: 'var(--color-text-muted)' }}>
<strong style={{ color: 'var(--color-text-secondary)' }}>{fmtNum(member.zip_count)}</strong> ZIPs
</span>
</div>
)}
{member.avg_distress_score != null && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<TrendingUp size={11} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<span style={{ fontSize: '0.72rem', color: 'var(--color-text-muted)' }}>
Distress <strong style={{ color: scoreColor(member.avg_distress_score) }}>
{member.avg_distress_score.toFixed(1)}
</strong>
</span>
</div>
)}
{member.total_complaints != null && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<AlertTriangle size={11} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<span style={{ fontSize: '0.72rem', color: 'var(--color-text-muted)' }}>
<strong style={{ color: 'var(--color-text-secondary)' }}>{fmtNum(member.total_complaints)}</strong> complaints
</span>
</div>
)}
{member.community_college_count != null && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<School size={11} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<span style={{ fontSize: '0.72rem', color: 'var(--color-text-muted)' }}>
<strong style={{ color: 'var(--color-text-secondary)' }}>{member.community_college_count}</strong> colleges
</span>
</div>
)}
</div>
{/* Position on key issues */}
{member.position_student_debt && (
<div
style={{
marginTop: 10,
padding: '5px 10px',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'rgba(37,99,235,0.07)',
border: '1px solid rgba(37,99,235,0.15)',
fontSize: '0.72rem',
color: 'var(--color-text-secondary)',
}}
>
<strong style={{ color: '#93c5fd' }}>Position:</strong> {member.position_student_debt}
</div>
)}
{/* Opportunity factor breakdown */}
{member.opportunity_factors && typeof member.opportunity_factors === 'object' && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
{(['debt_distress', 'college_density', 'zip_coverage', 'complaint_density', 'hotspot_concentration', 'advocacy_readiness'] as const).map((key) => {
const factor = member.opportunity_factors?.[key] as { score?: number; raw?: number } | undefined;
if (!factor?.score) return null;
const labels: Record<string, string> = {
debt_distress: 'Distress', college_density: 'Colleges', zip_coverage: 'ZIPs',
complaint_density: 'Complaints', hotspot_concentration: 'Hotspots', advocacy_readiness: 'Advocacy',
};
const score = Math.round(factor.score);
const clr = score >= 70 ? '#f87171' : score >= 40 ? '#fbbf24' : '#4ade80';
return (
<span
key={key}
title={`${labels[key]}: ${score}/100 (raw: ${factor.raw})`}
style={{
fontSize: '0.6rem', padding: '2px 6px', borderRadius: 'var(--radius-sm)',
backgroundColor: `${clr}12`, border: `1px solid ${clr}33`, color: clr,
}}
>
{labels[key]} {score}
</span>
);
})}
{(member.opportunity_factors as Record<string, unknown>).rank != null && (
<span style={{ fontSize: '0.6rem', color: 'var(--color-text-muted)', alignSelf: 'center' }}>
Rank #{String((member.opportunity_factors as Record<string, unknown>).rank)}
</span>
)}
</div>
)}
{/* Committees */}
{member.committees && member.committees.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
{member.committees.map((c) => (
<span
key={c}
style={{
fontSize: '0.6rem', padding: '2px 6px', borderRadius: 'var(--radius-sm)',
backgroundColor: 'rgba(37,99,235,0.08)', border: '1px solid rgba(37,99,235,0.2)',
color: '#93c5fd',
}}
>
{c}
</span>
))}
</div>
)}
</div>
</div>
{/* ── Divider ───────────────────────────────────────────────────── */}
<hr className="divider" />
{/* ── Collapsible: Staffers ─────────────────────────────────────── */}
<section>
<button
type="button"
onClick={() => setStaffersOpen((v) => !v)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '9px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
fontSize: '0.78rem',
fontWeight: 600,
}}
aria-expanded={staffersOpen}
aria-controls={`staffers-${member.bioguide_id}`}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Users size={13} aria-hidden="true" />
Staffers
<span
className="badge"
style={{
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text-muted)',
border: '1px solid var(--color-border)',
fontSize: '0.6rem',
}}
>
{member.staffers?.length ?? 0}
</span>
</span>
{staffersOpen
? <ChevronUp size={14} aria-hidden="true" />
: <ChevronDown size={14} aria-hidden="true" />}
</button>
<AnimatePresence initial={false}>
{staffersOpen && (
<motion.div
id={`staffers-${member.bioguide_id}`}
key="staffers"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div
style={{
borderTop: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}
>
{member.staffers && member.staffers.length > 0 ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['Name & Title', 'Role / Flags', 'Contact', 'Links'].map((h) => (
<th
key={h}
style={{
padding: '7px 10px',
fontSize: '0.65rem',
fontWeight: 600,
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
textAlign: 'left',
whiteSpace: 'nowrap',
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{member.staffers.map((s, i) => (
<StafferRow key={`${s.full_name}-${i}`} staffer={s} />
))}
</tbody>
</table>
</div>
) : (
<p style={{ padding: '12px 16px', fontSize: '0.78rem', color: 'var(--color-text-muted)', margin: 0 }}>
No staffer data available.
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</section>
{/* ── Collapsible: Community Colleges ──────────────────────────── */}
<section>
<button
type="button"
onClick={() => setCollegesOpen((v) => !v)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '9px 16px',
background: 'none',
border: 'none',
borderTop: '1px solid var(--color-border)',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
fontSize: '0.78rem',
fontWeight: 600,
}}
aria-expanded={collegesOpen}
aria-controls={`colleges-${member.bioguide_id}`}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<School size={13} aria-hidden="true" />
Community Colleges
<span
className="badge"
style={{
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text-muted)',
border: '1px solid var(--color-border)',
fontSize: '0.6rem',
}}
>
{member.community_colleges?.length ?? 0}
</span>
{member.highest_debt_college && (
<span style={{ fontSize: '0.65rem', color: '#f59e0b', fontWeight: 400 }}>
— highest: {member.highest_debt_college}
</span>
)}
</span>
{collegesOpen
? <ChevronUp size={14} aria-hidden="true" />
: <ChevronDown size={14} aria-hidden="true" />}
</button>
<AnimatePresence initial={false}>
{collegesOpen && (
<motion.div
id={`colleges-${member.bioguide_id}`}
key="colleges"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div
style={{
borderTop: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}
>
{member.community_colleges && member.community_colleges.length > 0 ? (
<>
<div style={{ padding: '6px 16px 0', display: 'flex', alignItems: 'center', gap: 6 }}>
<AlertTriangle size={10} style={{ color: '#f59e0b' }} aria-hidden="true" />
<span style={{ fontSize: '0.65rem', color: '#f59e0b' }}>
Amber = median debt above {fmt$(HIGH_DEBT_THRESHOLD)}
</span>
</div>
<div style={{ padding: '4px 0 8px' }}>
<CollegeTable colleges={member.community_colleges} />
</div>
</>
) : (
<p style={{ padding: '12px 16px', fontSize: '0.78rem', color: 'var(--color-text-muted)', margin: 0 }}>
No community college data available.
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</section>
</motion.article>
);
}
/* ─── CongressTab ────────────────────────────────────────────────────────── */
export default function CongressTab({ onNavigate }: { onNavigate?: (tab: string) => void }) {
const [members, setMembers] = useState<CongressMember[]>([]);
const [total, setTotal] = useState(0);
const [stats, setStats] = useState<ApiStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [stateFilter, setStateFilter] = useState('');
const [partyFilter, setPartyFilter] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [sortBy, setSortBy] = useState<'state' | 'opportunity' | 'name' | 'debt'>('state');
const [oppLevel, setOppLevel] = useState('');
// Debounce search input
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchQuery), 320);
return () => clearTimeout(t);
}, [searchQuery]);
const fetchMembers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (stateFilter) params.set('state', stateFilter);
if (partyFilter) params.set('party', partyFilter);
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim());
if (sortBy !== 'state') params.set('sort', sortBy);
if (oppLevel) params.set('opp_level', oppLevel);
const res = await fetch(`/api/congress?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: ApiResponse = await res.json();
setMembers(data.members ?? []);
setTotal(data.total ?? 0);
setStats(data.stats ?? null);
} catch (err) {
setError((err as Error).message || 'Failed to load congress members.');
} finally {
setLoading(false);
}
}, [stateFilter, partyFilter, debouncedSearch, sortBy, oppLevel]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
const partyOptions = [
{ value: '', label: 'All Parties' },
{ value: 'D', label: 'Democrat' },
{ value: 'R', label: 'Republican' },
{ value: 'I', label: 'Independent' },
];
const selectStyle: React.CSSProperties = {
background: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
color: 'var(--color-text)',
padding: '0.5rem 0.75rem',
fontSize: '0.875rem',
outline: 'none',
cursor: 'pointer',
minWidth: 0,
};
return (
<div style={{ padding: '1rem 1.25rem', maxWidth: 1200, margin: '0 auto' }}>
{/* ── Header ──────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
<div
style={{
width: 34,
height: 34,
borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(37,99,235,0.25), rgba(168,85,247,0.15))',
border: '1px solid rgba(37,99,235,0.35)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
aria-hidden="true"
>
<Users size={18} style={{ color: '#60a5fa' }} />
</div>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: '1.125rem', fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
Congress Members
</h1>
<p style={{ fontSize: '0.78rem', color: 'var(--color-text-muted)', margin: 0 }}>
{loading
? 'Loading...'
: `${total.toLocaleString()} member${total !== 1 ? 's' : ''}`}
</p>
</div>
<button
type="button"
onClick={fetchMembers}
disabled={loading}
className="btn btn-ghost btn-sm"
aria-label="Refresh congress members"
>
<RefreshCw
size={14}
aria-hidden="true"
style={{ animation: loading ? 'spin 0.7s linear infinite' : 'none' }}
/>
</button>
</div>
{/* ── Cross-reference navigation ────────────────────────────────── */}
{onNavigate && (
<div className="flex items-center gap-1.5 flex-wrap mb-4">
<span className="text-xs" style={{ color: 'var(--color-text-muted)', marginRight: 2 }}>Related:</span>
<button
onClick={() => onNavigate('opportunity-map')}
className="btn btn-ghost btn-sm"
style={{ fontSize: 12, gap: 4 }}
>
<Map size={14} />
Opportunity Map
</button>
<button
onClick={() => onNavigate('geo-report')}
className="btn btn-ghost btn-sm"
style={{ fontSize: 12, gap: 4 }}
>
<BarChart3 size={14} />
Geo Intelligence
</button>
</div>
)}
{/* ── Filter Bar ──────────────────────────────────────────────────── */}
<div
className="card-elevated"
style={{
marginBottom: 20,
display: 'flex',
gap: 10,
alignItems: 'center',
flexWrap: 'wrap',
padding: '10px 14px',
}}
>
{/* State dropdown */}
<select
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value)}
style={selectStyle}
aria-label="Filter by state"
>
{US_STATES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
{/* Party filter */}
<div style={{ display: 'flex', gap: 4 }}>
{partyOptions.map((p) => {
const active = partyFilter === p.value;
const pc = p.value ? partyColors(p.value) : null;
return (
<button
key={p.value}
type="button"
onClick={() => setPartyFilter(p.value)}
className="btn btn-sm"
style={{
backgroundColor: active
? pc ? pc.bg : 'var(--color-surface-el)'
: 'transparent',
color: active
? pc ? pc.text : 'var(--color-text)'
: 'var(--color-text-muted)',
border: active
? `1px solid ${pc ? pc.border : 'var(--color-border)'}`
: '1px solid transparent',
fontWeight: active ? 600 : 400,
}}
aria-pressed={active}
>
{p.label}
</button>
);
})}
</div>
{/* Search */}
<div style={{ position: 'relative', flex: 1, minWidth: 160 }}>
<Search
size={13}
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--color-text-muted)',
pointerEvents: 'none',
}}
aria-hidden="true"
/>
<input
type="search"
className="input"
style={{ paddingLeft: '2rem', width: '100%' }}
placeholder="Search by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search congress members by name"
/>
</div>
{/* Sort dropdown */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
style={selectStyle}
aria-label="Sort by"
>
<option value="state">Sort: State</option>
<option value="opportunity">Sort: Opportunity</option>
<option value="debt">Sort: Avg Debt</option>
<option value="name">Sort: Name</option>
</select>
{/* Opportunity level filter */}
<div style={{ display: 'flex', gap: 3 }}>
{[
{ v: '', label: 'All' },
{ v: 'high', label: 'High', color: '#ef4444' },
{ v: 'med', label: 'Med', color: '#f59e0b' },
{ v: 'low', label: 'Low', color: '#22c55e' },
].map((o) => {
const active = oppLevel === o.v;
return (
<button
key={o.v}
type="button"
onClick={() => setOppLevel(o.v)}
className="btn btn-sm"
style={{
backgroundColor: active ? (o.color ? `${o.color}18` : 'var(--color-surface-el)') : 'transparent',
color: active ? (o.color || 'var(--color-text)') : 'var(--color-text-muted)',
border: active ? `1px solid ${o.color || 'var(--color-border)'}40` : '1px solid transparent',
fontSize: '0.7rem', padding: '3px 8px',
}}
aria-pressed={active}
>
{o.label}
</button>
);
})}
</div>
{/* Total badge */}
{!loading && (
<span
className="badge"
style={{
backgroundColor: 'rgba(37,99,235,0.1)',
color: '#93c5fd',
border: '1px solid rgba(37,99,235,0.2)',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{total.toLocaleString()} results
</span>
)}
</div>
{/* ── Stats Dashboard ──────────────────────────────────────────────── */}
{!loading && stats && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 8,
marginBottom: 16,
}}
>
{[
{ label: 'Members', value: stats.total_members.toLocaleString(), icon: '👥' },
{ label: 'Avg Opp Score', value: stats.avg_opportunity_score != null ? stats.avg_opportunity_score.toFixed(1) : '—', icon: '📊', color: scoreColor(stats.avg_opportunity_score) },
{ label: 'CC Enrollment', value: stats.total_cc_enrollment > 0 ? (stats.total_cc_enrollment / 1000).toFixed(0) + 'K' : '—', icon: '🎓' },
{ label: 'High Priority', value: String(stats.high_priority_count), icon: '🔴', color: '#ef4444' },
].map((s) => (
<div
key={s.label}
className="card"
style={{ padding: '10px 12px', textAlign: 'center' }}
>
<div style={{ fontSize: '1.125rem', fontWeight: 700, color: s.color || 'var(--color-text)' }}>
{s.value}
</div>
<div style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
{s.label}
</div>
</div>
))}
</div>
)}
{/* ── Loading Skeletons ────────────────────────────────────────────── */}
{loading && (
<div
style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))' }}
aria-live="polite"
aria-busy="true"
>
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="skeleton"
style={{ height: 160, borderRadius: 'var(--radius-lg)' }}
aria-hidden="true"
/>
))}
</div>
)}
{/* ── Error ───────────────────────────────────────────────────────── */}
{!loading && error && (
<div
role="alert"
style={{
padding: '12px 16px',
borderRadius: 'var(--radius-lg)',
backgroundColor: 'rgba(239,68,68,0.05)',
border: '1px solid rgba(239,68,68,0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<p style={{ fontSize: '0.875rem', color: 'var(--color-error)', margin: 0 }}>{error}</p>
<button type="button" onClick={fetchMembers} className="btn btn-secondary btn-sm">
<RefreshCw size={12} aria-hidden="true" /> Retry
</button>
</div>
)}
{/* ── Empty ───────────────────────────────────────────────────────── */}
{!loading && !error && members.length === 0 && (
<div
style={{
borderRadius: 'var(--radius-lg)',
border: '1px dashed var(--color-border)',
backgroundColor: 'var(--color-surface)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4rem 2rem',
gap: 12,
textAlign: 'center',
}}
role="status"
>
<Inbox size={38} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<p style={{ fontSize: '1.0625rem', fontWeight: 600, color: 'var(--color-text)', margin: 0 }}>
{searchQuery || stateFilter || partyFilter
? 'No members match your filters'
: 'No congress members found'}
</p>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', margin: 0, maxWidth: 300 }}>
{searchQuery || stateFilter || partyFilter
? 'Try adjusting your search or filter criteria.'
: 'Member data will appear here once the API is connected.'}
</p>
</div>
)}
{/* ── Card Grid ───────────────────────────────────────────────────── */}
{!loading && !error && members.length > 0 && (
<div
style={{
display: 'grid',
gap: 14,
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
}}
role="list"
aria-label="Congress members"
>
<AnimatePresence initial={false}>
{members.map((m, i) => (
<div key={m.bioguide_id} role="listitem">
<MemberCard member={m} index={i} />
</div>
))}
</AnimatePresence>
</div>
)}
</div>
);
}