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