14d7127e41
- Error Feedback Service: POST /api/feedback saves to JSONL, GET with admin token - ErrorFeedback component: warm amber tones, friendly messages, 'Report Issue' button - Replaced ALL red-themed error displays across 15+ pages with ErrorFeedback - Kind classification: network, auth, location, upgrade, default messages - QA test suite v2: 8-layer testing (system, API, render, scenario, edge, mobile, perf, security) - Browser-based visual QA with Playwright (Layer 9)
523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
"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,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import ErrorFeedback from "@/components/ErrorFeedback";
|
|
|
|
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("/auth");
|
|
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 ? (
|
|
<ErrorFeedback error={error} kind="network" onRetry={fetchGroups} context="groups" />
|
|
) : 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-[60] 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 && (
|
|
<ErrorFeedback error={createError} kind="default" onRetry={() => setCreateError(null)} context="create group" />
|
|
)}
|
|
|
|
<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-[60] 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 && (
|
|
<ErrorFeedback error={joinError} kind="default" onRetry={() => setJoinError(null)} context="join group" />
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|