57fe9d9bf1
- 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>
91 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
}
|