← back to Norma
app/pulse/activity/page.tsx
375 lines
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import {
Activity, FileText, Calendar, ChevronLeft, ChevronRight,
MessageSquare, Sparkles, Target,
} from 'lucide-react';
/* ────────────────────────── Types ────────────────────────── */
interface ActivityItem {
signature_id: string;
signed_at: string;
comment: string | null;
petition_id: string;
petition_title: string;
category: string | null;
status: string;
signature_count: number | null;
signature_goal: number | null;
is_featured: boolean;
}
/* ────────────────────────── Colors ────────────────────────── */
const C = {
darkGreen: '#1B4332',
gold: '#DAA520',
text: '#1a1a1a',
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 formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', timeZone: 'America/Los_Angeles',
});
}
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 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 ────────────────────────── */
export default function PulseActivityPage() {
const [activity, setActivity] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const fetchActivity = useCallback(async (p: number) => {
setLoading(true);
setError('');
try {
const res = await fetch(`/api/pulse/activity?page=${p}&limit=20`, { credentials: 'include' });
if (!res.ok) {
if (res.status === 401) { setError('Please sign in to view your activity.'); return; }
throw new Error('Failed to fetch');
}
const data = await res.json();
setActivity(data.activity || []);
setTotalPages(data.totalPages || 1);
setTotal(data.total || 0);
} catch {
setError('Failed to load activity. Please try again.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchActivity(page); }, [page, fetchActivity]);
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '40px 24px 80px' }}>
{/* Header */}
<div style={{ marginBottom: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
backgroundColor: 'rgba(27,67,50,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Activity size={22} style={{ color: C.darkGreen }} />
</div>
<div>
<h1 style={{ fontSize: 28, fontWeight: 'bold', color: C.text, margin: 0, fontFamily: 'Georgia, serif' }}>
My Activity
</h1>
<p style={{ fontSize: 14, color: C.textMuted, margin: '4px 0 0', fontFamily: 'system-ui, sans-serif' }}>
{total > 0 ? `${total} petition${total === 1 ? '' : 's'} signed` : 'Your signature history'}
</p>
</div>
</div>
</div>
{/* Error state — for "please sign in" cases, show a CTA button so
guests have a one-click path to /login instead of a dead red box. */}
{error && (
<div style={{
backgroundColor: /sign in/i.test(error) ? '#f0fdf4' : '#fef2f2',
border: '1px solid ' + (/sign in/i.test(error) ? '#bbf7d0' : '#fecaca'),
borderRadius: 12, padding: 24, textAlign: 'center',
color: /sign in/i.test(error) ? '#14532d' : '#991b1b',
fontFamily: 'system-ui, sans-serif',
}}>
<div style={{ marginBottom: /sign in/i.test(error) ? 12 : 0 }}>{error}</div>
{/sign in/i.test(error) && (
<Link
href="/login"
style={{
display: 'inline-block', padding: '10px 24px',
backgroundColor: C.darkGreen, color: '#fff',
borderRadius: 8, fontSize: 14, fontWeight: 600,
textDecoration: 'none',
}}
>
Sign in
</Link>
)}
</div>
)}
{/* Loading state */}
{loading && !error && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{[1, 2, 3, 4].map(i => (
<div key={i} style={{
backgroundColor: '#fff', borderRadius: 16, padding: 24,
border: '1px solid ' + C.border,
}}>
<div style={{ height: 14, backgroundColor: '#e5e7eb', borderRadius: 7, width: '30%', marginBottom: 12 }} />
<div style={{ height: 20, backgroundColor: '#e5e7eb', borderRadius: 7, width: '80%', marginBottom: 8 }} />
<div style={{ height: 14, backgroundColor: '#e5e7eb', borderRadius: 7, width: '50%' }} />
</div>
))}
</div>
)}
{/* Empty state */}
{!loading && !error && activity.length === 0 && (
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 60,
textAlign: 'center', border: '1px solid ' + C.border,
}}>
<FileText size={48} style={{ color: '#d1d5db', marginBottom: 16 }} />
<h3 style={{ fontSize: 20, fontWeight: 600, color: C.text, margin: '0 0 8px', fontFamily: 'Georgia, serif' }}>
No activity yet
</h3>
<p style={{ fontSize: 15, color: C.textMuted, margin: '0 0 24px', fontFamily: 'system-ui, sans-serif' }}>
Sign your first petition to see it here.
</p>
<Link
href="/pulse/petitions"
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
backgroundColor: C.darkGreen, color: '#fff', textDecoration: 'none',
padding: '12px 28px', borderRadius: 9999, fontSize: 15, fontWeight: 600,
fontFamily: 'system-ui, sans-serif',
}}
>
Browse Petitions
</Link>
</div>
)}
{/* Timeline */}
{!loading && !error && activity.length > 0 && (
<div style={{ position: 'relative' }}>
{/* Vertical timeline line */}
<div style={{
position: 'absolute', left: 21, top: 24, bottom: 24,
width: 2, backgroundColor: '#e5e7eb',
}} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{activity.map((item, idx) => {
const pct = progressPercent(item.signature_count, item.signature_goal);
return (
<div key={item.signature_id} style={{ display: 'flex', gap: 20, position: 'relative' }}>
{/* Timeline dot */}
<div style={{
width: 44, minWidth: 44, display: 'flex', flexDirection: 'column',
alignItems: 'center', paddingTop: 24,
}}>
<div style={{
width: 12, height: 12, borderRadius: '50%', zIndex: 1,
backgroundColor: item.is_featured ? C.gold : C.darkGreen,
border: '3px solid #fff',
boxShadow: '0 0 0 2px ' + (item.is_featured ? C.gold : C.darkGreen),
}} />
</div>
{/* Card */}
<div style={{
flex: 1, backgroundColor: '#fff', borderRadius: 16,
border: '1px solid ' + C.border, padding: 24,
marginBottom: idx < activity.length - 1 ? 16 : 0,
transition: 'box-shadow 200ms',
}}>
{/* Date + status */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 12, flexWrap: 'wrap', gap: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
<Calendar size={14} />
Signed {formatDate(item.signed_at)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<span style={{
display: 'inline-block', fontSize: 11, fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.5px',
padding: '3px 10px', borderRadius: 9999,
backgroundColor: `${categoryColor(item.category)}15`,
color: categoryColor(item.category),
fontFamily: 'system-ui, sans-serif',
}}>
{categoryLabel(item.category)}
</span>
{item.is_featured && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 600, padding: '3px 10px', borderRadius: 9999,
backgroundColor: 'rgba(218,165,32,0.15)', color: C.gold,
fontFamily: 'system-ui, sans-serif',
}}>
<Sparkles size={12} />
Featured
</span>
)}
</div>
</div>
{/* Title */}
<Link
href={`/pulse/petition/${item.petition_id}`}
style={{
display: 'block', fontSize: 18, fontWeight: 700, lineHeight: '26px',
color: C.text, textDecoration: 'none', marginBottom: 12,
fontFamily: 'Georgia, serif',
}}
>
{item.petition_title}
</Link>
{/* Comment */}
{item.comment && (
<div style={{
display: 'flex', gap: 8, padding: '10px 14px', borderRadius: 10,
backgroundColor: '#f9fafb', marginBottom: 12,
fontSize: 14, color: '#4b5563', fontFamily: 'system-ui, sans-serif',
fontStyle: 'italic', lineHeight: '22px',
}}>
<MessageSquare size={16} style={{ color: '#9ca3af', flexShrink: 0, marginTop: 2 }} />
<span>“{item.comment}”</span>
</div>
)}
{/* Progress */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flex: 1 }}>
<div style={{
display: 'flex', justifyContent: 'space-between', marginBottom: 4,
fontSize: 13, fontFamily: 'system-ui, sans-serif',
}}>
<span style={{ fontWeight: 600, color: C.text }}>
<Target size={13} style={{ display: 'inline', verticalAlign: '-2px', marginRight: 4 }} />
{formatNumber(item.signature_count)} signatures
</span>
{item.signature_goal && (
<span style={{ color: C.textMuted }}>
{pct}% of {formatNumber(item.signature_goal)}
</span>
)}
</div>
<div style={{ height: 6, borderRadius: 3, backgroundColor: '#e5e7eb', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, backgroundColor: C.gold,
width: `${pct}%`, transition: 'width 500ms ease',
}} />
</div>
</div>
<span style={{
fontSize: 12, fontWeight: 600, fontFamily: 'system-ui, sans-serif',
padding: '4px 10px', borderRadius: 9999,
backgroundColor: item.status === 'active' ? 'rgba(22,163,106,0.1)' : 'rgba(107,114,128,0.1)',
color: item.status === 'active' ? '#16a34a' : '#6b7280',
textTransform: 'capitalize',
}}>
{item.status}
</span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Pagination */}
{!loading && totalPages > 1 && (
<div style={{
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 16,
marginTop: 32, fontFamily: 'system-ui, sans-serif',
}}>
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '8px 16px', borderRadius: 8, fontSize: 14, fontWeight: 500,
backgroundColor: page <= 1 ? '#f3f4f6' : '#fff',
border: '1px solid ' + C.border, cursor: page <= 1 ? 'default' : 'pointer',
color: page <= 1 ? '#9ca3af' : C.text,
transition: 'all 150ms',
}}
>
<ChevronLeft size={16} />
Previous
</button>
<span style={{ fontSize: 14, color: C.textMuted }}>
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '8px 16px', borderRadius: 8, fontSize: 14, fontWeight: 500,
backgroundColor: page >= totalPages ? '#f3f4f6' : '#fff',
border: '1px solid ' + C.border, cursor: page >= totalPages ? 'default' : 'pointer',
color: page >= totalPages ? '#9ca3af' : C.text,
transition: 'all 150ms',
}}
>
Next
<ChevronRight size={16} />
</button>
</div>
)}
</div>
);
}