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,
|
Wand2,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Shield,
|
Shield,
|
||||||
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navGroups: AdminNavGroup[] = [
|
const navGroups: AdminNavGroup[] = [
|
||||||
@@ -89,6 +90,11 @@ const navGroups: AdminNavGroup[] = [
|
|||||||
href: "/admin/email/send",
|
href: "/admin/email/send",
|
||||||
icon: <Mail className="h-4 w-4" />,
|
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