566690bd04
- Add avatar, hometown, currentLocation, interests, language, digestPreference, directoryVisible fields to TowerUser - Migration: 20260617200000_add_tower_user_profile_fields - GET /my/dashboard API endpoint with stats (groups, messages, consents) - BFF route /api/my/dashboard - MemberShell 3-column layout (220px nav + flex-1 main + 280px rail) - Dashboard page: hero card, stat cards, group list, interests chips - Sidebar returns null for /my paths (MemberShell owns the nav) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
5.8 KiB
TypeScript
170 lines
5.8 KiB
TypeScript
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
|
|
import Link from 'next/link';
|
|
|
|
interface DashboardData {
|
|
profile: {
|
|
id: string;
|
|
jid: string;
|
|
displayName: string | null;
|
|
avatar: string | null;
|
|
hometown: string | null;
|
|
currentLocation: string | null;
|
|
interests: string[];
|
|
language: string;
|
|
digestPreference: string;
|
|
directoryVisible: boolean;
|
|
createdAt: string;
|
|
};
|
|
stats: {
|
|
groupsJoined: number;
|
|
messagesContributed: number;
|
|
activeConsents: number;
|
|
};
|
|
groups: Array<{
|
|
id: string;
|
|
name: string;
|
|
joinedAt: string;
|
|
consentStatus: string;
|
|
}>;
|
|
}
|
|
|
|
async function fetchDashboard(token: string): Promise<DashboardData | null> {
|
|
const res = await fetch(`${getApiBaseUrl()}/my/dashboard`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}).catch(() => null);
|
|
if (!res || !res.ok) return null;
|
|
return (await res.json()) as DashboardData;
|
|
}
|
|
|
|
function StatCard({ label, value }: { label: string; value: number }) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col gap-1">
|
|
<span className="text-2xl font-bold text-gray-900">{value}</span>
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default async function DashboardPage() {
|
|
const token = await getMemberToken();
|
|
if (!token) return null;
|
|
|
|
const data = await fetchDashboard(token);
|
|
|
|
if (!data) {
|
|
return (
|
|
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
|
|
<h1 className="text-lg font-semibold text-red-800">Could not load dashboard</h1>
|
|
<p className="text-sm text-red-700 mt-1">Your session may have expired. Please sign out and onboard again.</p>
|
|
<form method="POST" action="/api/my/logout" className="mt-4">
|
|
<button type="submit" className="text-sm text-blue-600 underline">Sign out</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { profile, stats, groups } = data;
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto space-y-8">
|
|
{/* Hero */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 flex items-center gap-5">
|
|
<div className="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center text-xl font-bold text-indigo-600 shrink-0">
|
|
{profile.displayName ? profile.displayName.charAt(0).toUpperCase() : '?'}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="text-xl font-semibold text-gray-900 truncate">
|
|
{profile.displayName ?? 'Community Member'}
|
|
</h1>
|
|
<p className="text-sm text-gray-500 truncate">{profile.jid}</p>
|
|
{(profile.hometown || profile.currentLocation) && (
|
|
<p className="text-sm text-gray-400 mt-0.5">
|
|
{[profile.hometown, profile.currentLocation].filter(Boolean).join(' · ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Link
|
|
href="/my/settings"
|
|
className="ml-auto text-sm text-indigo-600 hover:underline shrink-0"
|
|
>
|
|
Edit profile
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<StatCard label="Groups joined" value={stats.groupsJoined} />
|
|
<StatCard label="Messages shared" value={stats.messagesContributed} />
|
|
<StatCard label="Active consents" value={stats.activeConsents} />
|
|
</div>
|
|
|
|
{/* Groups */}
|
|
{groups.length > 0 && (
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
|
Your groups
|
|
</h2>
|
|
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
|
{groups.map((g) => (
|
|
<div key={g.id} className="flex items-center justify-between px-5 py-4">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">{g.name}</p>
|
|
<p className="text-xs text-gray-400">
|
|
Joined {new Date(g.joinedAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<span
|
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
g.consentStatus === 'GRANTED'
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-gray-100 text-gray-500'
|
|
}`}
|
|
>
|
|
{g.consentStatus === 'GRANTED' ? 'Active' : 'Revoked'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 text-right">
|
|
<Link href="/my/groups" className="text-sm text-indigo-600 hover:underline">
|
|
Manage all groups →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{groups.length === 0 && (
|
|
<div className="text-center py-12 text-gray-400 text-sm">
|
|
<p>No group memberships yet.</p>
|
|
<p className="mt-1">An admin will send you an onboarding link when you are added to a group.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Interests */}
|
|
{profile.interests.length > 0 && (
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
|
Interests
|
|
</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{profile.interests.map((interest) => (
|
|
<span
|
|
key={interest}
|
|
className="text-xs bg-indigo-50 text-indigo-700 rounded-full px-3 py-1"
|
|
>
|
|
{interest}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Member since */}
|
|
<p className="text-xs text-gray-400 text-center">
|
|
Member since {new Date(profile.createdAt).toLocaleDateString('en-IN', { year: 'numeric', month: 'long', day: 'numeric' })}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|