← back to Norma
app/pulse/petition/[id]/page.tsx
629 lines
'use client';
import React, { useEffect, useState, use } from 'react';
import Link from 'next/link';
import {
Target, Calendar, CheckCircle2, Users, Share2, Twitter,
Facebook, LinkIcon, ArrowLeft, Loader2, AlertCircle, Tag,
ChevronRight, Heart,
} from 'lucide-react';
/* ────────────────────────── Types ────────────────────────── */
interface Petition {
id: string;
title: string;
body: string | null;
description: string | null;
target: string | null;
category: string | null;
tags: string[] | null;
signature_count: number | null;
signature_goal: number | null;
is_featured: boolean;
talking_points: string[] | null;
created_at: string;
updated_at: string;
}
interface RelatedPetition {
id: string;
title: string;
body: string | null;
category: string | null;
signature_count: number | null;
signature_goal: number | null;
is_featured: boolean;
}
/* ────────────────────────── Colors ────────────────────────── */
const C = {
darkGreen: '#1B4332',
gold: '#DAA520',
cream: '#FAFAF5',
text: '#1a1a1a',
textLight: '#fafafa',
textMuted: '#6b7280',
border: '#e5e7eb',
success: '#059669',
error: '#dc2626',
};
/* ────────────────────────── 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',
};
return colors[cat || ''] || '#6b7280';
}
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.toLocaleString();
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
}
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));
}
/* ────────────────────────── Page Component ────────────────────────── */
export default function PetitionDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [petition, setPetition] = useState<Petition | null>(null);
const [related, setRelated] = useState<RelatedPetition[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Sign form state
const [showSignForm, setShowSignForm] = useState(false);
const [signName, setSignName] = useState('');
const [signEmail, setSignEmail] = useState('');
const [signZip, setSignZip] = useState('');
const [signing, setSigning] = useState(false);
const [signResult, setSignResult] = useState<{ success: boolean; message: string } | null>(null);
// Share
const [copied, setCopied] = useState(false);
useEffect(() => {
async function load() {
try {
const res = await fetch(`/api/pulse/petitions/${id}`);
if (!res.ok) {
if (res.status === 404) setError('Petition not found');
else setError('Failed to load petition');
return;
}
const data = await res.json();
setPetition(data.petition);
setRelated(data.related || []);
} catch (err) {
setError('Failed to load petition');
} finally {
setLoading(false);
}
}
load();
}, [id]);
const handleSign = async (e: React.FormEvent) => {
e.preventDefault();
if (!signName.trim() || !signEmail.trim()) return;
setSigning(true);
setSignResult(null);
try {
const res = await fetch(`/api/pulse/petitions/${id}/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: signName.trim(),
email: signEmail.trim(),
zip_code: signZip.trim() || undefined,
}),
});
const data = await res.json();
setSignResult({ success: data.success !== false, message: data.message || 'Thank you!' });
if (data.success && petition) {
setPetition({ ...petition, signature_count: data.totalSignatures });
}
} catch {
setSignResult({ success: false, message: 'Something went wrong. Please try again.' });
} finally {
setSigning(false);
}
};
const shareUrl = typeof window !== 'undefined' ? window.location.href : '';
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// ─── Loading state ───
if (loading) {
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '60px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 300 }}>
<Loader2 size={32} style={{ color: C.gold, animation: 'spin 1s linear infinite' }} />
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
// ─── Error state ───
if (error || !petition) {
return (
<div style={{ maxWidth: 600, margin: '0 auto', padding: '80px 24px', textAlign: 'center' }}>
<AlertCircle size={48} style={{ color: C.error, marginBottom: 16 }} />
<h2 style={{ fontSize: 24, color: C.text, margin: '0 0 12px', fontFamily: 'Georgia, serif' }}>
{error || 'Petition not found'}
</h2>
<p style={{ fontSize: 15, color: C.textMuted, margin: '0 0 24px', fontFamily: 'system-ui, sans-serif' }}>
This petition may have been removed or is no longer active.
</p>
<Link
href="/pulse/petitions"
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
color: C.darkGreen, textDecoration: 'none', fontSize: 15, fontWeight: 600,
fontFamily: 'system-ui, sans-serif',
}}
>
<ArrowLeft size={16} />
Back to all petitions
</Link>
</div>
);
}
const pct = progressPercent(petition.signature_count, petition.signature_goal);
const bodyText = petition.body || petition.description || '';
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px 64px' }}>
{/* Back link */}
<Link
href="/pulse/petitions"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
color: C.textMuted, textDecoration: 'none', fontSize: 14, fontWeight: 500,
fontFamily: 'system-ui, sans-serif', marginBottom: 24,
}}
>
<ArrowLeft size={16} />
All petitions
</Link>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 32 }} className="poa-detail-grid">
{/* ─── Main content ─── */}
<div style={{ gridColumn: '1', minWidth: 0 }}>
{/* Category + date */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<span style={{
fontSize: 12, fontWeight: 600, fontFamily: 'system-ui, sans-serif',
textTransform: 'uppercase', letterSpacing: '0.5px',
padding: '4px 12px', borderRadius: 9999,
backgroundColor: `${categoryColor(petition.category)}12`,
color: categoryColor(petition.category),
}}>
{categoryLabel(petition.category)}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
<Calendar size={14} />
{formatDate(petition.created_at)}
</span>
</div>
{/* Title */}
<h1 style={{
fontSize: 'clamp(28px, 4vw, 44px)', fontWeight: 'bold', lineHeight: 1.2,
color: C.text, margin: '0 0 16px', fontFamily: 'Georgia, serif',
}}>
{petition.title}
</h1>
{/* Target */}
{petition.target && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '10px 18px', borderRadius: 12,
backgroundColor: '#d1fae5', color: '#065f46',
marginBottom: 24, fontSize: 14, fontWeight: 600,
fontFamily: 'system-ui, sans-serif',
}}>
<Target size={16} />
Petition to: {petition.target}
</div>
)}
{/* ─── Progress Bar (large) ─── */}
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24,
border: '1px solid #e5e7eb',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<div>
<span style={{ fontSize: 32, fontWeight: 'bold', color: C.text, fontFamily: 'Georgia, serif' }}>
{formatNumber(petition.signature_count)}
</span>
<span style={{ fontSize: 16, color: C.textMuted, fontFamily: 'system-ui, sans-serif', marginLeft: 8 }}>
signatures
</span>
</div>
{petition.signature_goal && petition.signature_goal > 0 && (
<span style={{ fontSize: 15, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
Goal: {formatNumber(petition.signature_goal)}
</span>
)}
</div>
<div style={{ height: 12, borderRadius: 6, backgroundColor: '#e5e7eb', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 6, backgroundColor: C.gold,
width: petition.signature_goal ? `${pct}%` : `${Math.min(100, (petition.signature_count || 0) / 100)}%`,
minWidth: (petition.signature_count || 0) > 0 ? 12 : 0,
transition: 'width 500ms ease',
}} />
</div>
{petition.signature_goal && petition.signature_goal > 0 && (
<div style={{ marginTop: 8, fontSize: 13, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
{pct}% of goal reached
</div>
)}
</div>
{/* ─── Sign button (before form) ─── */}
{!showSignForm && !signResult?.success && (
<button
onClick={() => setShowSignForm(true)}
style={{
width: '100%', padding: '18px 32px', borderRadius: 16,
backgroundColor: C.gold, color: C.darkGreen, border: 'none',
fontSize: 18, fontWeight: 700, fontFamily: 'system-ui, sans-serif',
cursor: 'pointer', transition: 'transform 150ms, background-color 150ms',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
marginBottom: 32,
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.01)'; e.currentTarget.style.backgroundColor = '#c8941a'; }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.backgroundColor = C.gold; }}
>
<Heart size={20} />
Sign This Petition
</button>
)}
{/* ─── Sign Form ─── */}
{showSignForm && !signResult?.success && (
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 28, marginBottom: 32,
border: `2px solid ${C.gold}`,
}}>
<h3 style={{ fontSize: 20, fontWeight: 'bold', color: C.text, margin: '0 0 4px', fontFamily: 'Georgia, serif' }}>
Sign This Petition
</h3>
<p style={{ fontSize: 14, color: C.textMuted, margin: '0 0 20px', fontFamily: 'system-ui, sans-serif' }}>
Add your name to make your voice heard
</p>
<form onSubmit={handleSign}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, color: C.text, marginBottom: 5, fontFamily: 'system-ui, sans-serif' }}>
Full Name *
</label>
<input
type="text"
required
value={signName}
onChange={e => setSignName(e.target.value)}
placeholder="Your full name"
style={{
width: '100%', padding: '12px 16px', borderRadius: 10,
border: '1px solid #d1d5db', fontSize: 15, fontFamily: 'system-ui, sans-serif',
color: C.text, backgroundColor: C.cream, outline: 'none',
boxSizing: 'border-box',
}}
onFocus={e => (e.target.style.borderColor = C.gold)}
onBlur={e => (e.target.style.borderColor = '#d1d5db')}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, color: C.text, marginBottom: 5, fontFamily: 'system-ui, sans-serif' }}>
Email Address *
</label>
<input
type="email"
required
value={signEmail}
onChange={e => setSignEmail(e.target.value)}
placeholder="you@example.com"
style={{
width: '100%', padding: '12px 16px', borderRadius: 10,
border: '1px solid #d1d5db', fontSize: 15, fontFamily: 'system-ui, sans-serif',
color: C.text, backgroundColor: C.cream, outline: 'none',
boxSizing: 'border-box',
}}
onFocus={e => (e.target.style.borderColor = C.gold)}
onBlur={e => (e.target.style.borderColor = '#d1d5db')}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, color: C.text, marginBottom: 5, fontFamily: 'system-ui, sans-serif' }}>
ZIP Code <span style={{ fontWeight: 400, color: C.textMuted }}>(optional)</span>
</label>
<input
type="text"
value={signZip}
onChange={e => setSignZip(e.target.value)}
placeholder="12345"
maxLength={10}
style={{
width: '100%', padding: '12px 16px', borderRadius: 10,
border: '1px solid #d1d5db', fontSize: 15, fontFamily: 'system-ui, sans-serif',
color: C.text, backgroundColor: C.cream, outline: 'none',
boxSizing: 'border-box',
}}
onFocus={e => (e.target.style.borderColor = C.gold)}
onBlur={e => (e.target.style.borderColor = '#d1d5db')}
/>
</div>
</div>
{signResult && !signResult.success && (
<div style={{
marginTop: 14, padding: '10px 14px', borderRadius: 10,
backgroundColor: '#fef2f2', color: C.error, fontSize: 14,
fontFamily: 'system-ui, sans-serif', display: 'flex', alignItems: 'center', gap: 8,
}}>
<AlertCircle size={16} />
{signResult.message}
</div>
)}
<button
type="submit"
disabled={signing}
style={{
width: '100%', padding: '16px', borderRadius: 12, marginTop: 18,
backgroundColor: signing ? '#9ca3af' : C.gold,
color: C.darkGreen, border: 'none',
fontSize: 16, fontWeight: 700, fontFamily: 'system-ui, sans-serif',
cursor: signing ? 'not-allowed' : 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'background-color 150ms',
}}
>
{signing ? (
<>
<Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} />
Signing...
</>
) : (
<>
<CheckCircle2 size={18} />
Add My Name
</>
)}
</button>
<p style={{ fontSize: 12, color: C.textMuted, margin: '12px 0 0', textAlign: 'center', fontFamily: 'system-ui, sans-serif' }}>
By signing, you agree that your name may be displayed publicly as a supporter.
</p>
</form>
</div>
)}
{/* ─── Sign Success ─── */}
{signResult?.success && (
<div style={{
backgroundColor: '#ecfdf5', borderRadius: 16, padding: 28, marginBottom: 32,
border: '2px solid #059669', textAlign: 'center',
}}>
<CheckCircle2 size={48} style={{ color: C.success, marginBottom: 12 }} />
<h3 style={{ fontSize: 22, fontWeight: 'bold', color: '#065f46', margin: '0 0 8px', fontFamily: 'Georgia, serif' }}>
Thank You for Signing!
</h3>
<p style={{ fontSize: 15, color: '#047857', margin: '0 0 16px', fontFamily: 'system-ui, sans-serif' }}>
Your signature has been recorded. Help spread the word by sharing this petition.
</p>
</div>
)}
{/* ─── Body ─── */}
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 28, marginBottom: 24,
border: '1px solid #e5e7eb',
}}>
<div style={{ fontSize: 16, lineHeight: '28px', color: C.text, fontFamily: 'system-ui, sans-serif' }}>
{bodyText.split('\n').map((paragraph, i) => (
paragraph.trim() ? (
<p key={i} style={{ margin: '0 0 16px' }}>{paragraph}</p>
) : null
))}
{!bodyText && (
<p style={{ color: C.textMuted, fontStyle: 'italic' }}>No detailed description available.</p>
)}
</div>
</div>
{/* ─── Talking Points ─── */}
{petition.talking_points && petition.talking_points.length > 0 && (
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 28, marginBottom: 24,
border: '1px solid #e5e7eb',
}}>
<h3 style={{ fontSize: 20, fontWeight: 'bold', color: C.text, margin: '0 0 16px', fontFamily: 'Georgia, serif' }}>
Key Points
</h3>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{petition.talking_points.map((tp, i) => (
<li key={i} style={{ display: 'flex', gap: 12, marginBottom: 14, alignItems: 'flex-start' }}>
<CheckCircle2
size={20}
style={{ color: C.gold, flexShrink: 0, marginTop: 2 }}
/>
<span style={{ fontSize: 15, lineHeight: '24px', color: C.text, fontFamily: 'system-ui, sans-serif' }}>
{tp}
</span>
</li>
))}
</ul>
</div>
)}
{/* ─── Tags ─── */}
{petition.tags && petition.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 24 }}>
{petition.tags.map(tag => (
<span key={tag} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '5px 12px', borderRadius: 9999,
backgroundColor: '#f3f4f6', color: C.textMuted,
fontSize: 13, fontFamily: 'system-ui, sans-serif',
}}>
<Tag size={12} />
{tag}
</span>
))}
</div>
)}
{/* ─── Share Buttons ─── */}
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24,
border: '1px solid #e5e7eb',
}}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: C.text, margin: '0 0 14px', fontFamily: 'system-ui, sans-serif', display: 'flex', alignItems: 'center', gap: 8 }}>
<Share2 size={18} />
Share This Petition
</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<a
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(petition.title)}&url=${encodeURIComponent(shareUrl)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10,
backgroundColor: '#1DA1F2', color: '#fff', textDecoration: 'none',
fontSize: 14, fontWeight: 600, fontFamily: 'system-ui, sans-serif',
transition: 'opacity 150ms',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
>
<Twitter size={16} />
Twitter
</a>
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10,
backgroundColor: '#1877F2', color: '#fff', textDecoration: 'none',
fontSize: 14, fontWeight: 600, fontFamily: 'system-ui, sans-serif',
transition: 'opacity 150ms',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
>
<Facebook size={16} />
Facebook
</a>
<button
onClick={handleCopyLink}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10,
backgroundColor: '#f3f4f6', color: C.text, border: '1px solid #d1d5db',
fontSize: 14, fontWeight: 600, fontFamily: 'system-ui, sans-serif',
cursor: 'pointer', transition: 'all 150ms',
}}
>
<LinkIcon size={16} />
{copied ? 'Copied!' : 'Copy Link'}
</button>
</div>
</div>
{/* ─── Related Petitions ─── */}
{related.length > 0 && (
<div>
<h3 style={{ fontSize: 22, fontWeight: 'bold', color: C.text, margin: '0 0 16px', fontFamily: 'Georgia, serif' }}>
Related Petitions
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{related.map(r => (
<Link
key={r.id}
href={`/pulse/petition/${r.id}`}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderRadius: 12,
backgroundColor: '#fff', border: '1px solid #e5e7eb',
textDecoration: 'none', color: 'inherit',
transition: 'box-shadow 150ms, transform 150ms',
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)'; e.currentTarget.style.transform = 'translateY(-1px)'; }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none'; }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, color: C.text, margin: 0, fontFamily: 'Georgia, serif' }}>
{r.title}
</h4>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<Users size={13} style={{ color: C.textMuted }} />
<span style={{ fontSize: 13, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
{formatNumber(r.signature_count)} signatures
</span>
</div>
</div>
<ChevronRight size={18} style={{ color: C.textMuted, flexShrink: 0 }} />
</Link>
))}
</div>
</div>
)}
</div>
</div>
{/* Responsive grid and spinner styles */}
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
@media (min-width: 900px) {
.poa-detail-grid {
grid-template-columns: 1fr 360px !important;
}
}
`}</style>
</div>
);
}