From 64ae34e9c940365bd249ef12199f40cbd9fd65cf Mon Sep 17 00:00:00 2001 From: root Date: Mon, 15 Jun 2026 12:25:52 +0200 Subject: [PATCH] feat: private forum groups - Group/GroupMember models, group CRUD API, invite codes, private threads, groups UI pages --- prisma/schema.prisma | 31 +- src/app/api/forum/threads/route.ts | 49 +- src/app/api/groups/[id]/invite/route.ts | 51 ++ src/app/api/groups/[id]/join/route.ts | 80 +++ .../groups/[id]/members/[memberId]/route.ts | 73 +++ src/app/api/groups/[id]/route.ts | 59 ++ src/app/api/groups/route.ts | 120 ++++ src/app/forum/page.tsx | 98 +++- src/app/groups/[id]/page.tsx | 455 +++++++++++++++ src/app/groups/page.tsx | 535 ++++++++++++++++++ 10 files changed, 1545 insertions(+), 6 deletions(-) create mode 100644 src/app/api/groups/[id]/invite/route.ts create mode 100644 src/app/api/groups/[id]/join/route.ts create mode 100644 src/app/api/groups/[id]/members/[memberId]/route.ts create mode 100644 src/app/api/groups/[id]/route.ts create mode 100644 src/app/api/groups/route.ts create mode 100644 src/app/groups/[id]/page.tsx create mode 100644 src/app/groups/page.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7b58e32..01f9c33 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,8 @@ model User { cashouts CashoutRequest[] halalBookmarks HalalBookmark[] halalUsage HalalUsage? + ownedGroups Group[] @relation("GroupOwner") + groupMemberships GroupMember[] forumThreads ForumThread[] forumPosts ForumPost[] chatHistory ChatHistory[] @@ -158,7 +160,34 @@ model ForumThread { shariahFlags String? createdAt DateTime @default(now()) - posts ForumPost[] + posts ForumPost[] + group Group? @relation(fields: [groupId], references: [id]) + groupId String? +} + +model Group { + id String @id @default(cuid()) + name String + description String? + ownerId String + owner User @relation("GroupOwner", fields: [ownerId], references: [id]) + inviteCode String @unique + createdAt DateTime @default(now()) + + members GroupMember[] + threads ForumThread[] +} + +model GroupMember { + id String @id @default(cuid()) + groupId String + group Group @relation(fields: [groupId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + role String @default("member") + joinedAt DateTime @default(now()) + + @@unique([groupId, userId]) } model ForumPost { diff --git a/src/app/api/forum/threads/route.ts b/src/app/api/forum/threads/route.ts index cdca20f..f9bfe2b 100644 --- a/src/app/api/forum/threads/route.ts +++ b/src/app/api/forum/threads/route.ts @@ -14,8 +14,28 @@ export async function GET(request: NextRequest) { where.categoryId = categoryId; } + // For group-scoped visibility: if user is authenticated, find which group IDs + // they are a member of, so we can include public threads (groupId: null) plus + // private threads where they are a member. + const auth = await requireAuth(request); + let userGroupIds: string[] = []; + + if (auth) { + const memberships = await prisma.groupMember.findMany({ + where: { userId: auth.userId }, + select: { groupId: true }, + }); + userGroupIds = memberships.map((m) => m.groupId); + } + const threads = await prisma.forumThread.findMany({ - where, + where: { + ...where, + OR: [ + { groupId: null }, + { groupId: { in: userGroupIds } }, + ], + }, include: { author: { select: { id: true, name: true, isPremium: true, isPro: true }, @@ -23,6 +43,9 @@ export async function GET(request: NextRequest) { category: { select: { id: true, name: true, icon: true }, }, + group: { + select: { id: true, name: true }, + }, _count: { select: { posts: true }, }, @@ -65,7 +88,7 @@ export async function POST(request: NextRequest) { ); } - const { title, content, categoryId } = await request.json(); + const { title, content, categoryId, groupId } = await request.json(); if (!title || !content || !categoryId) { return NextResponse.json( @@ -86,13 +109,30 @@ export async function POST(request: NextRequest) { ); } + // If groupId is provided, verify user is a member of that group + if (groupId) { + const membership = await prisma.groupMember.findUnique({ + where: { + groupId_userId: { groupId, userId: user.id }, + }, + }); + + if (!membership) { + return NextResponse.json( + { error: "You are not a member of this group" }, + { status: 403 } + ); + } + } + const thread = await prisma.forumThread.create({ data: { title, content, categoryId, authorId: user.id, - shariahStatus: "pending", + groupId: groupId || null, + shariahStatus: groupId ? "private" : "pending", }, include: { author: { @@ -101,6 +141,9 @@ export async function POST(request: NextRequest) { category: { select: { id: true, name: true, icon: true }, }, + group: { + select: { id: true, name: true }, + }, _count: { select: { posts: true }, }, diff --git a/src/app/api/groups/[id]/invite/route.ts b/src/app/api/groups/[id]/invite/route.ts new file mode 100644 index 0000000..bc522a3 --- /dev/null +++ b/src/app/api/groups/[id]/invite/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/groups/[id]/invite — get invite code (owner only) +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const group = await prisma.group.findUnique({ + where: { id }, + select: { + id: true, + name: true, + ownerId: true, + inviteCode: true, + }, + }); + + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + + // Only the owner can view the invite code + if (group.ownerId !== auth.userId) { + return NextResponse.json( + { error: "Only the group owner can view the invite code" }, + { status: 403 } + ); + } + + return NextResponse.json({ + groupId: group.id, + inviteCode: group.inviteCode, + }); + } catch (error) { + console.error("GET /api/groups/[id]/invite error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/groups/[id]/join/route.ts b/src/app/api/groups/[id]/join/route.ts new file mode 100644 index 0000000..2e9eddf --- /dev/null +++ b/src/app/api/groups/[id]/join/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// POST /api/groups/[id]/join — join by invite code +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const { inviteCode } = await request.json(); + + if (!inviteCode || typeof inviteCode !== "string") { + return NextResponse.json( + { error: "Missing required field: inviteCode" }, + { status: 400 } + ); + } + + // Find the group and verify invite code + const group = await prisma.group.findUnique({ + where: { id }, + include: { + members: { + where: { userId: auth.userId }, + }, + }, + }); + + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + + if (group.inviteCode !== inviteCode) { + return NextResponse.json( + { error: "Invalid invite code" }, + { status: 403 } + ); + } + + // Check if user is already a member + if (group.members.length > 0) { + return NextResponse.json( + { error: "You are already a member of this group" }, + { status: 409 } + ); + } + + // Add user as member + const membership = await prisma.groupMember.create({ + data: { + groupId: id, + userId: auth.userId, + role: "member", + }, + include: { + user: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + group: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json({ membership }, { status: 201 }); + } catch (error) { + console.error("POST /api/groups/[id]/join error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/groups/[id]/members/[memberId]/route.ts b/src/app/api/groups/[id]/members/[memberId]/route.ts new file mode 100644 index 0000000..eb5616b --- /dev/null +++ b/src/app/api/groups/[id]/members/[memberId]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// DELETE /api/groups/[id]/members/[memberId] — remove member (owner only) +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; memberId: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id, memberId } = await params; + + // Find the group and verify the requester is the owner + const group = await prisma.group.findUnique({ + where: { id }, + }); + + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + + if (group.ownerId !== auth.userId) { + return NextResponse.json( + { error: "Only the group owner can remove members" }, + { status: 403 } + ); + } + + // Find the membership to remove + const membership = await prisma.groupMember.findUnique({ + where: { id: memberId }, + }); + + if (!membership) { + return NextResponse.json( + { error: "Member not found" }, + { status: 404 } + ); + } + + if (membership.groupId !== id) { + return NextResponse.json( + { error: "Member does not belong to this group" }, + { status: 400 } + ); + } + + // Cannot remove the owner + if (membership.role === "owner") { + return NextResponse.json( + { error: "Cannot remove the group owner" }, + { status: 403 } + ); + } + + await prisma.groupMember.delete({ + where: { id: memberId }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("DELETE /api/groups/[id]/members/[memberId] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/groups/[id]/route.ts b/src/app/api/groups/[id]/route.ts new file mode 100644 index 0000000..d3942a5 --- /dev/null +++ b/src/app/api/groups/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/groups/[id] — group details + member list +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const group = await prisma.group.findUnique({ + where: { id }, + include: { + owner: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + members: { + include: { + user: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + }, + orderBy: { joinedAt: "asc" }, + }, + _count: { + select: { members: true, threads: true }, + }, + }, + }); + + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + + // Verify user is a member of this group + const isMember = group.members.some((m) => m.userId === auth.userId); + if (!isMember) { + return NextResponse.json( + { error: "You are not a member of this group" }, + { status: 403 } + ); + } + + return NextResponse.json({ group }); + } catch (error) { + console.error("GET /api/groups/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/groups/route.ts b/src/app/api/groups/route.ts new file mode 100644 index 0000000..66c25ed --- /dev/null +++ b/src/app/api/groups/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import { getUserTier, FORUM_LIMITS } from "@/lib/tiers"; +import crypto from "crypto"; + +function generateInviteCode(): string { + return crypto.randomBytes(4).toString("hex").slice(0, 8); +} + +// POST /api/groups — create a group (premium only) +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ where: { id: auth.userId } }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const tier = getUserTier(user.isPremium, user.isPro); + const limits = FORUM_LIMITS[tier]; + + if (!limits.canPost) { + return NextResponse.json( + { error: "Your tier does not support creating groups. Upgrade to Premium or Pro to create groups." }, + { status: 403 } + ); + } + + const { name, description } = await request.json(); + + if (!name || typeof name !== "string" || name.trim().length === 0) { + return NextResponse.json( + { error: "Missing required field: name" }, + { status: 400 } + ); + } + + const inviteCode = generateInviteCode(); + + const group = await prisma.group.create({ + data: { + name: name.trim(), + description: description?.trim() || null, + ownerId: user.id, + inviteCode, + members: { + create: { + userId: user.id, + role: "owner", + }, + }, + }, + include: { + owner: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + members: { + include: { + user: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + }, + }, + _count: { + select: { members: true, threads: true }, + }, + }, + }); + + return NextResponse.json({ group }, { status: 201 }); + } catch (error) { + console.error("POST /api/groups error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// GET /api/groups — list groups where current user is a member +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const groups = await prisma.group.findMany({ + where: { + members: { + some: { + userId: auth.userId, + }, + }, + }, + include: { + owner: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + _count: { + select: { members: true, threads: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ groups }); + } catch (error) { + console.error("GET /api/groups error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/forum/page.tsx b/src/app/forum/page.tsx index c6f067e..09bbf1d 100644 --- a/src/app/forum/page.tsx +++ b/src/app/forum/page.tsx @@ -16,6 +16,7 @@ import { CheckCircle, Loader2, Lock, + Users, MessageCircle, FileText, } from "lucide-react"; @@ -34,6 +35,7 @@ interface Thread { title: string; content: string; categoryId: string; + groupId?: string | null; pinned: boolean; shariahStatus: "pending" | "approved" | "rejected"; shariahFlags?: string; @@ -52,6 +54,20 @@ interface Thread { _count: { posts: number; }; + group?: { + id: string; + name: string; + } | null; +} + +interface Group { + id: string; + name: string; + description?: string; + _count: { + members: number; + threads: number; + }; } const SHARIAH_LABELS: Record = { @@ -66,6 +82,7 @@ export default function ForumPage() { const [categories, setCategories] = useState([]); const [threads, setThreads] = useState([]); + const [groups, setGroups] = useState([]); const [loadingData, setLoadingData] = useState(true); const [error, setError] = useState(null); @@ -79,6 +96,7 @@ export default function ForumPage() { title: "", content: "", categoryId: "", + groupId: "", }); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); @@ -104,6 +122,23 @@ export default function ForumPage() { } }, []); + const fetchGroups = useCallback(async () => { + if (!canPost) return; + try { + const res = await fetch("/mobile/api/groups", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const data = await res.json(); + if (res.ok) { + setGroups(data.groups || []); + } + } catch { + // ignore + } + }, [token, canPost]); + const fetchThreads = useCallback(async () => { setLoadingData(true); setError(null); @@ -112,7 +147,7 @@ export default function ForumPage() { if (activeCategory) params.set("categoryId", activeCategory); if (searchQuery.trim()) params.set("search", searchQuery.trim()); - const res = await fetch(`/api/forum/threads?${params.toString()}`); + const res = await fetch(`/mobile/api/forum/threads?${params.toString()}`); const data = await res.json(); if (res.ok) { setThreads(data.threads); @@ -145,6 +180,12 @@ export default function ForumPage() { } }, [token, fetchThreads]); + useEffect(() => { + if (token) { + fetchGroups(); + } + }, [token, fetchGroups]); + // Debounce search useEffect(() => { const timer = setTimeout(() => { @@ -169,13 +210,14 @@ export default function ForumPage() { title: createForm.title, content: createForm.content, categoryId: createForm.categoryId, + ...(createForm.groupId ? { groupId: createForm.groupId } : {}), }), }); const data = await res.json(); if (res.ok) { setShowCreateModal(false); - setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "" }); + setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "", groupId: "" }); fetchThreads(); } else { setCreateError(data.error || "Failed to create thread"); @@ -226,6 +268,13 @@ export default function ForumPage() {
+ {canPost && ( +

+ {group ? group.name : "Group"} +

+
+ + + {loadingData ? ( +
+ +

Loading group...

+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : !group ? ( +
+

Group not found

+ +
+ ) : ( + <> + {/* Group info card */} +
+
+
+
+ + {group.name.charAt(0).toUpperCase()} + +
+
+

{group.name}

+ {group.description && ( +

+ {group.description} +

+ )} +
+
+ + {group._count.members} members +
+ · +
+ + {group._count.threads} threads +
+ · +
+ + {timeAgo(group.createdAt)} +
+
+
+
+ + {/* Owner info */} +
+ + Created by + + {group.owner.name} + +
+
+
+ + {/* Invite code section (owner only) */} + {isOwner && ( +
+
+
+ + Invite Code + + + Share to invite members + +
+ {loadingInvite ? ( +
+ +
+ ) : inviteCode ? ( +
+
+ + {inviteCode} + +
+ +
+ ) : ( +

+ Failed to load invite code. +

+ )} +
+
+ )} + + {/* Members section */} +
+
+
+ + + Members ({group.members.length}) + +
+
+ +
+ {group.members.map((member) => { + const RoleIcon = ROLE_ICONS[member.role] || null; + const canRemove = + isOwner && + member.role !== "owner" && + member.userId !== user.id; + + return ( +
+
+
+ {member.user.name.charAt(0).toUpperCase()} +
+
+
+ + {member.user.name} + + {member.user.isPro && ( + + )} +
+
+ {RoleIcon && ( +
+ + {member.role} +
+ )} + {member.role === "member" && ( + Member + )} +
+
+ {canRemove && ( + + )} +
+
+ ); + })} +
+
+ + {/* View threads button */} +
+ +
+ + )} + + ); +} + +function MessageCircle(props: any) { + return ( + + + + ); +} diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx new file mode 100644 index 0000000..10c9371 --- /dev/null +++ b/src/app/groups/page.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter } from "next/navigation"; +import { + Lock, + Users, + Plus, + X, + Crown, + Loader2, + AlertTriangle, + ChevronRight, +} from "lucide-react"; + +interface GroupMember { + id: string; + userId: string; + role: "owner" | "admin" | "member"; + user: { + id: string; + name: string; + isPremium: boolean; + isPro: boolean; + }; +} + +interface Group { + id: string; + name: string; + description?: string; + inviteCode: string; + createdAt: string; + ownerId: string; + owner: { + id: string; + name: string; + isPremium: boolean; + isPro: boolean; + }; + members: GroupMember[]; + _count: { + members: number; + threads: number; + }; +} + +export default function GroupsPage() { + const { user, token, loading } = useAuth(); + const router = useRouter(); + + const [groups, setGroups] = useState([]); + const [loadingData, setLoadingData] = useState(true); + const [error, setError] = useState(null); + + // Create group modal + const [showCreateModal, setShowCreateModal] = useState(false); + const [createName, setCreateName] = useState(""); + const [createDescription, setCreateDescription] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + // Join group modal + const [showJoinModal, setShowJoinModal] = useState(false); + const [joinCode, setJoinCode] = useState(""); + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + const [joinSuccess, setJoinSuccess] = useState(null); + + const canCreateGroup = user?.isPremium || user?.isPro; + + const fetchGroups = useCallback(async () => { + setLoadingData(true); + setError(null); + try { + const res = await fetch("/mobile/api/groups", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const data = await res.json(); + if (res.ok) { + setGroups(data.groups || []); + } else { + setError(data.error || "Failed to load groups"); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoadingData(false); + } + }, [token]); + + useEffect(() => { + if (!loading && !token) { + router.push("/login"); + return; + } + }, [loading, token, router]); + + useEffect(() => { + if (token) { + fetchGroups(); + } + }, [token, fetchGroups]); + + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + setCreateError(null); + setCreating(true); + + try { + const res = await fetch("/mobile/api/groups", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + name: createName.trim(), + description: createDescription.trim() || undefined, + }), + }); + + const data = await res.json(); + if (res.ok) { + setShowCreateModal(false); + setCreateName(""); + setCreateDescription(""); + fetchGroups(); + } else { + setCreateError(data.error || "Failed to create group"); + } + } catch { + setCreateError("Network error. Please try again."); + } finally { + setCreating(false); + } + }; + + const handleJoinGroup = async (e: React.FormEvent) => { + e.preventDefault(); + setJoinError(null); + setJoinSuccess(null); + setJoining(true); + + try { + const res = await fetch("/mobile/api/groups/join", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + inviteCode: joinCode.trim(), + }), + }); + + const data = await res.json(); + if (res.ok) { + setJoinSuccess("Joined group successfully!"); + setJoinCode(""); + setTimeout(() => { + setShowJoinModal(false); + setJoinSuccess(null); + fetchGroups(); + }, 1200); + } else { + setJoinError(data.error || "Failed to join group"); + } + } catch { + setJoinError("Network error. Please try again."); + } finally { + setJoining(false); + } + }; + + function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffSec = Math.floor((now - then) / 1000); + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return new Date(dateStr).toLocaleDateString(); + } + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

Groups

+

Private forum groups

+
+
+
+ + {canCreateGroup && ( + + )} +
+
+
+ + {/* Premium gating banner for free users */} + {!canCreateGroup && ( +
+
+ +
+

+ Free members can browse.{" "} + Premium or{" "} + Pro required to create groups. +

+
+ +
+
+ )} + + {/* Groups list */} +
+ {loadingData ? ( +
+ +

Loading groups...

+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : groups.length === 0 ? ( +
+
+ +
+

+ No groups yet +

+

+ {canCreateGroup + ? "Create a private group to discuss topics with select members." + : "Join a private group using an invite code."} +

+ {canCreateGroup && ( + + )} +
+ ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} +
+ + {/* Create group modal */} + {showCreateModal && ( +
+
setShowCreateModal(false)} + /> +
+
+
+ +

Create Group

+
+ +
+ +
+
+ + setCreateName(e.target.value)} + placeholder="e.g., Book Club, Quran Study" + required + maxLength={100} + className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition" + /> +
+ +
+ +