← back to Norma
app/pulse/petitions/page.tsx
456 lines
'use client';
import React, { useEffect, useState, useCallback, Suspense } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import {
Search, SlidersHorizontal, ChevronLeft, ChevronRight,
FileText, Target, Sparkles, Tag, ArrowUpDown, Loader2, Plus,
} from 'lucide-react';
/* ────────────────────────── Types ────────────────────────── */
interface Petition {
id: string;
title: string;
body: string | null;
description: string | null;
target: string | null;
category: string | null;
signature_count: number | null;
signature_goal: number | null;
is_featured: boolean;
tags: string[] | null;
created_at: string;
}
/* ────────────────────────── Colors ────────────────────────── */
const C = {
darkGreen: '#1B4332',
gold: '#DAA520',
cream: '#FAFAF5',
text: '#1a1a1a',
textLight: '#fafafa',
textMuted: '#6b7280',
border: '#e5e7eb',
};
/* ────────────────────────── Helpers ────────────────────────── */
function categoryLabel(cat: string | null): string {
if (!cat) return 'General';
return cat.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function categoryColor(cat: string | null): string {
const colors: Record<string, string> = {
debt_cancellation: '#dc2626',
consumer_protection: '#6366f1',
voting_rights: '#7c3aed',
legal: '#d97706',
trending: '#059669',
education: '#0891b2',
environment: '#16a34a',
healthcare: '#e11d48',
};
return colors[cat || ''] || '#6b7280';
}
function progressPercent(count: number | null, goal: number | null): number {
if (!goal || goal <= 0) return 0;
return Math.min(100, Math.round(((count || 0) / goal) * 100));
}
function formatNumber(n: number | null): string {
if (n === null || n === undefined) return '0';
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}K`;
return n.toString();
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const days = Math.floor(diff / 86400000);
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 30) return `${days} days ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
}
const CATEGORIES = [
'debt_cancellation', 'consumer_protection', 'voting_rights', 'legal',
'trending', 'education', 'environment', 'healthcare',
];
const SORT_OPTIONS = [
{ value: 'featured', label: 'Featured' },
{ value: 'newest', label: 'Newest' },
{ value: 'signatures', label: 'Most Signatures' },
{ value: 'trending', label: 'Trending' },
];
const PAGE_SIZE = 12;
/* ────────────────────────── Petition Card ────────────────────────── */
function PetitionCard({ petition }: { petition: Petition }) {
const preview = (petition.body || petition.description || '').slice(0, 130);
const pct = progressPercent(petition.signature_count, petition.signature_goal);
return (
<Link href={`/pulse/petition/${petition.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<div
style={{
backgroundColor: '#fff', borderRadius: 16, overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'transform 200ms, box-shadow 200ms', cursor: 'pointer',
display: 'flex', flexDirection: 'column', height: '100%',
border: petition.is_featured ? `2px solid ${C.gold}` : '1px solid #e5e7eb',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 20px rgba(0,0,0,0.1)'; }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)'; }}
>
{petition.is_featured && (
<div style={{ backgroundColor: C.gold, color: C.darkGreen, padding: '5px 16px', fontSize: 11, fontWeight: 700, fontFamily: 'system-ui, sans-serif', display: 'flex', alignItems: 'center', gap: 6 }}>
<Sparkles size={12} /> FEATURED
</div>
)}
<div style={{ padding: 20, flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Top row: category + time */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{
fontSize: 11, fontWeight: 600, fontFamily: 'system-ui, sans-serif',
textTransform: 'uppercase', letterSpacing: '0.5px',
padding: '3px 8px', borderRadius: 9999,
backgroundColor: `${categoryColor(petition.category)}12`,
color: categoryColor(petition.category),
}}>
{categoryLabel(petition.category)}
</span>
<span style={{ fontSize: 12, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
{timeAgo(petition.created_at)}
</span>
</div>
{/* Title */}
<h3 style={{ fontSize: 17, fontWeight: 700, lineHeight: '24px', margin: '0 0 8px', color: C.text, fontFamily: 'Georgia, serif' }}>
{petition.title}
</h3>
{/* Preview */}
<p style={{ fontSize: 14, lineHeight: '21px', color: C.textMuted, margin: '0 0 12px', fontFamily: 'system-ui, sans-serif', flex: 1 }}>
{preview}{preview.length >= 130 ? '...' : ''}
</p>
{/* Target */}
{petition.target && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 14, fontSize: 12, color: C.darkGreen, fontFamily: 'system-ui, sans-serif' }}>
<Target size={13} />
<span style={{ fontWeight: 500 }}>{petition.target}</span>
</div>
)}
{/* Progress */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 5, fontSize: 13, fontFamily: 'system-ui, sans-serif' }}>
<span style={{ fontWeight: 600, color: C.text }}>
{formatNumber(petition.signature_count)} signed
</span>
{petition.signature_goal && petition.signature_goal > 0 && (
<span style={{ color: C.textMuted, fontSize: 12 }}>
{pct}% of {formatNumber(petition.signature_goal)}
</span>
)}
</div>
<div style={{ height: 5, borderRadius: 3, backgroundColor: '#e5e7eb', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, backgroundColor: C.gold,
width: `${petition.signature_goal ? pct : Math.min(100, (petition.signature_count || 0) / 100)}%`,
minWidth: (petition.signature_count || 0) > 0 ? 8 : 0,
transition: 'width 500ms ease',
}} />
</div>
</div>
</div>
</div>
</Link>
);
}
/* ────────────────────────── Main Page ────────────────────────── */
function PetitionsListInner() {
const searchParams = useSearchParams();
const initialCategory = searchParams.get('category') || '';
const [petitions, setPetitions] = useState<Petition[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [category, setCategory] = useState(initialCategory);
const [sort, setSort] = useState('featured');
const [filtersOpen, setFiltersOpen] = useState(false);
const totalPages = Math.ceil(total / PAGE_SIZE);
const loadPetitions = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(page * PAGE_SIZE));
params.set('sort', sort);
if (category) params.set('category', category);
if (searchTerm) params.set('search', searchTerm);
const res = await fetch(`/api/pulse/petitions?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setPetitions(data.petitions || []);
setTotal(data.total || 0);
}
} catch (err) {
console.error('Failed to load petitions:', err);
} finally {
setLoading(false);
}
}, [page, sort, category, searchTerm]);
useEffect(() => {
loadPetitions();
}, [loadPetitions]);
// Reset page when filters change
useEffect(() => {
setPage(0);
}, [sort, category, searchTerm]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(0);
loadPetitions();
};
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px 64px' }}>
{/* Header */}
<div style={{ marginBottom: 32, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16 }}>
<div>
<h1 style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 'bold', color: C.text, margin: '0 0 8px', fontFamily: 'Georgia, serif' }}>
Active Petitions
</h1>
<p style={{ fontSize: 16, color: C.textMuted, margin: 0, fontFamily: 'system-ui, sans-serif' }}>
{total} petition{total !== 1 ? 's' : ''} ready for your voice
</p>
</div>
<Link
href="/pulse/petitions/create"
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
backgroundColor: C.gold, color: C.darkGreen, textDecoration: 'none',
fontSize: 15, fontWeight: 700, fontFamily: 'system-ui, sans-serif',
padding: '12px 24px', borderRadius: 9999,
transition: 'transform 150ms, background-color 150ms',
boxShadow: '0 2px 8px rgba(218,165,32,0.25)',
flexShrink: 0,
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.03)'; e.currentTarget.style.backgroundColor = '#c8941a'; }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.backgroundColor = '#DAA520'; }}
>
<Plus size={18} />
Create Petition
</Link>
</div>
{/* Filter Bar */}
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 20, marginBottom: 24,
border: '1px solid #e5e7eb', boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
}}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Search */}
<form onSubmit={handleSearch} style={{ flex: '1 1 280px', position: 'relative' }}>
<Search size={18} style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: C.textMuted }} />
<input
type="text"
placeholder="Search petitions..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
style={{
width: '100%', padding: '12px 16px 12px 42px', borderRadius: 12,
border: '1px solid #d1d5db', backgroundColor: C.cream, fontSize: 14,
fontFamily: 'system-ui, sans-serif', outline: 'none', color: C.text,
boxSizing: 'border-box',
}}
onFocus={e => (e.target.style.borderColor = C.darkGreen)}
onBlur={e => (e.target.style.borderColor = '#d1d5db')}
/>
</form>
{/* Sort dropdown */}
<div style={{ position: 'relative', flex: '0 0 auto' }}>
<ArrowUpDown size={15} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: C.textMuted, pointerEvents: 'none' }} />
<select
value={sort}
onChange={e => setSort(e.target.value)}
style={{
padding: '12px 16px 12px 34px', borderRadius: 12,
border: '1px solid #d1d5db', backgroundColor: C.cream, fontSize: 14,
fontFamily: 'system-ui, sans-serif', color: C.text, cursor: 'pointer',
appearance: 'none', paddingRight: 32, outline: 'none',
}}
>
{SORT_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Filter toggle */}
<button
onClick={() => setFiltersOpen(!filtersOpen)}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '12px 18px',
borderRadius: 12, border: `1px solid ${filtersOpen ? C.darkGreen : '#d1d5db'}`,
backgroundColor: filtersOpen ? `${C.darkGreen}08` : 'transparent',
color: filtersOpen ? C.darkGreen : C.textMuted,
cursor: 'pointer', fontSize: 14, fontFamily: 'system-ui, sans-serif',
fontWeight: 500,
}}
>
<SlidersHorizontal size={16} />
Filters
{category && <span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: C.gold }} />}
</button>
</div>
{/* Expanded filters */}
{filtersOpen && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #e5e7eb' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: C.textMuted, marginBottom: 10, fontFamily: 'system-ui, sans-serif', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Category
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button
onClick={() => setCategory('')}
style={{
padding: '7px 16px', borderRadius: 9999, fontSize: 13,
fontFamily: 'system-ui, sans-serif', fontWeight: 500, cursor: 'pointer',
border: !category ? `2px solid ${C.darkGreen}` : '1px solid #d1d5db',
backgroundColor: !category ? C.darkGreen : 'transparent',
color: !category ? '#fff' : C.text,
}}
>
All
</button>
{CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setCategory(category === cat ? '' : cat)}
style={{
padding: '7px 16px', borderRadius: 9999, fontSize: 13,
fontFamily: 'system-ui, sans-serif', fontWeight: 500, cursor: 'pointer',
border: category === cat ? `2px solid ${categoryColor(cat)}` : '1px solid #d1d5db',
backgroundColor: category === cat ? `${categoryColor(cat)}12` : 'transparent',
color: category === cat ? categoryColor(cat) : C.text,
}}
>
{categoryLabel(cat)}
</button>
))}
</div>
</div>
)}
</div>
{/* Petition Grid */}
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} style={{ backgroundColor: '#fff', borderRadius: 16, height: 260, border: '1px solid #e5e7eb', padding: 20 }}>
<div style={{ height: 12, backgroundColor: '#e5e7eb', borderRadius: 6, width: '30%', marginBottom: 14 }} />
<div style={{ height: 18, backgroundColor: '#e5e7eb', borderRadius: 6, width: '90%', marginBottom: 8 }} />
<div style={{ height: 18, backgroundColor: '#e5e7eb', borderRadius: 6, width: '65%', marginBottom: 16 }} />
<div style={{ height: 13, backgroundColor: '#e5e7eb', borderRadius: 6, width: '100%', marginBottom: 6 }} />
<div style={{ height: 13, backgroundColor: '#e5e7eb', borderRadius: 6, width: '75%', marginBottom: 20 }} />
<div style={{ height: 5, backgroundColor: '#e5e7eb', borderRadius: 3, width: '100%' }} />
</div>
))}
</div>
) : petitions.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', backgroundColor: '#fff', borderRadius: 16, border: '1px solid #e5e7eb' }}>
<FileText size={48} style={{ color: C.textMuted, opacity: 0.3, marginBottom: 16 }} />
<h3 style={{ fontSize: 20, color: C.text, margin: '0 0 8px', fontFamily: 'Georgia, serif' }}>No petitions found</h3>
<p style={{ fontSize: 15, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
Try adjusting your filters or search terms
</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
{petitions.map(p => <PetitionCard key={p.id} petition={p} />)}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 16,
marginTop: 40, fontFamily: 'system-ui, sans-serif',
}}>
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '10px 20px',
borderRadius: 12, border: '1px solid #d1d5db', backgroundColor: '#fff',
color: page === 0 ? '#d1d5db' : C.text, cursor: page === 0 ? 'not-allowed' : 'pointer',
fontSize: 14, fontWeight: 500,
}}
>
<ChevronLeft size={16} />
Previous
</button>
<span style={{ fontSize: 14, color: C.textMuted }}>
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '10px 20px',
borderRadius: 12, border: '1px solid #d1d5db', backgroundColor: '#fff',
color: page >= totalPages - 1 ? '#d1d5db' : C.text,
cursor: page >= totalPages - 1 ? 'not-allowed' : 'pointer',
fontSize: 14, fontWeight: 500,
}}
>
Next
<ChevronRight size={16} />
</button>
</div>
)}
</div>
);
}
/* ─── Default export wraps inner component in Suspense for useSearchParams ─── */
export default function PetitionsListPage() {
return (
<Suspense fallback={
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Loader2 size={32} style={{ color: '#DAA520', animation: 'spin 1s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
}>
<PetitionsListInner />
</Suspense>
);
}