← back to Patty
components/petitions/PetitionsTab.tsx
1102 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
Plus, Megaphone, Search, ChevronDown, ChevronUp,
Sparkles, Target, Users, Share2, Calendar, Edit3,
Send, Loader2, X, Tag, Mail as MailIcon, DollarSign, ExternalLink,
} from 'lucide-react';
import { Grant, formatAmount } from '../grants/types';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const PET_SORT_OPTIONS = [
{ value: 'sigs_desc', label: 'Most Signatures' },
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'status', label: 'Status A-Z' },
];
const PET_SORT_CONFIGS: Record<string, SortConfig> = {
sigs_desc: { key: 'signature_count', direction: 'desc', type: 'number' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
status: { key: 'status', direction: 'asc', type: 'string' },
};
/* ─── Types ─────────────────────────────────────────────────── */
interface Petition {
id: string;
title: string;
slug: string;
summary: string | null;
body_html: string;
body_text: string | null;
target: string | null;
target_emails: string[] | null;
status: string;
category: string | null;
tags: string[] | null;
signature_count: number;
signature_goal: number;
share_count: number;
org_type: string | null;
compliance_flags: Record<string, unknown> | null;
external_url?: string | null;
distribution_platform?: string;
created_at: string;
}
/**
* Basic HTML tag stripper for display purposes.
* All petition content is created by the admin user or generated by Gemini AI,
* so it is trusted internal content. This function strips tags for plain text display.
*/
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/* ─── Component ─────────────────────────────────────────────── */
export default function PetitionsTab({ selectedId, onSelectedConsumed, onViewGrant }: { selectedId?: number | null; onSelectedConsumed?: () => void; onViewGrant?: (id: string) => void } = {}) {
const { addToast } = useToast();
const [petitions, setPetitions] = useState<Petition[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
const [expanded, setExpanded] = useState<string | null>(null);
const [sortBy, setSortBy] = useState('date_desc');
const sortedPetitions = useClientSort(petitions, sortBy, PET_SORT_CONFIGS);
// AI modal state
const [showAiModal, setShowAiModal] = useState(false);
const [aiTopic, setAiTopic] = useState('');
const [aiTarget, setAiTarget] = useState('');
const [aiTone, setAiTone] = useState('formal');
const [aiCategory, setAiCategory] = useState('policy');
const [aiGenerating, setAiGenerating] = useState(false);
const [aiResult, setAiResult] = useState<{
title: string; summary: string; body_html: string; body_text: string;
suggested_target: string; suggested_tags: string[]; suggested_goal: number;
} | null>(null);
const [savingDraft, setSavingDraft] = useState(false);
// Related grants state
const [petitionGrants, setPetitionGrants] = useState<Record<string, Grant[]>>({});
const [loadingGrants, setLoadingGrants] = useState<string | null>(null);
// Edit modal state
const [editPetition, setEditPetition] = useState<Petition | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editSummary, setEditSummary] = useState('');
const [editTarget, setEditTarget] = useState('');
const [editGoal, setEditGoal] = useState(100);
const [editStatus, setEditStatus] = useState('draft');
const [editSaving, setEditSaving] = useState(false);
// Deploy modal state
const [showDeployModal, setShowDeployModal] = useState(false);
const [deployPetition, setDeployPetition] = useState<Petition | null>(null);
const [deployPlatforms, setDeployPlatforms] = useState<Record<string, boolean>>({ moveon: true, change_org: true, we_the_people: false, custom: false });
const [deploying, setDeploying] = useState(false);
const [deployResults, setDeployResults] = useState<Array<{ platform: string; platform_name: string; creation_url: string; formatted_content: Record<string, unknown>; instructions: string }>>([]);
const [deployCustomUrl, setDeployCustomUrl] = useState('');
const [copiedField, setCopiedField] = useState<string | null>(null);
/* ── Fetch ──────────────────────────────────────────────────── */
const fetchPetitions = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filter !== 'all') params.set('status', filter);
if (debouncedSearch) params.set('search', debouncedSearch);
const res = await fetch(`/api/petitions?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setPetitions(data.rows || []);
setStatusCounts(data.statusCounts || {});
}
} catch (err) {
console.error('Fetch petitions error:', err);
} finally {
setLoading(false);
}
}, [filter, debouncedSearch]);
useEffect(() => { fetchPetitions(); }, [fetchPetitions]);
// Auto-expand petition from cross-tab navigation (Topics → Petitions)
useEffect(() => {
if (selectedId && petitions.length > 0) {
setExpanded(String(selectedId));
onSelectedConsumed?.();
// Scroll into view after render
setTimeout(() => {
document.getElementById(`petition-${selectedId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}, [selectedId, petitions.length, onSelectedConsumed]);
/* ── Escape key handler for modals ──────────────────────────── */
useEffect(() => {
if (!showAiModal && !editPetition && !showDeployModal) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showAiModal) setShowAiModal(false);
if (editPetition) setEditPetition(null);
if (showDeployModal) { setShowDeployModal(false); setDeployResults([]); }
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [showAiModal, editPetition, showDeployModal]);
/* ── AI Generate ────────────────────────────────────────────── */
async function handleAiGenerate() {
if (!aiTopic.trim()) return;
setAiGenerating(true);
setAiResult(null);
try {
const res = await fetch('/api/petitions/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: aiTopic, target: aiTarget, tone: aiTone, category: aiCategory }),
});
if (res.ok) {
const data = await res.json();
setAiResult(data);
addToast('Petition generated', 'success');
} else {
addToast('AI generation failed', 'error');
}
} catch (err) {
console.error('AI generate error:', err);
addToast('AI generation failed', 'error');
} finally {
setAiGenerating(false);
}
}
/* ── Save Draft ─────────────────────────────────────────────── */
async function handleSaveDraft() {
if (!aiResult) return;
setSavingDraft(true);
try {
const res = await fetch('/api/petitions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: aiResult.title,
summary: aiResult.summary,
body_html: aiResult.body_html,
body_text: aiResult.body_text,
target: aiResult.suggested_target,
category: aiCategory,
tags: aiResult.suggested_tags,
signature_goal: aiResult.suggested_goal,
status: 'draft',
}),
});
if (res.ok) {
setShowAiModal(false);
setAiResult(null);
setAiTopic('');
setAiTarget('');
fetchPetitions();
addToast('Draft saved', 'success');
} else {
addToast('Failed to save draft', 'error');
}
} catch (err) {
console.error('Save draft error:', err);
addToast('Failed to save draft', 'error');
} finally {
setSavingDraft(false);
}
}
/* ── Edit Petition ──────────────────────────────────────────── */
function openEdit(p: Petition) {
setEditPetition(p);
setEditTitle(p.title);
setEditSummary(p.summary || '');
setEditTarget(p.target || '');
setEditGoal(p.signature_goal);
setEditStatus(p.status);
}
async function handleSaveEdit() {
if (!editPetition) return;
setEditSaving(true);
try {
const res = await fetch('/api/petitions', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editPetition.id,
title: editTitle,
summary: editSummary,
target: editTarget,
signature_goal: editGoal,
status: editStatus,
}),
});
if (res.ok) {
setEditPetition(null);
fetchPetitions();
addToast('Petition updated', 'success');
} else {
addToast('Failed to update petition', 'error');
}
} catch (err) {
console.error('Save edit error:', err);
addToast('Failed to update petition', 'error');
} finally {
setEditSaving(false);
}
}
/* ── Deploy to Platforms ──────────────────────────────────── */
const DEPLOY_PLATFORMS = [
{ key: 'moveon', name: 'MoveOn', url: 'sign.moveon.org', icon: '🟡' },
{ key: 'change_org', name: 'Change.org', url: 'change.org', icon: '🔴' },
{ key: 'we_the_people', name: 'We The People', url: 'petitions.whitehouse.gov', icon: '🏛️' },
{ key: 'custom', name: 'Custom URL', url: '', icon: '🔗' },
];
async function handleDeploy() {
if (!deployPetition) return;
const selected = Object.entries(deployPlatforms).filter(([, v]) => v).map(([k]) => k);
if (selected.length === 0) { addToast('Select at least one platform', 'error'); return; }
setDeploying(true);
setDeployResults([]);
const results: typeof deployResults = [];
for (const platform of selected) {
try {
const res = await fetch('/api/orbit/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
petition_id: deployPetition.id,
platform,
custom_url: platform === 'custom' ? deployCustomUrl : undefined,
}),
});
if (res.ok) {
const data = await res.json();
results.push({
platform,
platform_name: data.platform_name,
creation_url: data.creation_url,
formatted_content: data.formatted_content,
instructions: data.instructions,
});
} else {
addToast(`Failed to deploy to ${platform}`, 'error');
}
} catch {
addToast(`Error deploying to ${platform}`, 'error');
}
}
setDeployResults(results);
setDeploying(false);
if (results.length > 0) addToast(`Generated content for ${results.length} platform(s)`, 'success');
}
function copyToClipboard(text: string, label: string) {
navigator.clipboard.writeText(String(text)).then(() => {
setCopiedField(label);
setTimeout(() => setCopiedField(null), 2000);
});
}
/* ── Find Related Grants ──────────────────────────────────── */
async function findRelatedGrants(petitionId: string) {
setLoadingGrants(petitionId);
try {
const res = await fetch('/api/grants-proxy/grants');
if (res.ok) {
const data = await res.json();
setPetitionGrants(prev => ({ ...prev, [petitionId]: data.rows || [] }));
}
} catch (err) {
console.error('Find grants error:', err);
addToast('Failed to find related grants', 'error');
} finally {
setLoadingGrants(null);
}
}
/* ── Helpers ────────────────────────────────────────────────── */
const statusFilters = ['all', 'draft', 'active', 'paused', 'closed', 'delivered'];
const statusBadgeClass: Record<string, string> = {
draft: 'badge-draft',
active: 'badge-active',
paused: 'badge-paused',
closed: 'badge-closed',
delivered: 'badge-delivered',
};
const categoryLabel = (c: string | null) => {
if (!c) return '';
return c.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
};
const formatDate = (d: string) => {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
const sigPct = (count: number, goal: number) => {
if (goal <= 0) return 0;
return Math.min(Math.round((count / goal) * 100), 100);
};
/* ─── Render ────────────────────────────────────────────────── */
return (
<div style={{ padding: '24px' }}>
{/* Header Row */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
My Petitions
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Create, manage, and track your advocacy campaigns
</p>
</div>
<button className="btn btn-primary" onClick={() => setShowAiModal(true)}>
<Sparkles size={16} />
AI Create Petition
</button>
</div>
{/* Filter Row */}
<div className="flex items-center gap-3 mb-6 flex-wrap">
<SortDropdown options={PET_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<div className="flex-1 relative" style={{ minWidth: 200 }}>
<Search
size={16}
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
/>
<input
className="input"
placeholder="Search by title or target..."
style={{ paddingLeft: 36 }}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 flex-wrap">
{statusFilters.map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-ghost'}`}
style={{ textTransform: 'capitalize' }}
>
{s}{statusCounts[s] !== undefined ? ` (${statusCounts[s]})` : ''}
</button>
))}
</div>
</div>
{/* Loading */}
{loading && (
<SkeletonList count={4} />
)}
{/* Empty State */}
{!loading && petitions.length === 0 && (
<div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
style={{
background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
border: '1px solid rgba(124, 58, 237, 0.2)',
}}
>
<Megaphone size={28} style={{ color: 'var(--color-primary)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
No petitions yet
</h3>
<p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Start your first advocacy campaign. Use AI to generate a compelling petition from trending topics.
</p>
<button className="btn btn-primary" onClick={() => setShowAiModal(true)}>
<Sparkles size={16} />
AI Create Petition
</button>
</div>
)}
{/* Petition Cards */}
{!loading && sortedPetitions.length > 0 && (
<div className="flex flex-col gap-3">
{sortedPetitions.map((p) => {
const pct = sigPct(p.signature_count, p.signature_goal);
const isExpanded = expanded === p.id;
return (
<div key={p.id} className="card" style={{ overflow: 'hidden' }}>
{/* Top Row */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3
className="text-sm font-semibold truncate cursor-pointer"
style={{ color: 'var(--color-text)' }}
onClick={() => setExpanded(isExpanded ? null : p.id)}
>
{p.title}
</h3>
<span className={`badge ${statusBadgeClass[p.status] ?? 'badge-draft'}`}>
{p.status}
</span>
{p.category && (
<span
className="badge"
style={{
backgroundColor: 'rgba(124, 58, 237, 0.1)',
color: 'var(--color-secondary)',
border: '1px solid rgba(124, 58, 237, 0.2)',
}}
>
{categoryLabel(p.category)}
</span>
)}
</div>
{/* Signature Progress */}
<div className="flex items-center gap-3 mt-2">
<div
style={{
flex: 1,
maxWidth: 200,
height: 6,
borderRadius: 3,
backgroundColor: 'var(--color-surface-el)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
borderRadius: 3,
background: 'linear-gradient(90deg, #7c3aed, #a78bfa)',
transition: 'width 0.3s ease',
}}
/>
</div>
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{p.signature_count}/{p.signature_goal} ({pct}%)
</span>
</div>
{/* Meta Row */}
<div className="flex items-center gap-4 mt-2">
{p.target && (
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Target size={12} /> {p.target}
</span>
)}
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Share2 size={12} /> {p.share_count} shares
</span>
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Calendar size={12} /> {formatDate(p.created_at)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{p.status === 'draft' && (
<button className="btn btn-ghost btn-sm" onClick={() => openEdit(p)} title="Edit">
<Edit3 size={14} />
</button>
)}
<button
className="btn btn-sm btn-primary"
title="Deploy to Platforms"
onClick={() => {
setDeployPetition(p);
setDeployPlatforms({ moveon: true, change_org: true, we_the_people: false, custom: false });
setDeployResults([]);
setDeployCustomUrl('');
setShowDeployModal(true);
}}
>
<Send size={14} />
<span className="hidden sm:inline">Campaign</span>
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setExpanded(isExpanded ? null : p.id)}
title={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div
style={{
marginTop: 12,
paddingTop: 12,
borderTop: '1px solid var(--color-border)',
}}
>
{p.summary && (
<p className="text-sm mb-3" style={{ color: 'var(--color-text-secondary)' }}>
{p.summary}
</p>
)}
{/* Body Preview -- displayed as plain text for safety */}
<div
className="text-xs mb-3"
style={{
color: 'var(--color-text-muted)',
maxHeight: 120,
overflow: 'hidden',
padding: 8,
borderRadius: 6,
backgroundColor: 'var(--color-surface-el)',
whiteSpace: 'pre-wrap',
}}
>
{stripHtml(p.body_html).slice(0, 500)}
</div>
{/* Target Emails */}
{p.target_emails && p.target_emails.length > 0 && (
<div className="flex items-center gap-2 mb-2 flex-wrap">
<MailIcon size={12} style={{ color: 'var(--color-text-muted)' }} />
{p.target_emails.map((em, i) => (
<span key={i} className="text-xs" style={{
color: 'var(--color-text-secondary)',
padding: '2px 6px',
borderRadius: 4,
backgroundColor: 'var(--color-surface-el)',
}}>
{em}
</span>
))}
</div>
)}
{/* Tags */}
{p.tags && p.tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap mb-3">
<Tag size={12} style={{ color: 'var(--color-text-muted)' }} />
{p.tags.map((t, i) => (
<span key={i} className="text-xs" style={{
color: 'var(--color-secondary)',
padding: '2px 8px',
borderRadius: 9999,
backgroundColor: 'rgba(124, 58, 237, 0.1)',
}}>
{t}
</span>
))}
</div>
)}
{/* Related Grants Section */}
<div style={{ marginTop: 8 }}>
{!petitionGrants[p.id] ? (
<button
onClick={() => findRelatedGrants(p.id)}
disabled={loadingGrants === p.id}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '6px 14px', borderRadius: 8,
border: '1px solid #059669',
backgroundColor: 'transparent', color: '#10b981',
cursor: loadingGrants === p.id ? 'wait' : 'pointer',
fontSize: 12, fontWeight: 600,
}}
>
{loadingGrants === p.id
? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Finding…</>
: <><DollarSign size={12} /> Find Related Grants</>
}
</button>
) : petitionGrants[p.id].length > 0 ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
Related Grants ({petitionGrants[p.id].length})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{petitionGrants[p.id].slice(0, 5).map(g => (
<div key={g.id}
onClick={() => onViewGrant?.(g.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
borderRadius: 6, backgroundColor: 'var(--color-surface-el)',
cursor: 'pointer', transition: 'background-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#059669' + '18'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
>
<DollarSign size={12} style={{ color: '#10b981', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text)' }}>{g.title}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', marginLeft: 6 }}>
{g.funder} · {formatAmount(g.amount_min, g.amount_max)}
</span>
</div>
{g.ai_fit_score != null && (
<span style={{ fontSize: 10, fontWeight: 600, color: '#f59e0b' }}>
{Math.round(g.ai_fit_score * 100)}%
</span>
)}
<ExternalLink size={11} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
</div>
))}
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No related grants found yet. Try discovering grants first.
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* ─── AI Create Modal ─────────────────────────────────────── */}
{showAiModal && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
}}
onClick={(e) => { if (e.target === e.currentTarget) setShowAiModal(false); }}
>
<div
style={{
width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
}}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
<Sparkles size={16} style={{ display: 'inline', marginRight: 6, color: 'var(--color-primary)' }} />
AI Create Petition
</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setShowAiModal(false)}>
<X size={16} />
</button>
</div>
{!aiResult ? (
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Topic *
</label>
<input
className="input"
placeholder="e.g., Student loan interest rate reduction for federal loans"
value={aiTopic}
onChange={(e) => setAiTopic(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Target (optional)
</label>
<input
className="input"
placeholder="e.g., Department of Education"
value={aiTarget}
onChange={(e) => setAiTarget(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Tone
</label>
<select
className="input"
value={aiTone}
onChange={(e) => setAiTone(e.target.value)}
>
<option value="urgent">Urgent</option>
<option value="formal">Formal</option>
<option value="passionate">Passionate</option>
<option value="grassroots">Grassroots</option>
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Category
</label>
<select
className="input"
value={aiCategory}
onChange={(e) => setAiCategory(e.target.value)}
>
<option value="debt_relief">Debt Relief</option>
<option value="policy">Policy</option>
<option value="education_access">Education Access</option>
<option value="consumer_protection">Consumer Protection</option>
<option value="federal_budget">Federal Budget</option>
</select>
</div>
</div>
<button
className="btn btn-primary w-full"
onClick={handleAiGenerate}
disabled={aiGenerating || !aiTopic.trim()}
>
{aiGenerating ? (
<>
<Loader2 size={16} className="animate-spin" />
Generating...
</>
) : (
<>
<Sparkles size={16} />
Generate Petition
</>
)}
</button>
</div>
) : (
/* ── AI Result Preview ─── */
<div className="flex flex-col gap-4">
<div>
<div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Title</div>
<div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{aiResult.title}</div>
</div>
<div>
<div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Summary</div>
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{aiResult.summary}</div>
</div>
<div>
<div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Target</div>
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{aiResult.suggested_target}</div>
</div>
<div>
<div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Body Preview</div>
<div
className="text-xs"
style={{
color: 'var(--color-text-muted)',
maxHeight: 200, overflow: 'auto',
padding: 8, borderRadius: 6,
backgroundColor: 'var(--color-surface-el)',
whiteSpace: 'pre-wrap',
}}
>
{stripHtml(aiResult.body_html)}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Tags:</span>
{aiResult.suggested_tags.map((t, i) => (
<span key={i} className="text-xs" style={{
color: 'var(--color-secondary)',
padding: '2px 8px', borderRadius: 9999,
backgroundColor: 'rgba(124, 58, 237, 0.1)',
}}>
{t}
</span>
))}
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Suggested Goal: {aiResult.suggested_goal} signatures
</div>
<div className="flex items-center gap-3">
<button className="btn btn-primary flex-1" onClick={handleSaveDraft} disabled={savingDraft}>
{savingDraft ? (
<><Loader2 size={16} className="animate-spin" /> Saving...</>
) : (
<><Plus size={16} /> Save as Draft</>
)}
</button>
<button className="btn btn-ghost" onClick={() => setAiResult(null)}>
Regenerate
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* ─── Edit Modal ──────────────────────────────────────────── */}
{editPetition && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
}}
onClick={(e) => { if (e.target === e.currentTarget) setEditPetition(null); }}
>
<div
style={{
width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
}}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
<Edit3 size={16} style={{ display: 'inline', marginRight: 6 }} />
Edit Petition
</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setEditPetition(null)}>
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title</label>
<input className="input" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Summary</label>
<textarea
className="input"
rows={3}
value={editSummary}
onChange={(e) => setEditSummary(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
<input className="input" value={editTarget} onChange={(e) => setEditTarget(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Signature Goal</label>
<input className="input" type="number" value={editGoal} onChange={(e) => setEditGoal(Number(e.target.value))} />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Status</label>
<select className="input" value={editStatus} onChange={(e) => setEditStatus(e.target.value)}>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="closed">Closed</option>
<option value="delivered">Delivered</option>
</select>
</div>
</div>
<button className="btn btn-primary w-full" onClick={handleSaveEdit} disabled={editSaving}>
{editSaving ? <><Loader2 size={16} className="animate-spin" /> Saving...</> : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
{/* ─── Deploy Modal ──────────────────────────────────────────── */}
{showDeployModal && deployPetition && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
}}
onClick={(e) => { if (e.target === e.currentTarget) { setShowDeployModal(false); setDeployResults([]); } }}
>
<div
style={{
width: '100%', maxWidth: 640, maxHeight: '90vh', overflow: 'auto',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
}}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
<Send size={16} style={{ display: 'inline', marginRight: 6, color: 'var(--color-primary)' }} />
Deploy to Platforms
</h3>
<button className="btn btn-ghost btn-sm" onClick={() => { setShowDeployModal(false); setDeployResults([]); }}>
<X size={16} />
</button>
</div>
{/* Petition Info */}
<div style={{ padding: '10px 14px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', marginBottom: 16 }}>
<div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{deployPetition.title}</div>
<div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
{categoryLabel(deployPetition.category)} · {deployPetition.signature_count} signatures · {deployPetition.status}
</div>
</div>
{/* Platform Checkboxes */}
{deployResults.length === 0 && (
<>
<div className="text-xs font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>Select Platforms:</div>
<div className="flex flex-col gap-2 mb-4">
{DEPLOY_PLATFORMS.map(plat => (
<label
key={plat.key}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
borderRadius: 8, cursor: 'pointer',
border: `1px solid ${deployPlatforms[plat.key] ? 'var(--color-primary)' : 'var(--color-border)'}`,
backgroundColor: deployPlatforms[plat.key] ? 'rgba(124, 58, 237, 0.08)' : 'transparent',
transition: 'all 0.15s ease',
}}
>
<input
type="checkbox"
checked={!!deployPlatforms[plat.key]}
onChange={(e) => setDeployPlatforms(prev => ({ ...prev, [plat.key]: e.target.checked }))}
style={{ accentColor: 'var(--color-primary)', width: 16, height: 16 }}
/>
<span style={{ fontSize: 18 }}>{plat.icon}</span>
<div style={{ flex: 1 }}>
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>{plat.name}</div>
{plat.url && <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{plat.url}</div>}
</div>
</label>
))}
{deployPlatforms.custom && (
<input
className="input mt-1"
placeholder="Enter custom platform URL..."
value={deployCustomUrl}
onChange={(e) => setDeployCustomUrl(e.target.value)}
/>
)}
</div>
{/* Select/Clear Buttons */}
<div className="flex items-center gap-2 mb-4">
<button
className="btn btn-ghost btn-sm"
onClick={() => setDeployPlatforms({ moveon: true, change_org: true, we_the_people: true, custom: true })}
>
Select All
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setDeployPlatforms({ moveon: false, change_org: false, we_the_people: false, custom: false })}
>
Clear All
</button>
</div>
{/* Deploy Button */}
<button
className="btn btn-primary w-full"
onClick={handleDeploy}
disabled={deploying || !Object.values(deployPlatforms).some(Boolean)}
>
{deploying ? (
<><Loader2 size={16} className="animate-spin" /> Generating...</>
) : (
<><Send size={16} /> Generate & Deploy</>
)}
</button>
</>
)}
{/* Deploy Results */}
{deployResults.length > 0 && (
<div className="flex flex-col gap-4">
<div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Results ({deployResults.length} platform{deployResults.length > 1 ? 's' : ''})
</div>
{deployResults.map((r) => (
<div key={r.platform} style={{ borderRadius: 8, border: '1px solid var(--color-border)', overflow: 'hidden' }}>
<div style={{ padding: '10px 14px', backgroundColor: 'rgba(124, 58, 237, 0.08)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div className="flex items-center gap-2">
<span style={{ color: '#10b981', fontSize: 14 }}>✓</span>
<span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>{r.platform_name}</span>
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>— Content ready</span>
</div>
<button
className="btn btn-sm btn-primary"
onClick={() => window.open(r.creation_url, '_blank')}
>
<ExternalLink size={12} /> Open Platform
</button>
</div>
<div style={{ padding: 14 }}>
{Object.entries(r.formatted_content).map(([key, value]) => (
<div key={key} style={{ marginBottom: 8 }}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium" style={{ color: 'var(--color-text-muted)', textTransform: 'capitalize' }}>
{key.replace(/_/g, ' ')}
</span>
<button
className="text-xs"
style={{
padding: '2px 8px', borderRadius: 4, cursor: 'pointer',
border: '1px solid var(--color-border)',
backgroundColor: copiedField === `${r.platform}-${key}` ? 'rgba(16, 185, 129, 0.15)' : 'transparent',
color: copiedField === `${r.platform}-${key}` ? '#10b981' : 'var(--color-text-muted)',
transition: 'all 0.15s',
}}
onClick={() => copyToClipboard(
Array.isArray(value) ? (value as string[]).join(', ') : String(value ?? ''),
`${r.platform}-${key}`
)}
>
{copiedField === `${r.platform}-${key}` ? 'Copied!' : 'Copy'}
</button>
</div>
<div
className="text-xs"
style={{
color: 'var(--color-text-secondary)',
padding: '6px 8px', borderRadius: 4,
backgroundColor: 'var(--color-surface-el)',
maxHeight: 80, overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}
>
{Array.isArray(value) ? (value as string[]).join(', ') : String(value ?? '—')}
</div>
</div>
))}
</div>
</div>
))}
<button
className="btn btn-ghost w-full"
onClick={() => setDeployResults([])}
>
← Back to Platform Selection
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}