Files
maaz519 57fe9d9bf1 feat: member portal Sprint 7 — Circles (interest/affinity sub-groups)
- Circle + CircleMembership models + migration (CircleRole: MEMBER/LEAD, public/invite-only)
- CirclesModule: admin CRUD + member list; unique circle name per tenant
- Member endpoints in MyController: GET /my/circles, POST /my/circles/:id/join|leave
- listForMember returns public circles + private ones the member belongs to, with isMember/myRole/memberCount
- join() enforces invite-only; leave() removes membership
- /my/circles page: "My circles" + "Discover" split, join/leave button, LEAD + Private badges
- Member + admin BFF routes; Circles nav item
- Register CirclesModule; add CIRCLE_CREATED/DELETED audit actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:52:04 +05:30

91 lines
3.2 KiB
TypeScript

import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { CircleJoinButton } from './CircleJoinButton';
import Link from 'next/link';
interface CircleItem {
id: string;
name: string;
description: string | null;
isPublic: boolean;
memberCount: number;
isMember: boolean;
myRole: 'MEMBER' | 'LEAD' | null;
}
async function fetchCircles(token: string): Promise<CircleItem[]> {
const res = await fetch(`${getApiBaseUrl()}/my/circles`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return [];
return (await res.json()) as CircleItem[];
}
function CircleCard({ c }: { c: CircleItem }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900">{c.name}</p>
{c.myRole === 'LEAD' && (
<span className="text-[10px] bg-amber-100 text-amber-700 rounded-full px-2 py-0.5 font-medium uppercase tracking-wide">Lead</span>
)}
{!c.isPublic && (
<span className="text-[10px] bg-gray-100 text-gray-500 rounded-full px-2 py-0.5 font-medium">Private</span>
)}
</div>
<p className="text-xs text-gray-400 mt-0.5">
{c.memberCount} member{c.memberCount !== 1 ? 's' : ''}
</p>
</div>
<CircleJoinButton circleId={c.id} initialMember={c.isMember} isPublic={c.isPublic} />
</div>
{c.description && <p className="text-sm text-gray-600 mt-2 leading-relaxed">{c.description}</p>}
</div>
);
}
export default async function CirclesPage() {
const token = await getMemberToken();
if (!token) return null;
const circles = await fetchCircles(token);
const mine = circles.filter((c) => c.isMember);
const discover = circles.filter((c) => !c.isMember);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900">Circles</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
{circles.length === 0 && (
<div className="text-center py-16 text-gray-400">
<p className="text-sm">No circles yet.</p>
<p className="text-xs mt-1">Your chapter admin will create interest circles you can join.</p>
</div>
)}
{mine.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">My circles</h2>
<div className="space-y-3">
{mine.map((c) => <CircleCard key={c.id} c={c} />)}
</div>
</section>
)}
{discover.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Discover</h2>
<div className="space-y-3">
{discover.map((c) => <CircleCard key={c.id} c={c} />)}
</div>
</section>
)}
</div>
);
}