← back to Patty
components/trends/TrendsTab.tsx
535 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
TrendingUp, Sparkles, RefreshCw, Loader2, Clock,
Megaphone, X, Plus, Target,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const TREND_SORT_OPTIONS = [
{ value: 'engagement_desc', label: 'Most Engaging' },
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'sentiment', label: 'Sentiment A-Z' },
];
const TREND_SORT_CONFIGS: Record<string, SortConfig> = {
engagement_desc: { key: 'engagement_score', direction: 'desc', type: 'number' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
sentiment: { key: 'sentiment', direction: 'asc', type: 'string' },
};
/* ─── Types ─────────────────────────────────────────────────── */
interface TrendingTopic {
id: string;
source: string;
source_url: string | null;
title: string;
content: string | null;
engagement_score: number | null;
sentiment: string | null;
category: string | null;
tags: string[] | null;
is_used: boolean;
expires_at: string | null;
created_at: string;
}
/* ─── Component ─────────────────────────────────────────────── */
export default function TrendsTab() {
const { addToast } = useToast();
const [topics, setTopics] = useState<TrendingTopic[]>([]);
const [loading, setLoading] = useState(true);
const [discovering, setDiscovering] = useState(false);
const [categoryFilter, setCategoryFilter] = useState('all');
const [sortBy, setSortBy] = useState('engagement_desc');
const sortedTopics = useClientSort(topics, sortBy, TREND_SORT_CONFIGS);
// Petition creation modal (pre-filled from trend)
const [createFromTrend, setCreateFromTrend] = useState<TrendingTopic | null>(null);
const [petTopic, setPetTopic] = useState('');
const [petTarget, setPetTarget] = useState('');
const [petTone, setPetTone] = useState('urgent');
const [petCategory, setPetCategory] = useState('policy');
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [generated, setGenerated] = useState<{
title: string; summary: string; body_html: string; body_text: string;
suggested_target: string; suggested_tags: string[]; suggested_goal: number;
} | null>(null);
/* ── Fetch ──────────────────────────────────────────────────── */
const fetchTopics = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (categoryFilter !== 'all') params.set('category', categoryFilter);
const res = await fetch(`/api/trending?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setTopics(data.rows || []);
}
} catch (err) {
console.error('Fetch trends error:', err);
} finally {
setLoading(false);
}
}, [categoryFilter]);
useEffect(() => { fetchTopics(); }, [fetchTopics]);
/* ── Escape key handler ───────────────────────────────────────── */
useEffect(() => {
if (!createFromTrend) return;
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setCreateFromTrend(null); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [createFromTrend]);
/* ── AI Discover ────────────────────────────────────────────── */
async function handleDiscover() {
setDiscovering(true);
try {
const res = await fetch('/api/trending/discover', { method: 'POST' });
if (res.ok) {
fetchTopics();
addToast('Trends discovered', 'success');
} else {
addToast('Failed to discover trends', 'error');
}
} catch (err) {
console.error('Discover error:', err);
addToast('Failed to discover trends', 'error');
} finally {
setDiscovering(false);
}
}
/* ── Create Petition from Trend ─────────────────────────────── */
function openCreateFromTrend(topic: TrendingTopic) {
setCreateFromTrend(topic);
setPetTopic(topic.title);
setPetCategory(topic.category || 'policy');
setPetTarget('');
setPetTone('urgent');
setGenerated(null);
}
async function handleGenerate() {
setGenerating(true);
setGenerated(null);
try {
const res = await fetch('/api/petitions/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: petTopic, target: petTarget, tone: petTone, category: petCategory }),
});
if (res.ok) {
const data = await res.json();
setGenerated(data);
addToast('Petition generated', 'success');
} else {
addToast('Generation failed', 'error');
}
} catch (err) {
console.error('Generate error:', err);
addToast('Generation failed', 'error');
} finally {
setGenerating(false);
}
}
async function handleSavePetition() {
if (!generated) return;
setSaving(true);
try {
const res = await fetch('/api/petitions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: generated.title,
summary: generated.summary,
body_html: generated.body_html,
body_text: generated.body_text,
target: generated.suggested_target,
category: petCategory,
tags: generated.suggested_tags,
signature_goal: generated.suggested_goal,
status: 'draft',
}),
});
if (res.ok) {
setCreateFromTrend(null);
setGenerated(null);
addToast('Petition saved as draft', 'success');
} else {
addToast('Failed to save petition', 'error');
}
} catch (err) {
console.error('Save petition error:', err);
addToast('Failed to save petition', 'error');
} finally {
setSaving(false);
}
}
/* ── Helpers ────────────────────────────────────────────────── */
const categories = ['all', 'debt_relief', 'policy', 'education_access', 'consumer_protection', 'federal_budget'];
const categoryLabels: Record<string, string> = {
all: 'All',
debt_relief: 'Debt Relief',
policy: 'Policy',
education_access: 'Education Access',
consumer_protection: 'Consumer Protection',
federal_budget: 'Federal Budget',
};
const sentimentColors: Record<string, { bg: string; text: string; border: string }> = {
positive: { bg: 'rgba(34,197,94,0.15)', text: 'var(--color-success)', border: 'rgba(34,197,94,0.3)' },
negative: { bg: 'rgba(239,68,68,0.15)', text: 'var(--color-error)', border: 'rgba(239,68,68,0.3)' },
neutral: { bg: 'rgba(161,161,170,0.15)', text: 'var(--color-text-secondary)', border: 'rgba(161,161,170,0.3)' },
mixed: { bg: 'rgba(245,158,11,0.15)', text: 'var(--color-warning)', border: 'rgba(245,158,11,0.3)' },
};
const sourceColors: Record<string, string> = {
'Twitter/X': '#1DA1F2',
'Reddit': '#FF4500',
'News': '#22c55e',
'Congress.gov': '#3b82f6',
'Change.org': '#E84444',
'TikTok': '#ff2d55',
'Instagram': '#C13584',
};
const expiresIn = (d: string | null) => {
if (!d) return null;
const diff = new Date(d).getTime() - Date.now();
if (diff <= 0) return 'Expired';
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h left`;
return `${hours}h left`;
};
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/* ─── Render ────────────────────────────────────────────────── */
return (
<div style={{ padding: '24px' }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Trending Topics
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
AI-curated topics ready for petition creation
</p>
</div>
<button
className="btn btn-primary"
onClick={handleDiscover}
disabled={discovering}
>
{discovering ? (
<><Loader2 size={16} className="animate-spin" /> Discovering...</>
) : (
<><Sparkles size={16} /> AI Discover Trends</>
)}
</button>
</div>
{/* Sort + Category Filter */}
<div className="flex items-center gap-2 mb-6 flex-wrap">
<SortDropdown options={TREND_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
{categories.map((c) => (
<button
key={c}
onClick={() => setCategoryFilter(c)}
className={`btn btn-sm ${categoryFilter === c ? 'btn-primary' : 'btn-ghost'}`}
>
{categoryLabels[c] || c}
</button>
))}
</div>
{/* Loading */}
{loading && (
<SkeletonList count={4} />
)}
{/* Empty State */}
{!loading && topics.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)',
}}
>
<TrendingUp size={28} style={{ color: 'var(--color-primary)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
No trending topics yet
</h3>
<p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Use AI to discover trending topics about student debt and education policy.
</p>
<button className="btn btn-primary" onClick={handleDiscover} disabled={discovering}>
{discovering ? (
<><Loader2 size={16} className="animate-spin" /> Discovering...</>
) : (
<><Sparkles size={16} /> Discover Trends Now</>
)}
</button>
{/* How It Works */}
<div className="mt-10 grid grid-cols-1 sm:grid-cols-3 gap-4 w-full max-w-2xl">
{[
{ title: 'Discover', desc: 'AI scans news, social media, and gov sites for trending civic issues' },
{ title: 'Analyze', desc: 'Each topic is scored for engagement potential and sentiment' },
{ title: 'Create', desc: 'One click to generate a petition draft from any trending topic' },
].map((step, i) => (
<div key={i} className="card-elevated" style={{ padding: '16px', textAlign: 'left' }}>
<div className="text-xs font-bold mb-2" style={{ color: 'var(--color-primary)' }}>
Step {i + 1}
</div>
<div className="text-sm font-semibold mb-1" style={{ color: 'var(--color-text)' }}>
{step.title}
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{step.desc}
</div>
</div>
))}
</div>
</div>
)}
{/* Topic Cards */}
{!loading && sortedTopics.length > 0 && (
<div className="flex flex-col gap-3">
{sortedTopics.map((t) => {
const sent = sentimentColors[t.sentiment || 'neutral'] || sentimentColors.neutral;
const srcColor = sourceColors[t.source] || 'var(--color-text-muted)';
const expiry = expiresIn(t.expires_at);
const score = t.engagement_score || 0;
return (
<div key={t.id} className="card">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
{/* Title + Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{t.title}
</h3>
{/* Source badge */}
<span
className="badge"
style={{
backgroundColor: `${srcColor}20`,
color: srcColor,
border: `1px solid ${srcColor}40`,
}}
>
{t.source}
</span>
{/* Sentiment badge */}
<span
className="badge"
style={{
backgroundColor: sent.bg,
color: sent.text,
border: `1px solid ${sent.border}`,
}}
>
{t.sentiment || 'neutral'}
</span>
{/* Category */}
{t.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)',
}}
>
{categoryLabels[t.category] || t.category}
</span>
)}
</div>
{/* Content */}
{t.content && (
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>
{t.content}
</p>
)}
{/* Engagement Score Bar */}
<div className="flex items-center gap-3 mb-2">
<span className="text-xs" style={{ color: 'var(--color-text-muted)', width: 80 }}>
Engagement
</span>
<div
style={{
flex: 1, maxWidth: 200, height: 6, borderRadius: 3,
backgroundColor: 'var(--color-surface-el)', overflow: 'hidden',
}}
>
<div
style={{
width: `${score}%`, height: '100%', borderRadius: 3,
background: score > 70 ? 'linear-gradient(90deg, #22c55e, #4ade80)' :
score > 40 ? 'linear-gradient(90deg, #f59e0b, #fbbf24)' :
'linear-gradient(90deg, #ef4444, #f87171)',
}}
/>
</div>
<span className="text-xs font-semibold" style={{ color: 'var(--color-text-secondary)', width: 30 }}>
{score}
</span>
</div>
{/* Tags + Expiry */}
<div className="flex items-center gap-2 flex-wrap">
{t.tags && t.tags.map((tag, i) => (
<span key={i} className="text-xs" style={{
color: 'var(--color-secondary)',
padding: '1px 6px', borderRadius: 9999,
backgroundColor: 'rgba(124, 58, 237, 0.08)',
}}>
{tag}
</span>
))}
{expiry && (
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
<Clock size={11} /> {expiry}
</span>
)}
</div>
</div>
{/* Action */}
<button
className="btn btn-sm btn-primary shrink-0"
onClick={() => openCreateFromTrend(t)}
>
<Megaphone size={14} />
Create Petition
</button>
</div>
</div>
);
})}
</div>
)}
{/* ─── Create Petition from Trend Modal ────────────────────── */}
{createFromTrend && (
<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) setCreateFromTrend(null); }}
>
<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)' }}>
Create Petition from Trend
</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setCreateFromTrend(null)}>
<X size={16} />
</button>
</div>
{!generated ? (
<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" value={petTopic} onChange={(e) => setPetTopic(e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
<input className="input" placeholder="e.g., Congress" value={petTarget} onChange={(e) => setPetTarget(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={petTone} onChange={(e) => setPetTone(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={petCategory} onChange={(e) => setPetCategory(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={handleGenerate} disabled={generating}>
{generating ? <><Loader2 size={16} className="animate-spin" /> Generating...</> : <><Sparkles size={16} /> Generate Petition</>}
</button>
</div>
) : (
<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)' }}>{generated.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)' }}>{generated.summary}</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: 160, overflow: 'auto',
padding: 8, borderRadius: 6, backgroundColor: 'var(--color-surface-el)',
whiteSpace: 'pre-wrap',
}}>
{stripHtml(generated.body_html)}
</div>
</div>
<div className="flex items-center gap-3">
<button className="btn btn-primary flex-1" onClick={handleSavePetition} disabled={saving}>
{saving ? <><Loader2 size={16} className="animate-spin" /> Saving...</> : <><Plus size={16} /> Save as Draft</>}
</button>
<button className="btn btn-ghost" onClick={() => setGenerated(null)}>Regenerate</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}