feat: member portal Sprint 1 — dashboard with MemberShell layout and TowerUser profile fields

- 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>
This commit is contained in:
2026-06-17 15:44:58 +05:30
parent 622737fdca
commit 566690bd04
8 changed files with 283 additions and 75 deletions
+63
View File
@@ -0,0 +1,63 @@
import Link from 'next/link';
import { getMemberToken } from '../_lib/api';
import { redirect } from 'next/navigation';
const NAV = [
{ href: '/my', label: 'Dashboard' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];
export default async function MemberLayout({ children }: { children: React.ReactNode }) {
const token = await getMemberToken();
if (!token) redirect('/onboard');
return (
<div className="flex h-full -m-6 min-h-screen">
{/* Left sidebar */}
<nav className="w-[220px] shrink-0 bg-white border-r border-gray-200 flex flex-col p-4">
<span className="font-bold text-base mb-6 px-2">TOWER</span>
<div className="flex flex-col gap-1 flex-1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
{item.label}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-3 mt-3">
<p className="px-3 text-xs text-gray-400 uppercase tracking-wide">Member portal</p>
</div>
</nav>
{/* Main content */}
<main className="flex-1 overflow-auto p-6 bg-gray-50">
{children}
</main>
{/* Right activity rail */}
<aside className="w-[280px] shrink-0 bg-white border-l border-gray-200 p-4 hidden lg:block">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-4">Quick actions</p>
<div className="flex flex-col gap-2">
<Link
href="/my/settings"
className="text-sm text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md hover:bg-gray-50"
>
Privacy settings
</Link>
<form method="POST" action="/api/my/logout">
<button
type="submit"
className="w-full text-left text-sm text-red-500 hover:text-red-700 px-3 py-2 rounded-md hover:bg-red-50"
>
Sign out
</button>
</form>
</div>
</aside>
</div>
);
}
+143 -48
View File
@@ -1,74 +1,169 @@
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
import Link from 'next/link';
interface MemberProfile {
id: string;
tenantId: string;
jid: string;
displayName: string | null;
createdAt: string;
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 fetchProfile(token: string): Promise<MemberProfile | null> {
const res = await fetch(`${getApiBaseUrl()}/my/profile`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
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 MemberProfile;
return (await res.json()) as DashboardData;
}
export default async function MyPage() {
const token = await getMemberToken();
if (!token) {
return (
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-yellow-200 bg-yellow-50">
<h1 className="text-lg font-semibold text-yellow-800">Member portal</h1>
<p className="text-sm text-yellow-700">
No member session. Complete onboarding via the link a group admin sent you.
</p>
</div>
);
}
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>
);
}
const profile = await fetchProfile(token);
if (!profile) {
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">Couldn&apos;t load your account</h1>
<p className="text-sm text-red-700">
Your session may have expired. Please complete onboarding again.
</p>
<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>
<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 mt-12 p-6 rounded-lg border border-gray-200 bg-white">
<h1 className="text-xl font-semibold mb-4">Your account</h1>
<dl className="text-sm space-y-1 mb-6">
<div>
<dt className="inline font-medium">Display name: </dt>
<dd className="inline">{profile.displayName ?? '—'}</dd>
<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>
<dt className="inline font-medium">JID: </dt>
<dd className="inline">{profile.jid}</dd>
<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>
<div>
<dt className="inline font-medium">Joined: </dt>
<dd className="inline">{new Date(profile.createdAt).toLocaleDateString()}</dd>
</div>
</dl>
<div className="flex flex-col gap-2 text-sm">
<Link href="/my/groups" className="text-blue-600 underline">Manage your groups </Link>
<Link href="/my/settings" className="text-blue-600 underline">Privacy &amp; account settings </Link>
<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>
);
}