Initial Falah Mobile rebuild — all 7 blocks complete
- Auth system (login/register/profile) - Nur AI chat with persona system - Souq marketplace with FLH economy - Forum community with Shariah moderation - Wallet & FLH top-up - Premium/Pro tier upgrade with Polar.sh - Halal Monitor with map & bookmarks - Home dashboard with daily verse & streaks - Health endpoints Next.js 16.2.7, Prisma v5 SQLite, JWT auth, Tailwind CSS v4
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Crown,
|
||||
Star,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Lock,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Flag,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AuthorInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPremium: boolean;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
interface ThreadDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: string;
|
||||
pinned: boolean;
|
||||
shariahStatus: "pending" | "approved" | "rejected";
|
||||
shariahFlags?: string;
|
||||
createdAt: string;
|
||||
author: AuthorInfo;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
content: string;
|
||||
threadId: string;
|
||||
shariahStatus: "pending" | "approved" | "rejected";
|
||||
shariahFlags?: string;
|
||||
createdAt: string;
|
||||
author: AuthorInfo;
|
||||
}
|
||||
|
||||
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: ShieldCheck },
|
||||
rejected: { label: "Flagged", color: "text-red-400", icon: Flag },
|
||||
};
|
||||
|
||||
export default function ThreadDetailPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const threadId = params.threadId as string;
|
||||
|
||||
const [thread, setThread] = useState<ThreadDetail | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reply form
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(null);
|
||||
|
||||
const canPost = user?.isPremium || user?.isPro;
|
||||
|
||||
const fetchThread = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/forum/threads`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const found = data.threads.find((t: ThreadDetail) => t.id === threadId);
|
||||
if (found) {
|
||||
setThread(found);
|
||||
} else {
|
||||
// Try fetching thread detail individually (use threads endpoint with the thread)
|
||||
// Since we don't have a single-thread endpoint, we'll get it from the list
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [threadId]);
|
||||
|
||||
const fetchPosts = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/forum/posts?threadId=${threadId}`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setPosts(data.posts);
|
||||
} else {
|
||||
setError(data.error || "Failed to load posts");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && threadId) {
|
||||
fetchThread();
|
||||
fetchPosts();
|
||||
}
|
||||
}, [token, threadId, fetchThread, fetchPosts]);
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyContent.trim()) return;
|
||||
|
||||
setReplyError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/forum/posts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: replyContent.trim(),
|
||||
threadId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setReplyContent("");
|
||||
fetchPosts();
|
||||
} else {
|
||||
setReplyError(data.error || "Failed to post reply");
|
||||
}
|
||||
} catch {
|
||||
setReplyError("Network error. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(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="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("/forum")}
|
||||
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">
|
||||
{thread ? thread.title : "Thread"}
|
||||
</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 thread...</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={() => { fetchThread(); fetchPosts(); }}
|
||||
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : !thread ? (
|
||||
<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">Thread not found</p>
|
||||
<button
|
||||
onClick={() => router.push("/forum")}
|
||||
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||
>
|
||||
Back to forum
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Original thread post */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
|
||||
{/* Author + category */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-xs font-bold text-[#D4AF37]">
|
||||
{thread.author.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">
|
||||
{thread.author.name}
|
||||
</span>
|
||||
{thread.author.isPro && (
|
||||
<Crown size={12} className="text-purple-400" />
|
||||
)}
|
||||
{thread.author.isPremium && !thread.author.isPro && (
|
||||
<Star size={12} className="text-[#D4AF37]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-600">
|
||||
<span className="text-gray-500">{thread.category.name}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(thread.createdAt)}</span>
|
||||
{/* Shariah compliance */}
|
||||
<span>·</span>
|
||||
<div className={`flex items-center gap-0.5 ${SHARIAH_LABELS[thread.shariahStatus].color}`}>
|
||||
{(() => {
|
||||
const Icon = SHARIAH_LABELS[thread.shariahStatus].icon;
|
||||
return <Icon size={9} />;
|
||||
})()}
|
||||
<span className="text-[9px]">{SHARIAH_LABELS[thread.shariahStatus].label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title & content */}
|
||||
<h2 className="text-base font-bold text-white mb-2">{thread.title}</h2>
|
||||
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
{thread.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts list */}
|
||||
<div className="px-4 space-y-2.5">
|
||||
{posts.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1 py-2">
|
||||
<MessageCircle size={14} className="text-gray-500" />
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{posts.length} {posts.length === 1 ? "Reply" : "Replies"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => {
|
||||
const shariahInfo = SHARIAH_LABELS[post.shariahStatus];
|
||||
const ShariahIcon = shariahInfo.icon;
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 animate-fade-in"
|
||||
>
|
||||
{/* Author info */}
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center text-[10px] font-bold text-gray-300">
|
||||
{post.author.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-white">
|
||||
{post.author.name}
|
||||
</span>
|
||||
{post.author.isPro && (
|
||||
<Crown size={10} className="text-purple-400" />
|
||||
)}
|
||||
{post.author.isPremium && !post.author.isPro && (
|
||||
<Star size={10} className="text-[#D4AF37]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-600">
|
||||
<Clock size={9} />
|
||||
<span>{timeAgo(post.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap mb-2">
|
||||
{post.content}
|
||||
</p>
|
||||
|
||||
{/* Shariah compliance label */}
|
||||
<div className={`flex items-center gap-0.5 ${shariahInfo.color}`}>
|
||||
<ShariahIcon size={9} />
|
||||
<span className="text-[9px]">{shariahInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reply form — premium gated for free users */}
|
||||
<div className="px-4 mt-4">
|
||||
{!canPost ? (
|
||||
<div className="bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-2xl p-4 text-center">
|
||||
<Lock size={20} className="mx-auto text-[#D4AF37] mb-2" />
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Premium Feature</h3>
|
||||
<p className="text-xs text-gray-400 mb-3 max-w-xs mx-auto">
|
||||
Upgrade to Premium or Pro to join the discussion and reply to threads.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/profile")}
|
||||
className="inline-flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2 text-xs font-medium text-[#D4AF37] active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
<Crown size={12} />
|
||||
Upgrade Now
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleReply} className="space-y-3">
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden">
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder="Write your reply..."
|
||||
maxLength={5000}
|
||||
rows={3}
|
||||
className="w-full bg-transparent px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{replyContent.length}/5000
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !replyContent.trim()}
|
||||
className="flex items-center gap-1.5 bg-[#D4AF37] text-[#0a0a0f] rounded-xl px-4 py-2 text-xs font-semibold transition active:bg-[#c5a233] disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={12} />
|
||||
Reply
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{replyError && (
|
||||
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
|
||||
<p className="text-xs text-red-300">{replyError}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
"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,
|
||||
MessageCircle,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: string;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
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 [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: "",
|
||||
});
|
||||
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("/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 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(`/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("/login");
|
||||
return;
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchCategories();
|
||||
}
|
||||
}, [token, fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchThreads();
|
||||
}
|
||||
}, [token, fetchThreads]);
|
||||
|
||||
// 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("/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,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "" });
|
||||
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-[10px] text-gray-500">Community Discussions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canPost && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateForm({
|
||||
title: "",
|
||||
content: "",
|
||||
categoryId: categories[0]?.id || "",
|
||||
});
|
||||
setCreateError(null);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-3 py-2 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-[11px] 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-[10px] font-semibold text-[#D4AF37] bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-lg px-2.5 py-1.5 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-2.5 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 || "",
|
||||
});
|
||||
setCreateError(null);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="mt-4 flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2.5 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" />
|
||||
)}
|
||||
<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-[10px] text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500">by</span>
|
||||
<span className="font-medium text-gray-400">
|
||||
{thread.author.name}
|
||||
</span>
|
||||
{thread.author.isPro && (
|
||||
<Crown size={9} className="text-purple-400" />
|
||||
)}
|
||||
{thread.author.isPremium && !thread.author.isPro && (
|
||||
<Star size={9} className="text-[#D4AF37]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-gray-700">·</span>
|
||||
|
||||
<span className="text-gray-500">{thread.category.name}</span>
|
||||
|
||||
<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-[9px]">{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-[9px]">{timeAgo(thread.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create thread 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">
|
||||
<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-2.5 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>
|
||||
|
||||
<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-2.5 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-2.5 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-2.5 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-2.5 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user