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>
This commit is contained in:
2026-06-17 16:52:04 +05:30
parent cd1eef0098
commit 57fe9d9bf1
18 changed files with 538 additions and 1 deletions
@@ -0,0 +1,53 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface Props {
circleId: string;
initialMember: boolean;
isPublic: boolean;
}
export function CircleJoinButton({ circleId, initialMember, isPublic }: Props) {
const router = useRouter();
const [member, setMember] = useState(initialMember);
const [loading, setLoading] = useState(false);
const act = async (action: 'join' | 'leave') => {
setLoading(true);
try {
const res = await fetch(`/api/my/circles/${circleId}/${action}`, { method: 'POST' });
if (res.ok) {
setMember(action === 'join');
router.refresh();
}
} finally {
setLoading(false);
}
};
if (member) {
return (
<button
type="button"
disabled={loading}
onClick={() => act('leave')}
className="text-xs rounded-full px-3 py-1.5 font-medium border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{loading ? '…' : 'Joined · Leave'}
</button>
);
}
return (
<button
type="button"
disabled={loading || !isPublic}
onClick={() => act('join')}
className="text-xs rounded-full px-4 py-1.5 font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{!isPublic ? 'Invite only' : loading ? '…' : 'Join'}
</button>
);
}
+90
View File
@@ -0,0 +1,90 @@
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>
);
}
+1
View File
@@ -7,6 +7,7 @@ const NAV = [
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },
{ href: '/my/circles', label: 'Circles' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];