feat: add admin feedback review page and nav link
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
type FeedbackSubmission = {
|
||||
id: string
|
||||
category: 'BUG' | 'FEATURE_REQUEST' | 'GENERAL'
|
||||
message: string
|
||||
email: string | null
|
||||
ipAddress: string
|
||||
reviewed: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
BUG: 'Bug',
|
||||
FEATURE_REQUEST: 'Feature Request',
|
||||
GENERAL: 'General',
|
||||
}
|
||||
|
||||
const CATEGORY_STYLES: Record<string, string> = {
|
||||
BUG: 'bg-red-500/10 text-red-400 border border-red-500/20',
|
||||
FEATURE_REQUEST: 'bg-amber-500/10 text-amber-400 border border-amber-500/20',
|
||||
GENERAL: 'bg-zinc-800 text-zinc-400 border border-zinc-700',
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default function AdminFeedbackPage() {
|
||||
const { getAuthHeaders } = useAuth()
|
||||
const [items, setItems] = useState<FeedbackSubmission[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState('ALL')
|
||||
const [reviewedFilter, setReviewedFilter] = useState('ALL')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const res = await fetch('/api/admin/feedback', {
|
||||
headers: getAuthHeaders(),
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to load feedback (${res.status})`)
|
||||
setItems(await res.json())
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function toggleReviewed(item: FeedbackSubmission) {
|
||||
const prev = items
|
||||
setItems((cur) =>
|
||||
cur.map((i) => (i.id === item.id ? { ...i, reviewed: !i.reviewed } : i))
|
||||
)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ reviewed: !item.reviewed }),
|
||||
})
|
||||
if (!res.ok) setItems(prev)
|
||||
} catch {
|
||||
setItems(prev)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = items.filter((i) => {
|
||||
if (categoryFilter !== 'ALL' && i.category !== categoryFilter) return false
|
||||
if (reviewedFilter === 'REVIEWED' && !i.reviewed) return false
|
||||
if (reviewedFilter === 'UNREVIEWED' && i.reviewed) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Feedback</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
User-submitted feedback. Mark items reviewed to track triage.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="rounded-md border border-zinc-800 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-3">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
<option value="ALL">All categories</option>
|
||||
<option value="BUG">Bug</option>
|
||||
<option value="FEATURE_REQUEST">Feature Request</option>
|
||||
<option value="GENERAL">General</option>
|
||||
</select>
|
||||
<select
|
||||
value={reviewedFilter}
|
||||
onChange={(e) => setReviewedFilter(e.target.value)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
<option value="ALL">All statuses</option>
|
||||
<option value="UNREVIEWED">Unreviewed</option>
|
||||
<option value="REVIEWED">Reviewed</option>
|
||||
</select>
|
||||
<span className="ml-auto self-center text-xs text-zinc-600">
|
||||
{filtered.length} of {items.length} submissions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No submissions match your filters.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
<th className="px-4 py-3">Date</th>
|
||||
<th className="px-4 py-3">Category</th>
|
||||
<th className="px-4 py-3">Message</th>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Reviewed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-900">
|
||||
{filtered.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-950/60">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-zinc-500">
|
||||
{formatDate(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||
CATEGORY_STYLES[item.category]
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[item.category]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-sm px-4 py-3 text-xs text-zinc-300">
|
||||
{item.message.length > 120
|
||||
? `${item.message.slice(0, 120)}…`
|
||||
: item.message}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-zinc-500">
|
||||
{item.email ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleReviewed(item)}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-semibold transition-colors ${
|
||||
item.reviewed
|
||||
? 'bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400'
|
||||
: 'bg-zinc-800 text-zinc-500 hover:bg-green-500/10 hover:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{item.reviewed ? 'Reviewed' : 'Mark reviewed'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Wand2,
|
||||
FolderKanban,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
const navGroups: AdminNavGroup[] = [
|
||||
@@ -89,6 +90,11 @@ const navGroups: AdminNavGroup[] = [
|
||||
href: "/admin/email/send",
|
||||
icon: <Mail className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Feedback",
|
||||
href: "/admin/feedback",
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user