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)
407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useAuth } from "@/lib/AuthContext";
|
|
import { useRouter, useParams } from "next/navigation";
|
|
import {
|
|
ArrowLeft,
|
|
Crown,
|
|
Star,
|
|
Clock,
|
|
CheckCircle,
|
|
Loader2,
|
|
Lock,
|
|
Send,
|
|
ShieldCheck,
|
|
Flag,
|
|
} from "lucide-react";
|
|
import ErrorFeedback from "@/components/ErrorFeedback";
|
|
|
|
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(`/mobile/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(`/mobile/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("/auth");
|
|
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("/mobile/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 ? (
|
|
<ErrorFeedback error={error} kind="network" onRetry={() => { fetchThread(); fetchPosts(); }} context="thread posts" />
|
|
) : !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-xs 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-[10px]">{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-xs 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-[10px]">{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-3 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-xs 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-3 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 && (
|
|
<ErrorFeedback error={replyError} kind="default" onRetry={() => setReplyError(null)} context="reply" />
|
|
)}
|
|
</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>
|
|
);
|
|
}
|