From 57fe9d9bf1ff2b54a0a0a6b6b3feb36ef936a4f2 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 17 Jun 2026 16:52:04 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20portal=20Sprint=207=20?= =?UTF-8?q?=E2=80=94=20Circles=20(interest/affinity=20sub-groups)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../20260617230000_add_circles/migration.sql | 31 ++++ apps/api/prisma/schema.prisma | 41 +++++ apps/api/src/app.module.ts | 2 + apps/api/src/modules/audit/audit.types.ts | 2 + .../src/modules/circles/circles.controller.ts | 46 ++++++ .../api/src/modules/circles/circles.module.ts | 12 ++ .../src/modules/circles/circles.service.ts | 145 ++++++++++++++++++ apps/api/src/modules/my/my.controller.ts | 17 ++ apps/api/src/modules/my/my.module.ts | 3 +- .../api/admin/circles/[id]/members/route.ts | 13 ++ apps/web/app/api/admin/circles/[id]/route.ts | 28 ++++ apps/web/app/api/admin/circles/route.ts | 20 +++ .../web/app/api/my/circles/[id]/join/route.ts | 13 ++ .../app/api/my/circles/[id]/leave/route.ts | 13 ++ apps/web/app/api/my/circles/route.ts | 9 ++ apps/web/app/my/circles/CircleJoinButton.tsx | 53 +++++++ apps/web/app/my/circles/page.tsx | 90 +++++++++++ apps/web/app/my/layout.tsx | 1 + 18 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260617230000_add_circles/migration.sql create mode 100644 apps/api/src/modules/circles/circles.controller.ts create mode 100644 apps/api/src/modules/circles/circles.module.ts create mode 100644 apps/api/src/modules/circles/circles.service.ts create mode 100644 apps/web/app/api/admin/circles/[id]/members/route.ts create mode 100644 apps/web/app/api/admin/circles/[id]/route.ts create mode 100644 apps/web/app/api/admin/circles/route.ts create mode 100644 apps/web/app/api/my/circles/[id]/join/route.ts create mode 100644 apps/web/app/api/my/circles/[id]/leave/route.ts create mode 100644 apps/web/app/api/my/circles/route.ts create mode 100644 apps/web/app/my/circles/CircleJoinButton.tsx create mode 100644 apps/web/app/my/circles/page.tsx diff --git a/apps/api/prisma/migrations/20260617230000_add_circles/migration.sql b/apps/api/prisma/migrations/20260617230000_add_circles/migration.sql new file mode 100644 index 0000000..49a37a1 --- /dev/null +++ b/apps/api/prisma/migrations/20260617230000_add_circles/migration.sql @@ -0,0 +1,31 @@ +CREATE TYPE "CircleRole" AS ENUM ('MEMBER', 'LEAD'); + +CREATE TABLE "Circle" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "isPublic" BOOLEAN NOT NULL DEFAULT true, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Circle_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "CircleMembership" ( + "id" TEXT NOT NULL, + "circleId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "CircleRole" NOT NULL DEFAULT 'MEMBER', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "CircleMembership_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Circle_tenantId_name_key" ON "Circle"("tenantId", "name"); +CREATE INDEX "Circle_tenantId_isPublic_idx" ON "Circle"("tenantId", "isPublic"); +CREATE UNIQUE INDEX "CircleMembership_circleId_userId_key" ON "CircleMembership"("circleId", "userId"); +CREATE INDEX "CircleMembership_userId_idx" ON "CircleMembership"("userId"); + +ALTER TABLE "Circle" ADD CONSTRAINT "Circle_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "CircleMembership" ADD CONSTRAINT "CircleMembership_circleId_fkey" FOREIGN KEY ("circleId") REFERENCES "Circle"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CircleMembership" ADD CONSTRAINT "CircleMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d0bc1dd..f216ccd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -94,6 +94,7 @@ model Tenant { threads Thread[] sevaOpportunities SevaOpportunity[] gamificationEvents GamificationEvent[] + circles Circle[] } enum AdminRole { @@ -420,6 +421,7 @@ model TowerUser { rsvps EventRsvp[] sevaEntries SevaEntry[] gamificationEvents GamificationEvent[] + circleMemberships CircleMembership[] @@unique([tenantId, phoneHash]) @@index([phoneHash]) @@ -689,3 +691,42 @@ model GamificationEvent { // One award per (user, type, ref) — prevents double-awarding the same source. @@unique([userId, type, refId]) } + +// ============================================================================ +// Circles (interest / affinity sub-groups within a chapter) +// ============================================================================ + +enum CircleRole { + MEMBER + LEAD +} + +model Circle { + id String @id @default(cuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + name String + description String? + isPublic Boolean @default(true) + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + memberships CircleMembership[] + + @@unique([tenantId, name]) + @@index([tenantId, isPublic]) +} + +model CircleMembership { + id String @id @default(cuid()) + circleId String + circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade) + userId String + user TowerUser @relation(fields: [userId], references: [id]) + role CircleRole @default(MEMBER) + createdAt DateTime @default(now()) + + @@unique([circleId, userId]) + @@index([userId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9213c6e..22f6fa3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -20,6 +20,7 @@ import { ThreadsModule } from './modules/threads/threads.module'; import { EventsModule } from './modules/events/events.module'; import { GamificationModule } from './modules/gamification/gamification.module'; import { SevaModule } from './modules/seva/seva.module'; +import { CirclesModule } from './modules/circles/circles.module'; @Module({ imports: [ @@ -44,6 +45,7 @@ import { SevaModule } from './modules/seva/seva.module'; EventsModule, GamificationModule, SevaModule, + CirclesModule, ], }) export class AppModule {} diff --git a/apps/api/src/modules/audit/audit.types.ts b/apps/api/src/modules/audit/audit.types.ts index 9e3a6ed..3ab7f29 100644 --- a/apps/api/src/modules/audit/audit.types.ts +++ b/apps/api/src/modules/audit/audit.types.ts @@ -44,6 +44,8 @@ export const AuditAction = { SEVA_CREATED: 'SEVA_CREATED', SEVA_DELETED: 'SEVA_DELETED', SEVA_COMPLETED: 'SEVA_COMPLETED', + CIRCLE_CREATED: 'CIRCLE_CREATED', + CIRCLE_DELETED: 'CIRCLE_DELETED', } as const; export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction]; diff --git a/apps/api/src/modules/circles/circles.controller.ts b/apps/api/src/modules/circles/circles.controller.ts new file mode 100644 index 0000000..2d4b114 --- /dev/null +++ b/apps/api/src/modules/circles/circles.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { CirclesService } from './circles.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { CurrentTenantContext } from '../auth/current-tenant.decorator'; +import { TenantContext } from '../../common/tenant-context'; + +@Controller('admin/circles') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('OWNER', 'ADMIN') +export class CirclesController { + constructor(private readonly service: CirclesService) {} + + @Get() + list(@CurrentTenantContext() ctx: TenantContext) { + return this.service.listAdmin(ctx.tenantId); + } + + @Post() + create( + @CurrentTenantContext() ctx: TenantContext, + @Body() body: { name: string; description?: string; isPublic?: boolean }, + ) { + return this.service.create(ctx.tenantId, ctx.adminId!, body); + } + + @Patch(':id') + update( + @CurrentTenantContext() ctx: TenantContext, + @Param('id') id: string, + @Body() body: { name?: string; description?: string; isPublic?: boolean }, + ) { + return this.service.update(ctx.tenantId, ctx.adminId!, id, body); + } + + @Delete(':id') + remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) { + return this.service.remove(ctx.tenantId, ctx.adminId!, id); + } + + @Get(':id/members') + members(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) { + return this.service.listMembers(ctx.tenantId, id); + } +} diff --git a/apps/api/src/modules/circles/circles.module.ts b/apps/api/src/modules/circles/circles.module.ts new file mode 100644 index 0000000..a33fe01 --- /dev/null +++ b/apps/api/src/modules/circles/circles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CirclesController } from './circles.controller'; +import { CirclesService } from './circles.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [CirclesController], + providers: [CirclesService], + exports: [CirclesService], +}) +export class CirclesModule {} diff --git a/apps/api/src/modules/circles/circles.service.ts b/apps/api/src/modules/circles/circles.service.ts new file mode 100644 index 0000000..93b4127 --- /dev/null +++ b/apps/api/src/modules/circles/circles.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.types'; + +@Injectable() +export class CirclesService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + // ── Admin ──────────────────────────────────────────────────────────────── + + async listAdmin(tenantId: string) { + return this.prisma.circle.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { memberships: true } } }, + }); + } + + async create(tenantId: string, adminId: string, dto: { + name: string; + description?: string; + isPublic?: boolean; + }) { + const existing = await this.prisma.circle.findFirst({ where: { tenantId, name: dto.name } }); + if (existing) throw new BadRequestException('A circle with this name already exists'); + + const circle = await this.prisma.circle.create({ + data: { + tenantId, + name: dto.name, + description: dto.description ?? null, + isPublic: dto.isPublic ?? true, + createdBy: adminId, + }, + }); + await this.audit.log({ + tenantId, + actorId: adminId, + action: AuditAction.CIRCLE_CREATED, + resourceType: 'Circle', + resourceId: circle.id, + payload: { name: circle.name }, + }); + return circle; + } + + async update(tenantId: string, adminId: string, id: string, dto: { + name?: string; + description?: string; + isPublic?: boolean; + }) { + const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } }); + if (!circle) throw new NotFoundException('Circle not found'); + return this.prisma.circle.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.isPublic !== undefined && { isPublic: dto.isPublic }), + }, + }); + } + + async remove(tenantId: string, adminId: string, id: string) { + const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } }); + if (!circle) throw new NotFoundException('Circle not found'); + await this.prisma.circle.delete({ where: { id } }); + await this.audit.log({ + tenantId, + actorId: adminId, + action: AuditAction.CIRCLE_DELETED, + resourceType: 'Circle', + resourceId: id, + }); + return { ok: true }; + } + + async listMembers(tenantId: string, circleId: string) { + const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } }); + if (!circle) throw new NotFoundException('Circle not found'); + return this.prisma.circleMembership.findMany({ + where: { circleId }, + include: { user: { select: { id: true, displayName: true, jid: true } } }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }); + } + + // ── Member ───────────────────────────────────────────────────────────────── + + async listForMember(userId: string, tenantId: string) { + const circles = await this.prisma.circle.findMany({ + where: { + tenantId, + // public circles, plus any private circle the member already belongs to + OR: [{ isPublic: true }, { memberships: { some: { userId } } }], + }, + orderBy: { name: 'asc' }, + include: { + memberships: { where: { userId }, select: { role: true } }, + _count: { select: { memberships: true } }, + }, + }); + + return circles.map((c) => ({ + id: c.id, + name: c.name, + description: c.description, + isPublic: c.isPublic, + memberCount: c._count.memberships, + isMember: c.memberships.length > 0, + myRole: c.memberships[0]?.role ?? null, + })); + } + + async join(userId: string, tenantId: string, circleId: string) { + const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } }); + if (!circle) throw new NotFoundException('Circle not found'); + if (!circle.isPublic) { + const existing = await this.prisma.circleMembership.findUnique({ + where: { circleId_userId: { circleId, userId } }, + }); + if (!existing) throw new ForbiddenException('This circle is invite-only'); + } + + const membership = await this.prisma.circleMembership.upsert({ + where: { circleId_userId: { circleId, userId } }, + create: { circleId, userId, role: 'MEMBER' }, + update: {}, + }); + return { ok: true, membershipId: membership.id }; + } + + async leave(userId: string, tenantId: string, circleId: string) { + const membership = await this.prisma.circleMembership.findFirst({ + where: { circleId, userId, circle: { tenantId } }, + }); + if (!membership) throw new NotFoundException('You are not a member of this circle'); + await this.prisma.circleMembership.delete({ where: { id: membership.id } }); + return { ok: true }; + } +} diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index 3a4226e..a2c78c0 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; import { MyService } from './my.service'; import { SevaService } from '../seva/seva.service'; +import { CirclesService } from '../circles/circles.service'; import { MemberAuth } from '../auth/member-auth.decorator'; import { CurrentMember } from '../auth/current-member.decorator'; import type { MemberJwtPayload } from '@tower/types'; @@ -26,6 +27,7 @@ export class MyController { constructor( private readonly service: MyService, private readonly seva: SevaService, + private readonly circles: CirclesService, ) {} @Get('seva') @@ -43,6 +45,21 @@ export class MyController { return this.seva.cancel(member.sub, member.tenantId, id); } + @Get('circles') + circlesList(@CurrentMember() member: MemberJwtPayload) { + return this.circles.listForMember(member.sub, member.tenantId); + } + + @Post('circles/:id/join') + circleJoin(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) { + return this.circles.join(member.sub, member.tenantId, id); + } + + @Post('circles/:id/leave') + circleLeave(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) { + return this.circles.leave(member.sub, member.tenantId, id); + } + @Get('dashboard') dashboard(@CurrentMember() member: MemberJwtPayload) { return this.service.getDashboard(member.sub, member.tenantId); diff --git a/apps/api/src/modules/my/my.module.ts b/apps/api/src/modules/my/my.module.ts index 8bc9a94..f59de79 100644 --- a/apps/api/src/modules/my/my.module.ts +++ b/apps/api/src/modules/my/my.module.ts @@ -4,9 +4,10 @@ import { MyService } from './my.service'; import { AuthModule } from '../auth/auth.module'; import { SevaModule } from '../seva/seva.module'; import { GamificationModule } from '../gamification/gamification.module'; +import { CirclesModule } from '../circles/circles.module'; @Module({ - imports: [AuthModule, SevaModule, GamificationModule], + imports: [AuthModule, SevaModule, GamificationModule, CirclesModule], controllers: [MyController], providers: [MyService], }) diff --git a/apps/web/app/api/admin/circles/[id]/members/route.ts b/apps/web/app/api/admin/circles/[id]/members/route.ts new file mode 100644 index 0000000..cbbe654 --- /dev/null +++ b/apps/web/app/api/admin/circles/[id]/members/route.ts @@ -0,0 +1,13 @@ +import { apiFetch, jsonResponse } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await apiFetch(`/admin/circles/${id}/members`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/admin/circles/[id]/route.ts b/apps/web/app/api/admin/circles/[id]/route.ts new file mode 100644 index 0000000..40e9255 --- /dev/null +++ b/apps/web/app/api/admin/circles/[id]/route.ts @@ -0,0 +1,28 @@ +import { apiFetch, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const body = await request.json(); + const res = await apiFetch(`/admin/circles/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const resBody = await res.json(); + return jsonResponse(resBody, res.status); +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await apiFetch(`/admin/circles/${id}`, { method: 'DELETE' }); + const resBody = await res.json(); + return jsonResponse(resBody, res.status); +} diff --git a/apps/web/app/api/admin/circles/route.ts b/apps/web/app/api/admin/circles/route.ts new file mode 100644 index 0000000..eed3679 --- /dev/null +++ b/apps/web/app/api/admin/circles/route.ts @@ -0,0 +1,20 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/admin/circles'); + const body = await res.json(); + return jsonResponse(body, res.status); +} + +export async function POST(request: Request): Promise { + const body = await request.json(); + const res = await apiFetch('/admin/circles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const resBody = await res.json(); + return jsonResponse(resBody, res.status); +} diff --git a/apps/web/app/api/my/circles/[id]/join/route.ts b/apps/web/app/api/my/circles/[id]/join/route.ts new file mode 100644 index 0000000..526a8d8 --- /dev/null +++ b/apps/web/app/api/my/circles/[id]/join/route.ts @@ -0,0 +1,13 @@ +import { jsonResponse, memberApiFetch } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await memberApiFetch(`/my/circles/${id}/join`, { method: 'POST' }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/circles/[id]/leave/route.ts b/apps/web/app/api/my/circles/[id]/leave/route.ts new file mode 100644 index 0000000..f641141 --- /dev/null +++ b/apps/web/app/api/my/circles/[id]/leave/route.ts @@ -0,0 +1,13 @@ +import { jsonResponse, memberApiFetch } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await memberApiFetch(`/my/circles/${id}/leave`, { method: 'POST' }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/circles/route.ts b/apps/web/app/api/my/circles/route.ts new file mode 100644 index 0000000..6f60950 --- /dev/null +++ b/apps/web/app/api/my/circles/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/circles'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/my/circles/CircleJoinButton.tsx b/apps/web/app/my/circles/CircleJoinButton.tsx new file mode 100644 index 0000000..5aa7596 --- /dev/null +++ b/apps/web/app/my/circles/CircleJoinButton.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/app/my/circles/page.tsx b/apps/web/app/my/circles/page.tsx new file mode 100644 index 0000000..425c871 --- /dev/null +++ b/apps/web/app/my/circles/page.tsx @@ -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 { + 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 ( +
+
+
+
+

{c.name}

+ {c.myRole === 'LEAD' && ( + Lead + )} + {!c.isPublic && ( + Private + )} +
+

+ {c.memberCount} member{c.memberCount !== 1 ? 's' : ''} +

+
+ +
+ {c.description &&

{c.description}

} +
+ ); +} + +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 ( +
+
+

Circles

+ ← Dashboard +
+ + {circles.length === 0 && ( +
+

No circles yet.

+

Your chapter admin will create interest circles you can join.

+
+ )} + + {mine.length > 0 && ( +
+

My circles

+
+ {mine.map((c) => )} +
+
+ )} + + {discover.length > 0 && ( +
+

Discover

+
+ {discover.map((c) => )} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx index aeffffb..3396b2c 100644 --- a/apps/web/app/my/layout.tsx +++ b/apps/web/app/my/layout.tsx @@ -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' }, ];