feat: private forum groups - Group/GroupMember models, group CRUD API, invite codes, private threads, groups UI pages

This commit is contained in:
root
2026-06-15 12:25:52 +02:00
parent 947fe3b66d
commit 64ae34e9c9
10 changed files with 1545 additions and 6 deletions
+455
View File
@@ -0,0 +1,455 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useParams } from "next/navigation";
import {
ArrowLeft,
Users,
Plus,
Copy,
Check,
X,
Crown,
Shield,
Loader2,
AlertTriangle,
Clock,
UserMinus,
} from "lucide-react";
interface GroupMember {
id: string;
userId: string;
role: "owner" | "admin" | "member";
user: {
id: string;
name: string;
isPremium: boolean;
isPro: boolean;
};
}
interface GroupDetail {
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;
};
}
const ROLE_ICONS: Record<string, any> = {
owner: Crown,
admin: Shield,
};
const ROLE_COLORS: Record<string, string> = {
owner: "text-[#D4AF37]",
admin: "text-blue-400",
member: "text-gray-500",
};
export default function GroupDetailPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const params = useParams();
const groupId = params.id as string;
const [group, setGroup] = useState<GroupDetail | null>(null);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
// Invite code
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [loadingInvite, setLoadingInvite] = useState(false);
const [copied, setCopied] = useState(false);
// Remove member
const [removingMember, setRemovingMember] = useState<string | null>(null);
const isOwner = group?.ownerId === user?.id;
const fetchGroup = useCallback(async () => {
setLoadingData(true);
setError(null);
try {
const res = await fetch(`/mobile/api/groups/${groupId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await res.json();
if (res.ok) {
setGroup(data);
} else {
setError(data.error || "Failed to load group");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingData(false);
}
}, [token, groupId]);
const fetchInviteCode = useCallback(async () => {
setLoadingInvite(true);
try {
const res = await fetch(`/mobile/api/groups/${groupId}/invite`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await res.json();
if (res.ok) {
setInviteCode(data.inviteCode);
}
} catch {
// silent
} finally {
setLoadingInvite(false);
}
}, [token, groupId]);
useEffect(() => {
if (!loading && !token) {
router.push("/login");
return;
}
}, [loading, token, router]);
useEffect(() => {
if (token && groupId) {
fetchGroup();
}
}, [token, groupId, fetchGroup]);
useEffect(() => {
if (isOwner && groupId && token) {
fetchInviteCode();
}
}, [isOwner, groupId, token, fetchInviteCode]);
const handleCopyCode = async () => {
if (inviteCode) {
try {
await navigator.clipboard.writeText(inviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// fallback
const input = document.createElement("input");
input.value = inviteCode;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
};
const handleRemoveMember = async (memberId: string) => {
if (!confirm("Remove this member from the group?")) return;
setRemovingMember(memberId);
try {
const res = await fetch(
`/mobile/api/groups/${groupId}/members/${memberId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (res.ok) {
fetchGroup();
}
} catch {
// silent
} finally {
setRemovingMember(null);
}
};
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 (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
{/* Sticky header */}
<div className="sticky top-0 z-20 bg-[#0a0a0f]/90 backdrop-blur-lg border-b border-gray-800/40">
<div className="flex items-center gap-3 px-4 h-12">
<button
onClick={() => router.push("/groups")}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<ArrowLeft size={16} className="text-gray-400" />
</button>
<h1 className="text-sm font-semibold text-white truncate">
{group ? group.name : "Group"}
</h1>
</div>
</div>
{loadingData ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
<p className="text-sm text-gray-500">Loading group...</p>
</div>
) : error ? (
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={() => { fetchGroup(); }}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
) : !group ? (
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
<p className="text-sm text-gray-400">Group not found</p>
<button
onClick={() => router.push("/groups")}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Back to groups
</button>
</div>
) : (
<>
{/* Group info card */}
<div className="px-4 pt-4 pb-2">
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center shrink-0">
<span className="text-2xl font-bold text-[#D4AF37]">
{group.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-white">{group.name}</h2>
{group.description && (
<p className="text-xs text-gray-500 mt-0.5">
{group.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-gray-600">
<div className="flex items-center gap-1">
<Users size={11} />
<span>{group._count.members} members</span>
</div>
<span className="text-gray-700">·</span>
<div className="flex items-center gap-1">
<MessageCircle size={11} />
<span>{group._count.threads} threads</span>
</div>
<span className="text-gray-700">·</span>
<div className="flex items-center gap-0.5">
<Clock size={9} />
<span>{timeAgo(group.createdAt)}</span>
</div>
</div>
</div>
</div>
{/* Owner info */}
<div className="flex items-center gap-2 bg-[#0a0a0f] rounded-xl px-3.5 py-2.5">
<Crown size={14} className="text-[#D4AF37] shrink-0" />
<span className="text-xs text-gray-500">Created by</span>
<span className={`text-xs font-semibold ${
group.owner.isPro
? "text-purple-400"
: group.owner.isPremium
? "text-[#D4AF37]"
: "text-gray-300"
}`}>
{group.owner.name}
</span>
</div>
</div>
</div>
{/* Invite code section (owner only) */}
{isOwner && (
<div className="px-4 mb-3">
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-gray-500">
Invite Code
</span>
<span className="text-[10px] text-amber-400/70 bg-amber-400/10 border border-amber-400/20 rounded-md px-1.5 py-0.5">
Share to invite members
</span>
</div>
{loadingInvite ? (
<div className="flex items-center justify-center py-3">
<Loader2 size={16} className="text-[#D4AF37] animate-spin" />
</div>
) : inviteCode ? (
<div className="flex items-center gap-2">
<div className="flex-1 bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3">
<code className="text-sm font-mono text-[#D4AF37] tracking-wider select-all">
{inviteCode}
</code>
</div>
<button
onClick={handleCopyCode}
className="w-11 h-11 rounded-xl bg-[#D4AF37]/15 border border-[#D4AF37]/30 flex items-center justify-center active:bg-[#D4AF37]/25 transition shrink-0"
>
{copied ? (
<Check size={18} className="text-emerald-400" />
) : (
<Copy size={18} className="text-[#D4AF37]" />
)}
</button>
</div>
) : (
<p className="text-xs text-gray-600">
Failed to load invite code.
</p>
)}
</div>
</div>
)}
{/* Members section */}
<div className="px-4">
<div className="flex items-center justify-between mb-3 px-1">
<div className="flex items-center gap-2">
<Users size={14} className="text-gray-500" />
<span className="text-xs font-medium text-gray-500">
Members ({group.members.length})
</span>
</div>
</div>
<div className="space-y-2">
{group.members.map((member) => {
const RoleIcon = ROLE_ICONS[member.role] || null;
const canRemove =
isOwner &&
member.role !== "owner" &&
member.userId !== user.id;
return (
<div
key={member.id}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-3.5"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center text-xs font-bold text-gray-300 shrink-0">
{member.user.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-white truncate">
{member.user.name}
</span>
{member.user.isPro && (
<Crown size={11} className="text-purple-400 shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
{RoleIcon && (
<div className={`flex items-center gap-0.5 ${ROLE_COLORS[member.role]}`}>
<RoleIcon size={10} />
<span className="text-[10px] capitalize">{member.role}</span>
</div>
)}
{member.role === "member" && (
<span className="text-[10px] text-gray-600">Member</span>
)}
</div>
</div>
{canRemove && (
<button
onClick={() => handleRemoveMember(member.id)}
disabled={removingMember === member.id}
className="w-9 h-9 rounded-full bg-red-900/15 border border-red-800/30 flex items-center justify-center active:bg-red-900/25 transition shrink-0 disabled:opacity-50"
>
{removingMember === member.id ? (
<Loader2 size={14} className="animate-spin text-red-400" />
) : (
<UserMinus size={14} className="text-red-400" />
)}
</button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* View threads button */}
<div className="px-4 mt-5">
<button
onClick={() => router.push(`/forum?groupId=${group.id}`)}
className="w-full flex items-center justify-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-3 active:bg-[#D4AF37]/25 transition"
>
<MessageCircle size={16} className="text-[#D4AF37]" />
<span className="text-sm font-medium text-[#D4AF37]">
View Group Threads
</span>
</button>
</div>
</>
)}
</div>
);
}
function MessageCircle(props: any) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={props.size || 24}
height={props.size || 24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
+535
View File
@@ -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<Group[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(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<string | null>(null);
// Join group modal
const [showJoinModal, setShowJoinModal] = useState(false);
const [joinCode, setJoinCode] = useState("");
const [joining, setJoining] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
const [joinSuccess, setJoinSuccess] = useState<string | null>(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 (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
{/* Header */}
<div className="px-4 pt-6 pb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-[#D4AF37]/15 flex items-center justify-center">
<Users size={18} className="text-[#D4AF37]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Groups</h1>
<p className="text-xs text-gray-500">Private forum groups</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setJoinCode("");
setJoinError(null);
setJoinSuccess(null);
setShowJoinModal(true);
}}
className="flex items-center gap-1.5 bg-gray-800/40 border border-gray-700/60 rounded-xl px-3 py-3 active:bg-gray-700/60 transition"
>
<span className="text-xs font-medium text-gray-300">Join</span>
</button>
{canCreateGroup && (
<button
onClick={() => {
setCreateName("");
setCreateDescription("");
setCreateError(null);
setShowCreateModal(true);
}}
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-3 py-3 active:bg-[#D4AF37]/25 transition"
>
<Plus size={14} className="text-[#D4AF37]" />
<span className="text-xs font-medium text-[#D4AF37]">Create</span>
</button>
)}
</div>
</div>
</div>
{/* Premium gating banner for free users */}
{!canCreateGroup && (
<div className="mx-4 mb-3 bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-xl px-3 py-2.5">
<div className="flex items-center gap-2">
<Lock size={14} className="text-[#D4AF37] shrink-0" />
<div className="flex-1">
<p className="text-xs text-amber-300/90">
<span className="font-semibold">Free</span> members can browse.{" "}
<span className="text-[#D4AF37] font-semibold">Premium</span> or{" "}
<span className="text-purple-400 font-semibold">Pro</span> required to create groups.
</p>
</div>
<button
onClick={() => router.push("/profile")}
className="text-xs font-semibold text-[#D4AF37] bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-lg px-2.5 py-3 active:bg-[#D4AF37]/25 transition"
>
Upgrade
</button>
</div>
</div>
)}
{/* Groups list */}
<div className="px-4">
{loadingData ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
<p className="text-sm text-gray-500">Loading groups...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchGroups}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
<Users size={28} className="text-gray-600" />
</div>
<h3 className="text-base font-semibold text-gray-400 mb-1">
No groups yet
</h3>
<p className="text-xs text-gray-600 text-center max-w-xs">
{canCreateGroup
? "Create a private group to discuss topics with select members."
: "Join a private group using an invite code."}
</p>
{canCreateGroup && (
<button
onClick={() => {
setCreateName("");
setCreateDescription("");
setCreateError(null);
setShowCreateModal(true);
}}
className="mt-4 flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-3 active:bg-[#D4AF37]/25 transition"
>
<Plus size={16} className="text-[#D4AF37]" />
<span className="text-sm font-medium text-[#D4AF37]">Create Group</span>
</button>
)}
</div>
) : (
<div className="space-y-2.5">
{groups.map((group) => (
<button
key={group.id}
onClick={() => router.push(`/groups/${group.id}`)}
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition-transform"
>
<div className="flex items-center gap-3">
{/* Group avatar */}
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center shrink-0">
<span className="text-base font-bold text-[#D4AF37]">
{group.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<h3 className="text-sm font-semibold text-white truncate">
{group.name}
</h3>
{group.ownerId === user.id && (
<Crown size={12} className="text-[#D4AF37] shrink-0" />
)}
</div>
{group.description && (
<p className="text-xs text-gray-500 truncate mb-1">
{group.description}
</p>
)}
<div className="flex items-center gap-3 text-xs text-gray-600">
<div className="flex items-center gap-1">
<Users size={11} />
<span>{group._count.members} members</span>
</div>
<span className="text-gray-700">·</span>
<span>{group._count.threads} threads</span>
</div>
</div>
<ChevronRight size={16} className="text-gray-600 shrink-0" />
</div>
</button>
))}
</div>
)}
</div>
{/* Create group modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowCreateModal(false)}
/>
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Users size={18} className="text-[#D4AF37]" />
<h2 className="text-base font-bold text-white">Create Group</h2>
</div>
<button
onClick={() => setShowCreateModal(false)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<X size={16} className="text-gray-500" />
</button>
</div>
<form onSubmit={handleCreateGroup} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Group Name *
</label>
<input
type="text"
value={createName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Description (optional)
</label>
<textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="What's this group about?"
maxLength={500}
rows={3}
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 resize-none"
/>
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 py-3 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="flex-1 py-3 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
>
{creating ? (
<>
<Loader2 size={14} className="animate-spin" />
Creating...
</>
) : (
"Create Group"
)}
</button>
</div>
</form>
</div>
</div>
)}
{/* Join group modal */}
{showJoinModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => {
if (!joining) setShowJoinModal(false);
}}
/>
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Lock size={18} className="text-[#D4AF37]" />
<h2 className="text-base font-bold text-white">Join Group</h2>
</div>
<button
onClick={() => {
if (!joining) setShowJoinModal(false);
}}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<X size={16} className="text-gray-500" />
</button>
</div>
<form onSubmit={handleJoinGroup} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Invite Code *
</label>
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
placeholder="Enter the invite code"
required
maxLength={50}
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"
/>
<p className="text-xs text-gray-600 mt-1.5">
Ask the group owner for the invite code.
</p>
</div>
{joinError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{joinError}</p>
</div>
)}
{joinSuccess && (
<div className="bg-emerald-900/10 border border-emerald-800/30 rounded-xl p-3">
<p className="text-xs text-emerald-300">{joinSuccess}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
if (!joining) setShowJoinModal(false);
}}
className="flex-1 py-3 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
>
Cancel
</button>
<button
type="submit"
disabled={joining}
className="flex-1 py-3 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
>
{joining ? (
<>
<Loader2 size={14} className="animate-spin" />
Joining...
</>
) : (
"Join Group"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}