Files
falah-mobile/src/app/forum/page.tsx
T
root ed34b186f9 Referral page, bug fixes, seed data, persona tiers
- New /refer page with share/copy/stats
- Fixed modal z-index conflicts (z-50 → z-[60]) across forum, groups, souq, halal-monitor
- Seed route: forum categories, marketplace listings, private groups
- Mufti/Daie personas now available in Premium tier
- Fixed referral share link: falahos.my/mobile/auth?ref=CODE
- Fixed client-side persona access check for premium
2026-06-18 09:38:05 +02:00

662 lines
24 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
MessageSquare,
Search,
Plus,
X,
Crown,
Star,
Pin,
Clock,
AlertTriangle,
CheckCircle,
Loader2,
Lock,
Users,
MessageCircle,
FileText,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
interface Category {
id: string;
name: string;
description?: string;
icon?: string;
order?: number;
}
interface Thread {
id: string;
title: string;
content: string;
categoryId: string;
groupId?: string | null;
pinned: boolean;
shariahStatus: "pending" | "approved" | "rejected";
shariahFlags?: string;
createdAt: string;
author: {
id: string;
name: string;
isPremium: boolean;
isPro: boolean;
};
category: {
id: string;
name: string;
icon?: string;
};
_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<string, { label: string; color: string; icon: any }> = {
pending: { label: "Pending Review", color: "text-amber-400", icon: Clock },
approved: { label: "Shariah Compliant", color: "text-emerald-400", icon: CheckCircle },
rejected: { label: "Flagged", color: "text-red-400", icon: AlertTriangle },
};
export default function ForumPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [categories, setCategories] = useState<Category[]>([]);
const [threads, setThreads] = useState<Thread[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
// Create thread modal
const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({
title: "",
content: "",
categoryId: "",
groupId: "",
});
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const canPost = user?.isPremium || user?.isPro;
const fetchCategories = useCallback(async () => {
try {
const res = await fetch("/mobile/api/forum/categories");
const data = await res.json();
if (res.ok) {
setCategories(data.categories);
// Auto-select first category in modal
if (data.categories.length > 0) {
setCreateForm((f) => ({
...f,
categoryId: data.categories[0].id,
}));
}
}
} catch {
// ignore
}
}, []);
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);
try {
const params = new URLSearchParams();
if (activeCategory) params.set("categoryId", activeCategory);
if (searchQuery.trim()) params.set("search", searchQuery.trim());
const res = await fetch(`/mobile/api/forum/threads?${params.toString()}`);
const data = await res.json();
if (res.ok) {
setThreads(data.threads);
} else {
setError(data.error || "Failed to load threads");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingData(false);
}
}, [activeCategory, searchQuery]);
useEffect(() => {
if (!loading && !token) {
router.push("/auth");
return;
}
}, [loading, token, router]);
useEffect(() => {
if (token) {
fetchCategories();
}
}, [token, fetchCategories]);
useEffect(() => {
if (token) {
fetchThreads();
}
}, [token, fetchThreads]);
useEffect(() => {
if (token) {
fetchGroups();
}
}, [token, fetchGroups]);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
if (token) fetchThreads();
}, 400);
return () => clearTimeout(timer);
}, [searchQuery]);
const handleCreateThread = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
setCreating(true);
try {
const res = await fetch("/mobile/api/forum/threads", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
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 || "", groupId: "" });
fetchThreads();
} else {
setCreateError(data.error || "Failed to create thread");
}
} catch {
setCreateError("Network error. Please try again.");
} finally {
setCreating(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">
<MessageSquare size={18} className="text-[#D4AF37]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Forum</h1>
<p className="text-xs text-gray-500">Community Discussions</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => router.push("/groups")}
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"
>
<Users size={14} className="text-gray-400" />
<span className="text-xs font-medium text-gray-300">Groups</span>
</button>
{canPost && (
<button
onClick={() => {
setCreateForm({
title: "",
content: "",
categoryId: categories[0]?.id || "",
groupId: "",
});
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]">New Thread</span>
</button>
)}
</div>
</div>
</div>
{/* Premium gating banner for free users */}
{!canPost && (
<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 post.
</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>
)}
{/* Search bar */}
<div className="px-4 mb-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search threads..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl pl-10 pr-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
/>
</div>
</div>
{/* Category pills */}
<div className="px-4 mb-4 overflow-x-auto">
<div className="flex gap-2 pb-1" style={{ minWidth: "max-content" }}>
<button
onClick={() => setActiveCategory(null)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
activeCategory === null
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
}`}
>
<MessageSquare size={13} />
All
</button>
{categories.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
isActive
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
}`}
>
{cat.icon || <MessageCircle size={13} />}
{cat.name}
</button>
);
})}
</div>
</div>
{/* Threads 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 threads...</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={fetchThreads}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
) : threads.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">
<MessageSquare size={28} className="text-gray-600" />
</div>
<h3 className="text-base font-semibold text-gray-400 mb-1">
{searchQuery || activeCategory
? "No threads found"
: "No discussions yet"}
</h3>
<p className="text-xs text-gray-600 text-center max-w-xs">
{searchQuery || activeCategory
? "Try a different search or category"
: canPost
? "Be the first to start a discussion!"
: "No discussions yet. Check back soon."}
</p>
{canPost && !searchQuery && !activeCategory && (
<button
onClick={() => {
setCreateForm({
title: "",
content: "",
categoryId: categories[0]?.id || "",
groupId: "",
});
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]">Start Discussion</span>
</button>
)}
</div>
) : (
<div className="space-y-2.5">
{threads.map((thread) => {
const shariahInfo = SHARIAH_LABELS[thread.shariahStatus];
const ShariahIcon = shariahInfo.icon;
return (
<button
key={thread.id}
onClick={() => router.push(`/forum/${thread.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-start gap-3">
<div className="flex-1 min-w-0">
{/* Title row */}
<div className="flex items-center gap-1.5 mb-1">
{thread.pinned && (
<Pin size={12} className="text-[#D4AF37] shrink-0" />
)}
{thread.groupId && (
<Lock size={12} className="text-amber-500/70 shrink-0" />
)}
<h3 className="text-sm font-semibold text-white truncate">
{thread.title}
</h3>
</div>
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-600">
<div className="flex items-center gap-1">
<span className="text-gray-500">by</span>
<span className={`font-medium ${
thread.author.isPro
? "text-purple-400"
: thread.author.isPremium
? "text-[#D4AF37]"
: "text-gray-400"
}`}>
{thread.author.name}
</span>
{thread.author.isPro && (
<PremiumBadge tier="pro" size="sm" />
)}
{thread.author.isPremium && !thread.author.isPro && (
<PremiumBadge tier="premium" size="sm" />
)}
</div>
<span className="text-gray-700">·</span>
<span className="text-gray-500">{thread.category.name}</span>
{thread.group && (
<>
<span className="text-gray-700">·</span>
<div className="flex items-center gap-0.5 text-amber-500/70">
<Lock size={8} />
<span className="text-[10px]">{thread.group.name}</span>
</div>
</>
)}
<span className="text-gray-700">·</span>
<div className="flex items-center gap-1">
<MessageCircle size={9} />
<span>{thread._count.posts}</span>
</div>
</div>
{/* Shariah status + time */}
<div className="flex items-center gap-2 mt-1.5">
<div className={`flex items-center gap-0.5 ${shariahInfo.color}`}>
<ShariahIcon size={9} />
<span className="text-[10px]">{shariahInfo.label}</span>
</div>
<span className="text-gray-700">·</span>
<div className="flex items-center gap-0.5 text-gray-600">
<Clock size={9} />
<span className="text-xs">{timeAgo(thread.createdAt)}</span>
</div>
</div>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Create thread 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">
<Plus size={18} className="text-[#D4AF37]" />
<h2 className="text-base font-bold text-white">New Thread</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={handleCreateThread} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Category *
</label>
<select
value={createForm.categoryId}
onChange={(e) =>
setCreateForm((f) => ({ ...f, categoryId: e.target.value }))
}
required
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3 text-sm text-white focus:outline-none focus:border-[#D4AF37]/40 transition appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
}}
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{groups.length > 0 && canPost && (
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Group Post (optional)
</label>
<select
value={createForm.groupId}
onChange={(e) =>
setCreateForm((f) => ({ ...f, groupId: e.target.value }))
}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3 text-sm text-white focus:outline-none focus:border-[#D4AF37]/40 transition appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
}}
>
<option value="">Public thread (visible to all)</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
🔒 {g.name} ({g._count.members} members)
</option>
))}
</select>
<p className="text-xs text-gray-600 mt-1">
Group posts are only visible to group members.
</p>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Title *
</label>
<input
type="text"
value={createForm.title}
onChange={(e) =>
setCreateForm((f) => ({ ...f, title: e.target.value }))
}
placeholder="What's on your mind?"
required
maxLength={200}
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">
Content *
</label>
<textarea
value={createForm.content}
onChange={(e) =>
setCreateForm((f) => ({ ...f, content: e.target.value }))
}
placeholder="Write your thoughts..."
required
maxLength={10000}
rows={5}
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 Thread"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}