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:
parent
b261cda1cd
commit
d2c8931ad0
371
dashboards/admin-web/src/app/(dashboard)/feedback/page.tsx
Normal file
371
dashboards/admin-web/src/app/(dashboard)/feedback/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user