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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user