← back to Freddy
components/organizations/OrgsTab.tsx
331 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
Building2, Search, Plus, Star, Shield, Users,
TrendingUp, ExternalLink, X, Loader2, MapPin,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
interface Org {
id: string;
name: string;
ein: string | null;
mission: string | null;
description: string | null;
website_url: string | null;
city: string | null;
state: string | null;
category: string;
focus_areas: string[];
year_founded: number | null;
annual_budget: string | null;
staff_size: number | null;
impact_score: number;
trust_score: number;
total_votes: number;
total_funding_received: string;
status: string;
created_at: string;
}
const CATEGORIES = [
'all', 'education', 'health', 'environment', 'poverty', 'housing',
'legal_aid', 'civil_rights', 'veterans', 'youth', 'elderly',
'disability', 'arts', 'other',
];
const fadeIn = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1, y: 0,
transition: { delay: i * 0.04, duration: 0.35 },
}),
};
export default function OrgsTab() {
const { addToast } = useToast();
const [orgs, setOrgs] = useState<Org[]>([]);
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState('votes');
const [category, setCategory] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
const [showCreate, setShowCreate] = useState(false);
const fetchOrgs = useCallback(async () => {
try {
const params = new URLSearchParams({ sort: sortBy });
if (category !== 'all') params.set('category', category);
const res = await fetch(`/api/organizations?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setOrgs(data.organizations || []);
}
} catch (err) {
console.error('Failed to fetch orgs:', err);
} finally {
setLoading(false);
}
}, [sortBy, category]);
useEffect(() => { fetchOrgs(); }, [fetchOrgs]);
const handleCreate = async (formData: Record<string, string>) => {
try {
const res = await fetch('/api/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
...formData,
focus_areas: formData.focus_areas ? formData.focus_areas.split(',').map(s => s.trim()).filter(Boolean) : [],
year_founded: formData.year_founded ? parseInt(formData.year_founded) : null,
staff_size: formData.staff_size ? parseInt(formData.staff_size) : null,
}),
});
if (res.ok) {
setShowCreate(false);
await fetchOrgs();
addToast('Organization registered', 'success');
} else {
addToast('Failed to register organization', 'error');
}
} catch (err) {
console.error('Create org error:', err);
addToast('Failed to register organization', 'error');
}
};
const filtered = orgs.filter(o =>
!debouncedSearch || o.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
(o.mission && o.mission.toLowerCase().includes(debouncedSearch.toLowerCase()))
);
const statusColor = (s: string) => {
if (s === 'featured') return 'badge-amber';
if (s === 'verified') return 'badge-success';
return 'badge-warning';
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<Building2 size={20} style={{ color: '#22c55e' }} />
Organizations
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Non-profit registry — verified organizations addressing top causes
</p>
</div>
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
<Plus size={14} />
Register Organization
</button>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
<input className="input pl-8" placeholder="Search organizations..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
</div>
<select className="input" style={{ width: 'auto' }} value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="votes">Sort by Votes</option>
<option value="impact">Sort by Impact</option>
<option value="trust">Sort by Trust</option>
<option value="funding">Sort by Funding</option>
</select>
</div>
<div className="flex items-center gap-2 flex-wrap">
{CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setCategory(cat)}
className="btn btn-sm"
style={{
backgroundColor: category === cat ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
color: category === cat ? '#fbbf24' : 'var(--color-text-secondary)',
borderColor: category === cat ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
}}
>
{cat === 'all' ? 'All' : cat.replace('_', ' ')}
</button>
))}
</div>
{loading ? (
<SkeletonList count={4} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{filtered.map((org, i) => (
<motion.div key={org.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>
{org.name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`badge ${statusColor(org.status)} text-xs`}>{org.status}</span>
{org.category && <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{org.category.replace('_', ' ')}</span>}
</div>
</div>
{org.website_url && (
<a href={org.website_url} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
<ExternalLink size={12} />
</a>
)}
</div>
{org.mission && (
<p className="text-xs line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>{org.mission}</p>
)}
{(org.city || org.state) && (
<div className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<MapPin size={10} /> {[org.city, org.state].filter(Boolean).join(', ')}
</div>
)}
<div className="grid grid-cols-3 gap-2">
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<Shield size={12} style={{ color: '#60a5fa', margin: '0 auto 2px' }} />
<div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{((org.trust_score || 0) * 100).toFixed(0)}%</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Trust</div>
</div>
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<TrendingUp size={12} style={{ color: '#22c55e', margin: '0 auto 2px' }} />
<div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{((org.impact_score || 0) * 100).toFixed(0)}%</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Impact</div>
</div>
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<Users size={12} style={{ color: '#fbbf24', margin: '0 auto 2px' }} />
<div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{org.total_votes}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Votes</div>
</div>
</div>
{org.focus_areas && org.focus_areas.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{org.focus_areas.slice(0, 3).map((area: string) => (
<span key={area} className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)' }}>
{area}
</span>
))}
</div>
)}
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="text-center py-12">
<Building2 size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<p style={{ color: 'var(--color-text-muted)' }}>No organizations found. Register one to get started.</p>
</div>
)}
{showCreate && <CreateOrgModal onClose={() => setShowCreate(false)} onSubmit={handleCreate} />}
</div>
);
}
function CreateOrgModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Record<string, string>) => void }) {
useEffect(() => {
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const [form, setForm] = useState({ name: '', ein: '', mission: '', description: '', website_url: '', city: '', state: '', category: 'other', focus_areas: '', year_founded: '', annual_budget: '', staff_size: '' });
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setSubmitting(true);
await onSubmit(form);
setSubmitting(false);
};
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => setForm(f => ({ ...f, [k]: e.target.value }));
return (
<div className="modal-overlay" onClick={onClose}>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Register Organization</h3>
<button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name *</label>
<input className="input" value={form.name} onChange={set('name')} placeholder="Organization name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>EIN</label>
<input className="input" value={form.ein} onChange={set('ein')} placeholder="XX-XXXXXXX" />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
<select className="input" value={form.category} onChange={set('category')}>
{CATEGORIES.filter(c => c !== 'all').map(c => <option key={c} value={c}>{c.replace('_', ' ')}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Mission</label>
<textarea className="input" rows={2} value={form.mission} onChange={set('mission')} placeholder="Organization mission..." />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Website</label>
<input className="input" value={form.website_url} onChange={set('website_url')} placeholder="https://..." />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>City</label>
<input className="input" value={form.city} onChange={set('city')} />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>State</label>
<input className="input" value={form.state} onChange={set('state')} />
</div>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Focus Areas (comma-separated)</label>
<input className="input" value={form.focus_areas} onChange={set('focus_areas')} placeholder="education, youth, mentoring" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Year Founded</label>
<input className="input" type="number" value={form.year_founded} onChange={set('year_founded')} />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Annual Budget</label>
<input className="input" value={form.annual_budget} onChange={set('annual_budget')} placeholder="$500K" />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Staff Size</label>
<input className="input" type="number" value={form.staff_size} onChange={set('staff_size')} />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="btn btn-secondary">Cancel</button>
<button onClick={handleSubmit} disabled={!form.name || submitting} className="btn btn-primary">
{submitting ? <Loader2 size={14} className="animate-spin" /> : null}
Register
</button>
</div>
</div>
</motion.div>
</div>
);
}