195 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|