← back to Norma
components/agents/PetitionBuilder.tsx
588 lines
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
ScrollText, Sparkles, Search, Eye, Users, Copy, CheckCircle2,
ChevronDown, AlertCircle, Building2, Target, FileText, Hash,
ExternalLink, Radio, Rocket,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
interface Organization {
id: string;
name: string;
city: string | null;
state: string | null;
}
interface Politician {
id: string;
name: string;
title: string | null;
party: string | null;
state: string | null;
district: string | null;
office_level: string | null;
}
const TARGET_TYPES = [
{ value: 'congress', label: 'Congress Member' },
{ value: 'state_legislature', label: 'State Legislature' },
{ value: 'federal_agency', label: 'Federal Agency' },
{ value: 'institution', label: 'Institution' },
];
const ISSUE_CATEGORIES = [
'Education Access', 'Education Funding', 'Workforce Development',
'Healthcare Access', 'Housing Affordability', 'Economic Justice',
'Racial Equity', 'Climate Action', 'Immigration Reform',
'Voting Rights', 'Criminal Justice Reform', 'Food Security',
];
type PetitionPlatform = 'pulse_of_america' | 'moveon';
const PLATFORM_OPTIONS: { value: PetitionPlatform; label: string; description: string; color: string }[] = [
{
value: 'pulse_of_america',
label: 'Pulse of America',
description: 'Publish locally -- petition is saved to our database and visible on the Pulse petitions page',
color: '#1B4332',
},
{
value: 'moveon',
label: 'MoveOn.org',
description: 'Copy petition text and open MoveOn.org to publish externally',
color: '#E40000',
},
];
interface PetitionBuilderProps {
selectedOrgId: string;
onOrgChange: (id: string) => void;
}
export default function PetitionBuilder({ selectedOrgId, onOrgChange }: PetitionBuilderProps) {
const { addToast } = useToast();
// Data
const [orgs, setOrgs] = useState<Organization[]>([]);
const [politicians, setPoliticians] = useState<Politician[]>([]);
const [polSearch, setPolSearch] = useState('');
const debouncedPolSearch = useDebounce(polSearch, 300);
// Form
const [targetType, setTargetType] = useState('congress');
const [targetId, setTargetId] = useState('');
const [title, setTitle] = useState('');
const [issueCategory, setIssueCategory] = useState('');
const [signatureGoal, setSignatureGoal] = useState('');
const [platform, setPlatform] = useState<PetitionPlatform>('pulse_of_america');
// Generated
const [generating, setGenerating] = useState(false);
const [petitionText, setPetitionText] = useState('');
const [talkingPoints, setTalkingPoints] = useState<string[]>([]);
const [suggestedGoal, setSuggestedGoal] = useState(0);
const [showPreview, setShowPreview] = useState(false);
const [publishing, setPublishing] = useState(false);
const [published, setPublished] = useState(false);
const [publishedPetitionId, setPublishedPetitionId] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Load organizations
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/orgs?limit=200&sort=name&sort_dir=asc');
if (res.ok) {
const data = await res.json();
setOrgs(data.rows || []);
}
} catch {}
})();
}, []);
// Load politicians when searching
useEffect(() => {
if (targetType !== 'congress' && targetType !== 'state_legislature') return;
(async () => {
try {
const params = new URLSearchParams({ limit: '50' });
if (debouncedPolSearch) params.set('q', debouncedPolSearch);
const res = await fetch(`/api/congress?${params}`);
if (res.ok) {
const data = await res.json();
setPoliticians(data.rows || data.politicians || []);
}
} catch {}
})();
}, [debouncedPolSearch, targetType]);
async function handleGenerate() {
if (!selectedOrgId) {
addToast('Please select an organization first', 'error');
return;
}
if (!title.trim()) {
addToast('Please enter a petition title', 'error');
return;
}
setGenerating(true);
setPetitionText('');
setTalkingPoints([]);
try {
const res = await fetch('/api/agents/petition/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
org_id: selectedOrgId,
target_type: targetType,
target_id: targetId || null,
title: title.trim(),
issue_category: issueCategory || null,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Generation failed');
}
const data = await res.json();
setPetitionText(data.petition_text || '');
setTalkingPoints(data.talking_points || []);
setSuggestedGoal(data.suggested_signatures_goal || 5000);
if (!signatureGoal) setSignatureGoal(String(data.suggested_signatures_goal || 5000));
addToast('Petition draft generated', 'success');
} catch (err) {
addToast((err as Error).message || 'Failed to generate petition', 'error');
}
setGenerating(false);
}
async function handlePublish() {
if (!petitionText) {
addToast('Generate a petition draft first', 'error');
return;
}
if (!title.trim()) {
addToast('Petition title is required', 'error');
return;
}
if (platform === 'moveon') {
// MoveOn: copy body to clipboard and open MoveOn petition creation page
try {
await navigator.clipboard.writeText(petitionText);
addToast('Petition body copied! Paste into MoveOn form.', 'success');
} catch {
addToast('Failed to copy -- please copy the text manually', 'error');
}
window.open('https://sign.moveon.org/petition/start/', '_blank');
return;
}
// Pulse of America: save locally to petitions table via /api/petitions
setPublishing(true);
try {
const res = await fetch('/api/petitions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
body: petitionText,
description: petitionText,
target: targetId || targetType,
category: issueCategory || 'other',
signature_goal: parseInt(signatureGoal) || suggestedGoal || 5000,
platform: 'custom',
status: 'active',
tags: issueCategory ? [issueCategory.toLowerCase().replace(/\s+/g, '_')] : [],
talking_points: talkingPoints,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to publish');
}
const data = await res.json();
setPublishedPetitionId(data.petition?.id || null);
setPublished(true);
addToast('Petition published to Pulse of America', 'success');
} catch (err) {
addToast((err as Error).message || 'Failed to publish petition', 'error');
}
setPublishing(false);
}
function handleCopy() {
navigator.clipboard.writeText(petitionText);
setCopied(true);
addToast('Copied to clipboard', 'success');
setTimeout(() => setCopied(false), 2000);
}
return (
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 40, height: 40, borderRadius: 12,
backgroundColor: 'rgba(99,102,241,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ScrollText size={20} style={{ color: '#6366f1' }} />
</div>
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
AI Petition Builder
</h1>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: 0 }}>
Generate compelling petitions powered by AI
</p>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
{/* Left: Configuration */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Organization */}
<div className="card" style={{ padding: 16 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<Building2 size={14} /> Organization
</label>
<select
className="input"
value={selectedOrgId}
onChange={e => onOrgChange(e.target.value)}
>
<option value="">-- Select Organization --</option>
{orgs.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
{/* Target */}
<div className="card" style={{ padding: 16 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<Target size={14} /> Target
</label>
<select
className="input"
value={targetType}
onChange={e => { setTargetType(e.target.value); setTargetId(''); }}
style={{ marginBottom: 10 }}
>
{TARGET_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
{(targetType === 'congress' || targetType === 'state_legislature') && (
<>
<div style={{ position: 'relative', marginBottom: 8 }}>
<Search size={14} style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
color: 'var(--color-text-muted)', pointerEvents: 'none',
}} />
<input
className="input"
style={{ paddingLeft: 32 }}
value={polSearch}
onChange={e => setPolSearch(e.target.value)}
placeholder="Search congress members..."
/>
</div>
<select
className="input"
value={targetId}
onChange={e => setTargetId(e.target.value)}
size={Math.min(politicians.length + 1, 6)}
style={{ minHeight: 100 }}
>
<option value="">-- Select Member --</option>
{politicians.map(p => (
<option key={p.id} value={p.id}>
{p.title ? `${p.title} ` : ''}{p.name} ({p.party || '?'}-{p.state || '?'})
{p.district ? ` Dist. ${p.district}` : ''}
</option>
))}
</select>
</>
)}
</div>
{/* Petition Details */}
<div className="card" style={{ padding: 16 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<FileText size={14} /> Petition Details
</label>
<input
className="input"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Petition title..."
style={{ marginBottom: 10 }}
/>
<select
className="input"
value={issueCategory}
onChange={e => setIssueCategory(e.target.value)}
style={{ marginBottom: 10 }}
>
<option value="">-- Issue Category --</option>
{ISSUE_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Hash size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<input
className="input"
type="number"
value={signatureGoal}
onChange={e => setSignatureGoal(e.target.value)}
placeholder="Signature goal (auto-suggested)"
min={100}
/>
</div>
</div>
{/* Platform */}
<div className="card" style={{ padding: 16 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Radio size={14} /> Platform
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{PLATFORM_OPTIONS.map(opt => {
const isSelected = platform === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setPlatform(opt.value)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '10px 12px', borderRadius: 8, cursor: 'pointer',
border: isSelected ? `2px solid ${opt.color}` : '2px solid var(--color-border)',
backgroundColor: isSelected ? `${opt.color}14` : 'transparent',
textAlign: 'left', transition: 'var(--transition)',
}}
>
<span style={{
width: 18, height: 18, borderRadius: '50%', flexShrink: 0, marginTop: 1,
border: isSelected ? `5px solid ${opt.color}` : '2px solid var(--color-text-muted)',
backgroundColor: isSelected ? '#fff' : 'transparent',
transition: 'var(--transition)',
}} />
<div>
<span style={{ fontSize: 13, fontWeight: 600, color: isSelected ? opt.color : 'var(--color-text)', display: 'block' }}>
{opt.label}
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4, display: 'block', marginTop: 2 }}>
{opt.description}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Generate Button */}
<button
className="btn btn-primary btn-lg"
onClick={handleGenerate}
disabled={generating || !selectedOrgId || !title.trim()}
style={{ width: '100%' }}
>
{generating ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Generating Draft...
</>
) : (
<>
<Sparkles size={16} />
Generate Draft
</>
)}
</button>
</div>
{/* Right: Generated Content */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<AnimatePresence mode="wait">
{petitionText ? (
<motion.div
key="content"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}
>
{/* Petition Text */}
<div className="card" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', margin: 0 }}>
Petition Draft
</h3>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={handleCopy}>
{copied ? <CheckCircle2 size={14} style={{ color: '#22c55e' }} /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowPreview(!showPreview)}>
<Eye size={14} />
{showPreview ? 'Edit' : 'Preview'}
</button>
</div>
</div>
{showPreview ? (
<div style={{
padding: 16, borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
fontSize: 14, color: 'var(--color-text)', lineHeight: 1.7,
whiteSpace: 'pre-wrap',
}}>
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 12px', color: 'var(--color-text)' }}>
{title}
</h2>
{petitionText}
<div style={{ marginTop: 20, padding: '12px 0', borderTop: '1px solid var(--color-border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
<Users size={16} />
Goal: {parseInt(signatureGoal) || suggestedGoal || 5000} signatures
</div>
</div>
</div>
) : (
<textarea
className="input"
value={petitionText}
onChange={e => setPetitionText(e.target.value)}
rows={14}
style={{ resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.6 }}
/>
)}
</div>
{/* Talking Points */}
{talkingPoints.length > 0 && (
<div className="card" style={{ padding: 16 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', margin: '0 0 12px' }}>
Talking Points
</h3>
<ul style={{ margin: 0, padding: '0 0 0 20px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{talkingPoints.map((point, i) => (
<li key={i} style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{point}
</li>
))}
</ul>
</div>
)}
{/* Publish */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 10 }}>
<button
className="btn btn-primary btn-lg"
onClick={handlePublish}
disabled={publishing || published}
style={{
flex: 1,
backgroundColor: platform === 'moveon' ? '#E40000' : undefined,
borderColor: platform === 'moveon' ? '#E40000' : undefined,
}}
>
{published ? (
<>
<CheckCircle2 size={16} />
Published to Pulse of America
</>
) : publishing ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Publishing...
</>
) : platform === 'moveon' ? (
<>
<ExternalLink size={16} />
Copy & Open MoveOn.org
</>
) : (
<>
<Rocket size={16} />
Publish to Pulse of America
</>
)}
</button>
<button
className="btn btn-ghost btn-lg"
onClick={handleGenerate}
disabled={generating}
>
<Sparkles size={16} />
Regenerate
</button>
</div>
{/* View on Pulse link after publishing */}
{published && publishedPetitionId && platform === 'pulse_of_america' && (
<a
href={`http://45.61.58.125:7400/pulse/petitions`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
gap: 6, width: '100%',
}}
>
<ExternalLink size={14} />
View on Pulse of America Petitions
</a>
)}
</div>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="card" style={{
padding: 48, textAlign: 'center',
display: 'flex', flexDirection: 'column', alignItems: 'center',
}}>
<div style={{
width: 56, height: 56, borderRadius: 16, marginBottom: 16,
backgroundColor: 'rgba(99,102,241,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ScrollText size={26} style={{ color: '#6366f1', opacity: 0.6 }} />
</div>
<h3 style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-text)', margin: '0 0 8px' }}>
{generating ? 'Generating your petition...' : 'Ready to Generate'}
</h3>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', maxWidth: 320, margin: 0 }}>
{generating
? 'AI is crafting a compelling petition based on your inputs...'
: 'Select an organization, choose a target, and enter your petition title to begin.'}
</p>
{generating && (
<div className="spinner" style={{ marginTop: 20, width: 28, height: 28 }} />
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}