← back to Norma
app/pulse/profile/page.tsx
376 lines
'use client';
import React, { useEffect, useState } from 'react';
import {
User, Calendar, FileText, Bookmark, Save,
Check, AlertCircle, Loader2,
} from 'lucide-react';
/* ────────────────────────── Types ────────────────────────── */
interface ProfileData {
username: string;
displayName: string;
role: string;
createdAt: string;
signedCount: number;
trackedCount: number;
}
/* ────────────────────────── Colors ────────────────────────── */
const C = {
darkGreen: '#1B4332',
gold: '#DAA520',
text: '#1a1a1a',
textMuted: '#6b7280',
border: '#e5e7eb',
};
/* ────────────────────────── Helpers ────────────────────────── */
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
timeZone: 'America/Los_Angeles',
});
}
function roleLabel(role: string): string {
switch (role) {
case 'admin': return 'Administrator';
case 'staff': return 'Staff Member';
case 'pulse': return 'Pulse User';
default: return role;
}
}
function roleColor(role: string): { bg: string; text: string } {
switch (role) {
case 'admin': return { bg: 'rgba(99,102,241,0.1)', text: '#6366f1' };
case 'staff': return { bg: 'rgba(14,165,233,0.1)', text: '#0ea5e9' };
default: return { bg: 'rgba(218,165,32,0.1)', text: '#b8860b' };
}
}
/* ────────────────────────── Page ────────────────────────── */
export default function PulseProfilePage() {
const [profile, setProfile] = useState<ProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Edit state
const [editDisplayName, setEditDisplayName] = useState('');
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState('');
useEffect(() => {
async function loadProfile() {
try {
// Fetch session, stats in parallel
const [sessionRes, activityRes, trackedRes] = await Promise.all([
fetch('/api/auth/session', { credentials: 'include' }),
fetch('/api/pulse/activity?page=1&limit=1', { credentials: 'include' }),
fetch('/api/pulse/tracked?page=1&limit=1', { credentials: 'include' }),
]);
if (!sessionRes.ok) {
setError('Please sign in to view your profile.');
return;
}
const sessionData = await sessionRes.json();
if (!sessionData.authenticated) {
setError('Please sign in to view your profile.');
return;
}
const activityData = activityRes.ok ? await activityRes.json() : { total: 0 };
const trackedData = trackedRes.ok ? await trackedRes.json() : { total: 0 };
// Fetch full profile from a profile API (or build from session data)
const profileRes = await fetch('/api/pulse/profile', { credentials: 'include' });
let displayName = sessionData.displayName || sessionData.user;
let createdAt = new Date().toISOString();
if (profileRes.ok) {
const profileData = await profileRes.json();
displayName = profileData.display_name || displayName;
createdAt = profileData.created_at || createdAt;
}
const p: ProfileData = {
username: sessionData.user,
displayName,
role: sessionData.role,
createdAt,
signedCount: activityData.total || 0,
trackedCount: trackedData.total || 0,
};
setProfile(p);
setEditDisplayName(p.displayName);
} catch {
setError('Failed to load profile. Please try again.');
} finally {
setLoading(false);
}
}
loadProfile();
}, []);
async function handleSaveDisplayName() {
if (!editDisplayName.trim() || editDisplayName.trim() === profile?.displayName) return;
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
const res = await fetch('/api/pulse/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ display_name: editDisplayName.trim() }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to save');
}
setProfile(prev => prev ? { ...prev, displayName: editDisplayName.trim() } : prev);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (err) {
setSaveError((err as Error).message);
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div style={{ maxWidth: 700, margin: '0 auto', padding: '40px 24px 80px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{[1, 2, 3].map(i => (
<div key={i} style={{
backgroundColor: '#fff', borderRadius: 16, padding: 32,
border: '1px solid ' + C.border,
}}>
<div style={{ height: 20, backgroundColor: '#e5e7eb', borderRadius: 7, width: '40%', marginBottom: 16 }} />
<div style={{ height: 14, backgroundColor: '#e5e7eb', borderRadius: 7, width: '70%' }} />
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div style={{ maxWidth: 700, margin: '0 auto', padding: '40px 24px 80px' }}>
<div style={{
backgroundColor: '#fef2f2', border: '1px solid #fecaca', borderRadius: 12,
padding: 24, textAlign: 'center', color: '#991b1b',
fontFamily: 'system-ui, sans-serif',
}}>
{error}
</div>
</div>
);
}
if (!profile) return null;
const rc = roleColor(profile.role);
return (
<div style={{ maxWidth: 700, 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',
}}>
<User size={22} style={{ color: C.darkGreen }} />
</div>
<h1 style={{ fontSize: 28, fontWeight: 'bold', color: C.text, margin: 0, fontFamily: 'Georgia, serif' }}>
My Profile
</h1>
</div>
</div>
{/* Profile Card */}
<div style={{
backgroundColor: '#fff', borderRadius: 16, overflow: 'hidden',
border: '1px solid ' + C.border, marginBottom: 24,
}}>
{/* Profile header with avatar */}
<div style={{
backgroundColor: C.darkGreen, padding: '32px 32px 40px',
display: 'flex', alignItems: 'center', gap: 20,
}}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
backgroundColor: 'rgba(218,165,32,0.2)', border: '3px solid ' + C.gold,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'Georgia, serif', fontSize: 28, fontWeight: 'bold',
color: C.gold,
}}>
{(profile.displayName || profile.username).charAt(0).toUpperCase()}
</div>
<div>
<h2 style={{ fontSize: 24, fontWeight: 'bold', color: '#fff', margin: 0, fontFamily: 'Georgia, serif' }}>
{profile.displayName}
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14, color: 'rgba(255,255,255,0.7)', fontFamily: 'system-ui, sans-serif' }}>
@{profile.username}
</span>
<span style={{
display: 'inline-block', fontSize: 12, fontWeight: 600,
padding: '3px 12px', borderRadius: 9999,
backgroundColor: 'rgba(218,165,32,0.2)', color: C.gold,
fontFamily: 'system-ui, sans-serif',
}}>
{roleLabel(profile.role)}
</span>
</div>
</div>
</div>
{/* Stats row */}
<div style={{ display: 'flex', borderBottom: '1px solid ' + C.border }}>
<div style={{ flex: 1, padding: '20px 32px', textAlign: 'center', borderRight: '1px solid ' + C.border }}>
<div style={{ fontSize: 28, fontWeight: 'bold', color: C.text, fontFamily: 'Georgia, serif' }}>
{profile.signedCount}
</div>
<div style={{ fontSize: 13, color: C.textMuted, fontFamily: 'system-ui, sans-serif', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, marginTop: 4 }}>
<FileText size={14} />
Petitions Signed
</div>
</div>
<div style={{ flex: 1, padding: '20px 32px', textAlign: 'center', borderRight: '1px solid ' + C.border }}>
<div style={{ fontSize: 28, fontWeight: 'bold', color: C.text, fontFamily: 'Georgia, serif' }}>
{profile.trackedCount}
</div>
<div style={{ fontSize: 13, color: C.textMuted, fontFamily: 'system-ui, sans-serif', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, marginTop: 4 }}>
<Bookmark size={14} />
Petitions Tracked
</div>
</div>
<div style={{ flex: 1, padding: '20px 32px', textAlign: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: C.text, fontFamily: 'system-ui, sans-serif' }}>
{formatDate(profile.createdAt)}
</div>
<div style={{ fontSize: 13, color: C.textMuted, fontFamily: 'system-ui, sans-serif', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, marginTop: 4 }}>
<Calendar size={14} />
Member Since
</div>
</div>
</div>
{/* Edit Display Name */}
<div style={{ padding: 32 }}>
<h3 style={{ fontSize: 17, fontWeight: 600, color: C.text, margin: '0 0 16px', fontFamily: 'Georgia, serif' }}>
Edit Display Name
</h3>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<input
type="text"
value={editDisplayName}
onChange={(e) => setEditDisplayName(e.target.value)}
maxLength={50}
placeholder="Your display name"
style={{
width: '100%', padding: '10px 14px', borderRadius: 8, fontSize: 15,
border: '1px solid ' + C.border, backgroundColor: '#f9fafb',
color: C.text, fontFamily: 'system-ui, sans-serif',
outline: 'none', transition: 'border-color 150ms',
boxSizing: 'border-box',
}}
onFocus={(e) => (e.currentTarget.style.borderColor = C.darkGreen)}
onBlur={(e) => (e.currentTarget.style.borderColor = C.border)}
/>
<p style={{ fontSize: 12, color: C.textMuted, margin: '6px 0 0', fontFamily: 'system-ui, sans-serif' }}>
This name will be displayed publicly on your activity.
</p>
</div>
<button
onClick={handleSaveDisplayName}
disabled={saving || !editDisplayName.trim() || editDisplayName.trim() === profile.displayName}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '10px 20px', borderRadius: 8, fontSize: 14, fontWeight: 600,
backgroundColor: C.darkGreen, color: '#fff', border: 'none',
cursor: (saving || !editDisplayName.trim() || editDisplayName.trim() === profile.displayName) ? 'default' : 'pointer',
fontFamily: 'system-ui, sans-serif', transition: 'all 150ms',
opacity: (saving || !editDisplayName.trim() || editDisplayName.trim() === profile.displayName) ? 0.5 : 1,
}}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
{/* Save feedback */}
{saveSuccess && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 12,
fontSize: 14, color: '#16a34a', fontFamily: 'system-ui, sans-serif',
}}>
<Check size={16} />
Display name updated successfully.
</div>
)}
{saveError && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 12,
fontSize: 14, color: '#dc2626', fontFamily: 'system-ui, sans-serif',
}}>
<AlertCircle size={16} />
{saveError}
</div>
)}
</div>
</div>
{/* Account Info */}
<div style={{
backgroundColor: '#fff', borderRadius: 16, padding: 32,
border: '1px solid ' + C.border,
}}>
<h3 style={{ fontSize: 17, fontWeight: 600, color: C.text, margin: '0 0 20px', fontFamily: 'Georgia, serif' }}>
Account Details
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderBottom: '1px solid #f3f4f6' }}>
<span style={{ fontSize: 14, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>Username</span>
<span style={{ fontSize: 14, fontWeight: 600, color: C.text, fontFamily: 'system-ui, monospace' }}>@{profile.username}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderBottom: '1px solid #f3f4f6' }}>
<span style={{ fontSize: 14, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>Role</span>
<span style={{
display: 'inline-block', fontSize: 13, fontWeight: 600,
padding: '4px 12px', borderRadius: 9999,
backgroundColor: rc.bg, color: rc.text,
fontFamily: 'system-ui, sans-serif',
}}>
{roleLabel(profile.role)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0' }}>
<span style={{ fontSize: 14, color: C.textMuted, fontFamily: 'system-ui, sans-serif' }}>Joined</span>
<span style={{ fontSize: 14, fontWeight: 500, color: C.text, fontFamily: 'system-ui, sans-serif' }}>{formatDate(profile.createdAt)}</span>
</div>
</div>
</div>
</div>
);
}