← back to Norma
app/pulse/tracked/page.tsx
386 lines
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import {
Bookmark, BookmarkMinus, FileText, Target, Sparkles,
ChevronLeft, ChevronRight, RefreshCw,
} from 'lucide-react';
/* ────────────────────────── Types ────────────────────────── */
interface TrackedPetition {
tracking_id: string;
tracked_at: string;
petition_id: string;
title: string;
description: string | null;
body: string | null;
category: string | null;
status: string;
target: string | null;
signature_count: number | null;
signature_goal: number | null;
is_featured: boolean;
tags: string[] | null;
petition_updated_at: string;
}
/* ────────────────────────── Colors ────────────────────────── */
const C = {
darkGreen: '#1B4332',
gold: '#DAA520',
text: '#1a1a1a',
textMuted: '#6b7280',
border: '#e5e7eb',
cream: '#FAFAF5',
};
/* ────────────────────────── 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 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));
}
function timeAgo(dateStr: string): string {
const now = Date.now();
const d = new Date(dateStr).getTime();
const diff = now - d;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
/* ────────────────────────── Page ────────────────────────── */
export default function PulseTrackedPage() {
const [tracked, setTracked] = useState<TrackedPetition[]>([]);
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 [untracking, setUntracking] = useState<Set<string>>(new Set());
const fetchTracked = useCallback(async (p: number) => {
setLoading(true);
setError('');
try {
const res = await fetch(`/api/pulse/tracked?page=${p}&limit=20`, { credentials: 'include' });
if (!res.ok) {
if (res.status === 401) { setError('Please sign in to view tracked petitions.'); return; }
throw new Error('Failed to fetch');
}
const data = await res.json();
setTracked(data.tracked || []);
setTotalPages(data.totalPages || 1);
setTotal(data.total || 0);
} catch {
setError('Failed to load tracked petitions. Please try again.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTracked(page); }, [page, fetchTracked]);
async function handleUntrack(petitionId: string) {
setUntracking(prev => new Set(prev).add(petitionId));
try {
const res = await fetch('/api/pulse/tracked', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ petition_id: petitionId }),
});
if (res.ok) {
setTracked(prev => prev.filter(t => t.petition_id !== petitionId));
setTotal(prev => prev - 1);
}
} catch {
// Silently fail
} finally {
setUntracking(prev => { const s = new Set(prev); s.delete(petitionId); return s; });
}
}
return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '40px 24px 80px' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32, flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
backgroundColor: 'rgba(218,165,32,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Bookmark size={22} style={{ color: C.gold }} />
</div>
<div>
<h1 style={{ fontSize: 28, fontWeight: 'bold', color: C.text, margin: 0, fontFamily: 'Georgia, serif' }}>
Tracked Petitions
</h1>
<p style={{ fontSize: 14, color: C.textMuted, margin: '4px 0 0', fontFamily: 'system-ui, sans-serif' }}>
{total > 0 ? `Tracking ${total} petition${total === 1 ? '' : 's'}` : 'Petitions you are following'}
</p>
</div>
</div>
<button
onClick={() => fetchTracked(page)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8, fontSize: 14, fontWeight: 500,
backgroundColor: '#fff', border: '1px solid ' + C.border, cursor: 'pointer',
color: C.text, fontFamily: 'system-ui, sans-serif', transition: 'all 150ms',
}}
>
<RefreshCw size={14} />
Refresh
</button>
</div>
{/* Error state */}
{error && (
<div style={{
backgroundColor: '#fef2f2', border: '1px solid #fecaca', borderRadius: 12,
padding: 24, textAlign: 'center', color: '#991b1b',
fontFamily: 'system-ui, sans-serif',
}}>
{error}
</div>
)}
{/* Loading state */}
{loading && !error && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}>
{[1, 2, 3, 4].map(i => (
<div key={i} style={{
backgroundColor: '#fff', borderRadius: 16, padding: 24, height: 260,
border: '1px solid ' + C.border,
}}>
<div style={{ height: 14, backgroundColor: '#e5e7eb', borderRadius: 7, width: '40%', marginBottom: 16 }} />
<div style={{ height: 20, backgroundColor: '#e5e7eb', borderRadius: 7, width: '90%', marginBottom: 8 }} />
<div style={{ height: 14, backgroundColor: '#e5e7eb', borderRadius: 7, width: '60%', marginBottom: 20 }} />
<div style={{ height: 6, backgroundColor: '#e5e7eb', borderRadius: 3, width: '100%' }} />
</div>
))}
</div>
)}
{/* Empty state */}
{!loading && !error && tracked.length === 0 && (
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 60,
textAlign: 'center', border: '1px solid ' + C.border,
}}>
<Bookmark size={48} style={{ color: '#d1d5db', marginBottom: 16 }} />
<h3 style={{ fontSize: 20, fontWeight: 600, color: C.text, margin: '0 0 8px', fontFamily: 'Georgia, serif' }}>
No tracked petitions
</h3>
<p style={{ fontSize: 15, color: C.textMuted, margin: '0 0 24px', fontFamily: 'system-ui, sans-serif' }}>
Track petitions to follow their progress and get updates.
</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>
)}
{/* Card grid */}
{!loading && !error && tracked.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}>
{tracked.map(item => {
const pct = progressPercent(item.signature_count, item.signature_goal);
const preview = (item.body || item.description || '').slice(0, 100);
const isRemoving = untracking.has(item.petition_id);
return (
<div
key={item.tracking_id}
style={{
backgroundColor: '#fff', borderRadius: 16, overflow: 'hidden',
border: item.is_featured ? `2px solid ${C.gold}` : '1px solid ' + C.border,
display: 'flex', flexDirection: 'column',
transition: 'transform 200ms, box-shadow 200ms, opacity 300ms',
opacity: isRemoving ? 0.5 : 1,
}}
>
{/* Featured badge */}
{item.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: 5,
}}>
<Sparkles size={12} />
FEATURED
</div>
)}
<div style={{ padding: 24, flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Category + updated */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 6 }}>
<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>
<span style={{ fontSize: 12, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>
Updated {timeAgo(item.petition_updated_at)}
</span>
</div>
{/* Title */}
<Link
href={`/pulse/petition/${item.petition_id}`}
style={{
display: 'block', fontSize: 17, fontWeight: 700, lineHeight: '24px',
color: C.text, textDecoration: 'none', marginBottom: 8,
fontFamily: 'Georgia, serif',
}}
>
{item.title}
</Link>
{/* Preview */}
{preview && (
<p style={{ fontSize: 14, lineHeight: '21px', color: C.textMuted, margin: '0 0 12px', fontFamily: 'system-ui, sans-serif', flex: 1 }}>
{preview}{preview.length >= 100 ? '...' : ''}
</p>
)}
{/* Target */}
{item.target && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14, fontSize: 13, color: C.darkGreen, fontFamily: 'system-ui, sans-serif' }}>
<Target size={14} />
<span style={{ fontWeight: 500 }}>{item.target}</span>
</div>
)}
{/* Progress bar */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 5, fontSize: 13, fontFamily: 'system-ui, sans-serif' }}>
<span style={{ fontWeight: 600, color: C.text }}>
{formatNumber(item.signature_count)} signatures
</span>
{item.signature_goal && item.signature_goal > 0 && (
<span style={{ color: C.textMuted }}>{pct}%</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>
{/* Untrack button */}
<button
onClick={() => handleUntrack(item.petition_id)}
disabled={isRemoving}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 600,
backgroundColor: 'transparent', border: '1px solid #fca5a5',
color: '#dc2626', cursor: isRemoving ? 'default' : 'pointer',
fontFamily: 'system-ui, sans-serif', transition: 'all 150ms',
width: '100%',
}}
>
<BookmarkMinus size={14} />
{isRemoving ? 'Removing...' : 'Untrack'}
</button>
</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,
}}
>
<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,
}}
>
Next
<ChevronRight size={16} />
</button>
</div>
)}
</div>
);
}