feat(admin-dashboard): Phase 3.1-3.3 - add feedback management UI with screenshot support

- Add feedback list page with search, type/status/screenshot filters
- Add thumbnail indicator for feedback with screenshots
- Add detail dialog with device context panel
- Add screenshot lightbox viewer
- Add delete screenshot button for GDPR compliance
This commit is contained in:
saravanakumardb1 2026-03-03 06:59:27 -08:00
parent b261cda1cd
commit d2c8931ad0

View File

@ -0,0 +1,371 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Search,
MessageSquare,
Bug,
Lightbulb,
ThumbsUp,
Eye,
Trash2,
ImageIcon,
Loader2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/toast';
interface Feedback {
id: string;
type: 'bug' | 'feature' | 'praise' | 'other';
title: string;
body?: string;
status: 'new' | 'reviewed' | 'resolved' | 'closed';
userId: string;
userEmail?: string;
createdAt: string;
screenshotBlobPath?: string;
screenshotContentType?: string;
deviceContext?: {
osVersion?: string;
appVersion?: string;
deviceModel?: string;
screenResolution?: string;
locale?: string;
};
}
const typeIcons = {
bug: Bug,
feature: Lightbulb,
praise: ThumbsUp,
other: MessageSquare,
};
const typeColors = {
bug: 'bg-red-100 text-red-800',
feature: 'bg-blue-100 text-blue-800',
praise: 'bg-green-100 text-green-800',
other: 'bg-gray-100 text-gray-800',
};
const statusColors = {
new: 'bg-yellow-100 text-yellow-800',
reviewed: 'bg-blue-100 text-blue-800',
resolved: 'bg-green-100 text-green-800',
closed: 'bg-gray-100 text-gray-800',
};
export default function FeedbackPage() {
const [feedback, setFeedback] = useState<Feedback[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [screenshotFilter, setScreenshotFilter] = useState<string>('all');
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
const [screenshotUrl, setScreenshotUrl] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const { toast } = useToast();
useEffect(() => {
fetchFeedback();
}, []);
async function fetchFeedback() {
try {
const res = await fetch('/api/feedback');
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setFeedback(data.items || []);
} catch (err) {
toast({ title: 'Error', description: 'Failed to load feedback', variant: 'destructive' });
} finally {
setLoading(false);
}
}
async function viewScreenshot(feedbackId: string) {
try {
const res = await fetch(`/api/feedback/${feedbackId}/screenshot`);
if (!res.ok) throw new Error('Failed to fetch screenshot');
const data = await res.json();
setScreenshotUrl(data.url);
setLightboxOpen(true);
} catch (err) {
toast({ title: 'Error', description: 'Failed to load screenshot', variant: 'destructive' });
}
}
async function deleteScreenshot(feedbackId: string) {
if (!confirm('Delete this screenshot?')) return;
try {
const res = await fetch(`/api/feedback/${feedbackId}/screenshot`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
toast({ title: 'Success', description: 'Screenshot deleted' });
fetchFeedback();
} catch (err) {
toast({ title: 'Error', description: 'Failed to delete screenshot', variant: 'destructive' });
}
}
const filteredFeedback = feedback.filter((f) => {
const matchesSearch = f.title.toLowerCase().includes(search.toLowerCase()) ||
f.body?.toLowerCase().includes(search.toLowerCase());
const matchesType = typeFilter === 'all' || f.type === typeFilter;
const matchesStatus = statusFilter === 'all' || f.status === statusFilter;
const matchesScreenshot = screenshotFilter === 'all' ||
(screenshotFilter === 'has' && f.screenshotBlobPath) ||
(screenshotFilter === 'none' && !f.screenshotBlobPath);
return matchesSearch && matchesType && matchesStatus && matchesScreenshot;
});
return (
<div className="container mx-auto py-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">User Feedback</h1>
<p className="text-muted-foreground">View and manage user-submitted feedback with screenshots</p>
</div>
</div>
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search feedback..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="feature">Feature</SelectItem>
<SelectItem value="praise">Praise</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="new">New</SelectItem>
<SelectItem value="reviewed">Reviewed</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={screenshotFilter} onValueChange={setScreenshotFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Screenshot" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="has">Has Screenshot</SelectItem>
<SelectItem value="none">No Screenshot</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Feedback ({filteredFeedback.length})</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Title</TableHead>
<TableHead>User</TableHead>
<TableHead>Status</TableHead>
<TableHead>Screenshot</TableHead>
<TableHead>Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFeedback.map((f) => {
const TypeIcon = typeIcons[f.type];
return (
<TableRow key={f.id}>
<TableCell>
<Badge className={typeColors[f.type]}>
<TypeIcon className="h-3 w-3 mr-1" />
{f.type}
</Badge>
</TableCell>
<TableCell className="font-medium max-w-[300px] truncate">
{f.title}
</TableCell>
<TableCell>{f.userEmail || f.userId.slice(0, 8)}</TableCell>
<TableCell>
<Badge className={statusColors[f.status]}>{f.status}</Badge>
</TableCell>
<TableCell>
{f.screenshotBlobPath ? (
<Badge variant="outline" className="bg-green-50">
<ImageIcon className="h-3 w-3 mr-1" />
Yes
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>{new Date(f.createdAt).toLocaleDateString()}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFeedback(f)}
>
<Eye className="h-4 w-4" />
</Button>
{f.screenshotBlobPath && (
<Button
variant="ghost"
size="sm"
onClick={() => deleteScreenshot(f.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Feedback Detail Dialog */}
<Dialog open={!!selectedFeedback} onOpenChange={() => setSelectedFeedback(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Feedback Details</DialogTitle>
</DialogHeader>
{selectedFeedback && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge className={typeColors[selectedFeedback.type]}>
{selectedFeedback.type}
</Badge>
<Badge className={statusColors[selectedFeedback.status]}>
{selectedFeedback.status}
</Badge>
</div>
<h3 className="text-lg font-semibold">{selectedFeedback.title}</h3>
<p className="text-muted-foreground">{selectedFeedback.body}</p>
{/* Screenshot Section */}
{selectedFeedback.screenshotBlobPath && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">Screenshot</h4>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => viewScreenshot(selectedFeedback.id)}
>
<Eye className="h-4 w-4 mr-2" />
View Screenshot
</Button>
<Button
variant="outline"
onClick={() => deleteScreenshot(selectedFeedback.id)}
>
<Trash2 className="h-4 w-4 mr-2 text-red-500" />
Delete Screenshot
</Button>
</div>
</div>
)}
{/* Device Context */}
{selectedFeedback.deviceContext && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">Device Context</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
{selectedFeedback.deviceContext.osVersion && (
<div><span className="text-muted-foreground">OS:</span> {selectedFeedback.deviceContext.osVersion}</div>
)}
{selectedFeedback.deviceContext.appVersion && (
<div><span className="text-muted-foreground">App:</span> {selectedFeedback.deviceContext.appVersion}</div>
)}
{selectedFeedback.deviceContext.deviceModel && (
<div><span className="text-muted-foreground">Device:</span> {selectedFeedback.deviceContext.deviceModel}</div>
)}
{selectedFeedback.deviceContext.screenResolution && (
<div><span className="text-muted-foreground">Resolution:</span> {selectedFeedback.deviceContext.screenResolution}</div>
)}
{selectedFeedback.deviceContext.locale && (
<div><span className="text-muted-foreground">Locale:</span> {selectedFeedback.deviceContext.locale}</div>
)}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
{/* Screenshot Lightbox */}
<Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>
<DialogContent className="max-w-4xl p-0 overflow-hidden">
{screenshotUrl && (
<img
src={screenshotUrl}
alt="Screenshot"
className="w-full h-auto max-h-[80vh] object-contain"
/>
)}
</DialogContent>
</Dialog>
</div>
);
}