From afa15298735a73f02ee14685de2e8ea5bd5e7f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Tue, 24 Feb 2026 16:05:11 +0300 Subject: [PATCH] feat(feedback): add user feedback/review system with admin management and hardened image upload validation --- app/(dashboard)/feedback/page.tsx | 387 +++++++++++++++++ app/admin/feedback/[feedbackId]/page.tsx | 200 +++++++++ app/admin/feedback/page.tsx | 423 +++++++++++++++++++ app/admin/layout.tsx | 13 +- app/admin/page.tsx | 41 +- app/api/admin/feedback/[feedbackId]/route.ts | 127 ++++++ app/api/feedback/route.ts | 151 +++++++ app/api/feedback/upload/route.ts | 84 ++++ app/api/upload/image/[filename]/route.ts | 34 +- app/api/upload/image/route.ts | 57 ++- components/admin/delete-feedback-button.tsx | 88 ++++ components/layout/header.tsx | 19 +- lib/image-upload-validation.ts | 77 ++++ lib/rate-limit.ts | 3 + prisma/schema.prisma | 55 +++ 15 files changed, 1725 insertions(+), 34 deletions(-) create mode 100644 app/(dashboard)/feedback/page.tsx create mode 100644 app/admin/feedback/[feedbackId]/page.tsx create mode 100644 app/admin/feedback/page.tsx create mode 100644 app/api/admin/feedback/[feedbackId]/route.ts create mode 100644 app/api/feedback/route.ts create mode 100644 app/api/feedback/upload/route.ts create mode 100644 components/admin/delete-feedback-button.tsx create mode 100644 lib/image-upload-validation.ts diff --git a/app/(dashboard)/feedback/page.tsx b/app/(dashboard)/feedback/page.tsx new file mode 100644 index 0000000..bcae3d8 --- /dev/null +++ b/app/(dashboard)/feedback/page.tsx @@ -0,0 +1,387 @@ +'use client'; + +import { ChangeEvent, FormEvent, useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { ArrowLeft, Bug, Image as ImageIcon, Loader2, MessageSquareQuote, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +type FeedbackCategory = 'BUG' | 'FEATURE' | 'OTHER'; +type TabValue = 'feedback' | 'review'; + +interface FormStatus { + type: 'success' | 'error'; + message: string; +} + +export default function FeedbackPage() { + const [activeTab, setActiveTab] = useState('feedback'); + const [status, setStatus] = useState(null); + const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false); + const [isSubmittingReview, setIsSubmittingReview] = useState(false); + + const [feedbackTitle, setFeedbackTitle] = useState(''); + const [feedbackCategory, setFeedbackCategory] = useState('BUG'); + const [feedbackMessage, setFeedbackMessage] = useState(''); + const [feedbackScreenshotFiles, setFeedbackScreenshotFiles] = useState([]); + const [feedbackScreenshotPreviewUrls, setFeedbackScreenshotPreviewUrls] = useState([]); + + const [reviewTitle, setReviewTitle] = useState(''); + const [reviewMessage, setReviewMessage] = useState(''); + const [reviewRating, setReviewRating] = useState('5'); + const [allowShowcase, setAllowShowcase] = useState(false); + + const uploadFeedbackScreenshot = async (file: File): Promise => { + const formData = new FormData(); + formData.append('image', file); + + const response = await fetch('/api/feedback/upload', { + method: 'POST', + body: formData, + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to upload screenshot'); + } + + return data.data.url as string; + }; + + const handleFeedbackScreenshotChange = (event: ChangeEvent) => { + const selectedFiles = Array.from(event.target.files ?? []); + if (selectedFiles.length === 0) return; + + const remainingSlots = 5 - feedbackScreenshotFiles.length; + if (remainingSlots <= 0) { + setStatus({ type: 'error', message: 'You can upload up to 5 screenshots.' }); + return; + } + + const allowedFiles = selectedFiles.slice(0, remainingSlots); + const nextFiles: File[] = []; + const nextPreviewUrls: string[] = []; + + for (const file of allowedFiles) { + const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type); + if (!isImage) { + setStatus({ type: 'error', message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.' }); + continue; + } + if (file.size > 10 * 1024 * 1024) { + setStatus({ type: 'error', message: 'Each screenshot must be smaller than 10MB.' }); + continue; + } + nextFiles.push(file); + nextPreviewUrls.push(URL.createObjectURL(file)); + } + + if (nextFiles.length > 0) { + setFeedbackScreenshotFiles((prev) => [...prev, ...nextFiles]); + setFeedbackScreenshotPreviewUrls((prev) => [...prev, ...nextPreviewUrls]); + } + event.target.value = ''; + }; + + const removeFeedbackScreenshot = (index: number) => { + const targetUrl = feedbackScreenshotPreviewUrls[index]; + if (targetUrl) URL.revokeObjectURL(targetUrl); + setFeedbackScreenshotFiles((prev) => prev.filter((_, currentIndex) => currentIndex !== index)); + setFeedbackScreenshotPreviewUrls((prev) => prev.filter((_, currentIndex) => currentIndex !== index)); + }; + + const clearFeedbackScreenshots = () => { + feedbackScreenshotPreviewUrls.forEach((url) => URL.revokeObjectURL(url)); + setFeedbackScreenshotFiles([]); + setFeedbackScreenshotPreviewUrls([]); + }; + + const handleFeedbackSubmit = async (event: FormEvent) => { + event.preventDefault(); + setStatus(null); + setIsSubmittingFeedback(true); + + try { + const screenshotUrls = await Promise.all( + feedbackScreenshotFiles.map((file) => uploadFeedbackScreenshot(file)) + ); + + const response = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'FEEDBACK', + category: feedbackCategory, + title: feedbackTitle, + message: feedbackMessage, + screenshotUrls, + }), + }); + + const data = await response.json(); + if (!response.ok) { + setStatus({ type: 'error', message: data.error || 'Failed to submit feedback' }); + return; + } + + setFeedbackTitle(''); + setFeedbackCategory('BUG'); + setFeedbackMessage(''); + clearFeedbackScreenshots(); + setStatus({ type: 'success', message: 'Feedback submitted. Thank you.' }); + } catch { + setStatus({ type: 'error', message: 'Failed to submit feedback' }); + } finally { + setIsSubmittingFeedback(false); + } + }; + + const handleReviewSubmit = async (event: FormEvent) => { + event.preventDefault(); + setStatus(null); + setIsSubmittingReview(true); + + try { + const response = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'REVIEW', + title: reviewTitle, + message: reviewMessage, + rating: Number(reviewRating), + allowShowcase, + }), + }); + + const data = await response.json(); + if (!response.ok) { + setStatus({ type: 'error', message: data.error || 'Failed to submit review' }); + return; + } + + setReviewTitle(''); + setReviewMessage(''); + setReviewRating('5'); + setAllowShowcase(false); + setStatus({ type: 'success', message: 'Review submitted. Thank you for sharing your experience.' }); + } catch { + setStatus({ type: 'error', message: 'Failed to submit review' }); + } finally { + setIsSubmittingReview(false); + } + }; + + return ( +
+
+ + + Back to Dashboard + + + + + Feedback & Review + + Send product feedback, report bugs, or share a review we can feature on the landing page. + + + + {status && ( +
+ {status.message} +
+ )} + + setActiveTab(value as TabValue)} className="w-full"> + + + + Feedback + + + + Review + + + + +
+
+ + setFeedbackTitle(event.target.value)} + placeholder="Short summary" + minLength={3} + maxLength={120} + required + disabled={isSubmittingFeedback} + /> +
+ +
+ + +
+ +
+ +