← back to Freddy
components/causes/CausesTab.tsx
388 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
Flame, Search, Plus, Sparkles, Loader2, TrendingUp,
ArrowUpRight, Tag, Users, X,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
interface Cause {
id: string;
title: string;
description: string;
category: string;
urgency_score: number;
petition_velocity: number;
news_mentions: number;
total_votes: number;
total_supporters: number;
trending_rank: number | null;
source: string;
tags: string[];
featured: boolean;
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 CausesTab() {
const { addToast } = useToast();
const [causes, setCauses] = useState<Cause[]>([]);
const [loading, setLoading] = useState(true);
const [discovering, setDiscovering] = useState(false);
const [sortBy, setSortBy] = useState('urgency');
const [category, setCategory] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
const [showCreate, setShowCreate] = useState(false);
const fetchCauses = useCallback(async () => {
try {
const params = new URLSearchParams({ sort: sortBy });
if (category !== 'all') params.set('category', category);
const res = await fetch(`/api/causes?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setCauses(data.causes || []);
}
} catch (err) {
console.error('Failed to fetch causes:', err);
} finally {
setLoading(false);
}
}, [sortBy, category]);
useEffect(() => { fetchCauses(); }, [fetchCauses]);
const handleDiscover = async () => {
setDiscovering(true);
try {
const res = await fetch('/api/causes/discover', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
await fetchCauses();
addToast('Causes discovered', 'success');
} else {
addToast('Discovery failed', 'error');
}
} catch (err) {
console.error('Discover error:', err);
addToast('Discovery failed', 'error');
} finally {
setDiscovering(false);
}
};
const handleCreateCause = async (formData: { title: string; description: string; category: string; tags: string }) => {
try {
const res = await fetch('/api/causes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
}),
});
if (res.ok) {
setShowCreate(false);
await fetchCauses();
addToast('Cause created', 'success');
} else {
addToast('Failed to create cause', 'error');
}
} catch (err) {
console.error('Create cause error:', err);
addToast('Failed to create cause', 'error');
}
};
const filtered = causes.filter(c =>
!debouncedSearch || c.title.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
(c.description && c.description.toLowerCase().includes(debouncedSearch.toLowerCase()))
);
return (
<div className="p-6 space-y-6">
{/* Header */}
<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)' }}>
<Flame size={20} style={{ color: '#ef4444' }} />
Causes
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Browse and vote on causes that matter most
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDiscover}
disabled={discovering}
className="btn btn-primary"
>
{discovering ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
{discovering ? 'Discovering...' : 'AI Discover Causes'}
</button>
<button onClick={() => setShowCreate(true)} className="btn btn-secondary">
<Plus size={14} />
Add Cause
</button>
</div>
</div>
{/* Filters */}
<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 causes..."
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="urgency">Sort by Urgency</option>
<option value="votes">Sort by Votes</option>
<option value="trending">Sort by Trending</option>
</select>
</div>
{/* Category Pills */}
<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>
{/* Causes Grid */}
{loading ? (
<SkeletonList count={4} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{filtered.map((cause, i) => (
<motion.div
key={cause.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)' }}>
{cause.title}
</h3>
{cause.category && (
<span className="badge badge-amber text-xs mt-1">
{cause.category.replace('_', ' ')}
</span>
)}
</div>
<div className="flex items-center gap-1">
{cause.source === 'ai' && (
<span title="AI Discovered"><Sparkles size={12} style={{ color: '#c084fc' }} /></span>
)}
{cause.urgency_score > 0.7 && (
<ArrowUpRight size={14} style={{ color: '#ef4444' }} />
)}
</div>
</div>
{cause.description && (
<p className="text-xs line-clamp-3" style={{ color: 'var(--color-text-muted)' }}>
{cause.description}
</p>
)}
{/* Urgency Gauge */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span style={{ color: 'var(--color-text-muted)' }}>Urgency</span>
<span style={{
color: cause.urgency_score > 0.7 ? '#ef4444' : cause.urgency_score > 0.4 ? '#d97706' : '#22c55e',
fontWeight: 600,
}}>
{(cause.urgency_score * 100).toFixed(0)}%
</span>
</div>
<div className="urgency-gauge">
<div
className="urgency-gauge-fill"
style={{
width: `${cause.urgency_score * 100}%`,
background: cause.urgency_score > 0.7
? 'linear-gradient(90deg, #d97706, #ef4444)'
: cause.urgency_score > 0.4
? 'linear-gradient(90deg, #22c55e, #d97706)'
: '#22c55e',
}}
/>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<span className="flex items-center gap-1">
<Users size={12} /> {cause.total_votes} votes
</span>
<span className="flex items-center gap-1">
<TrendingUp size={12} /> {cause.total_supporters} supporters
</span>
</div>
{/* Tags */}
{cause.tags && cause.tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{cause.tags.slice(0, 4).map((tag: string) => (
<span key={tag} 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)',
}}
>
<Tag size={10} style={{ display: 'inline', marginRight: 2 }} />
{tag}
</span>
))}
</div>
)}
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="text-center py-12">
<Flame size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<p style={{ color: 'var(--color-text-muted)' }}>
No causes found. Click "AI Discover Causes" to find trending issues.
</p>
</div>
)}
{/* Create Cause Modal */}
{showCreate && (
<CreateCauseModal onClose={() => setShowCreate(false)} onSubmit={handleCreateCause} />
)}
</div>
);
}
function CreateCauseModal({
onClose,
onSubmit,
}: {
onClose: () => void;
onSubmit: (data: { title: string; description: string; category: string; tags: string }) => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [cat, setCat] = useState('other');
const [tags, setTags] = useState('');
useEffect(() => {
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md rounded-xl p-6"
style={{
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Add New Cause</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)' }}>Title</label>
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Clean Water Access" />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Description</label>
<textarea className="input" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Why this cause matters..." />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
<select className="input" value={cat} onChange={(e) => setCat(e.target.value)}>
{CATEGORIES.filter(c => c !== 'all').map(c => (
<option key={c} value={c}>{c.replace('_', ' ')}</option>
))}
</select>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tags (comma-separated)</label>
<input className="input" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="water, health, access" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="btn btn-secondary">Cancel</button>
<button
onClick={() => onSubmit({ title, description, category: cat, tags })}
disabled={!title}
className="btn btn-primary"
>
Create Cause
</button>
</div>
</div>
</motion.div>
</div>
);
}