Files
shadow-gunbuilder-ai-proto/app/admin/feedback/page.tsx
T

195 lines
6.9 KiB
TypeScript

'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 is returned by the API but not displayed in the table (privacy/noise)
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-x-auto 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>
)
}