diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts new file mode 100644 index 0000000..fa94cd3 --- /dev/null +++ b/app/api/feedback/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: { rating?: number; comments?: string; challenges?: string; improvements?: string; malfunctions?: string; attachment_url?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { rating, comments, challenges, improvements, malfunctions, attachment_url } = body; + + if (rating && (rating < 1 || rating > 5)) { + return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 }); + } + + await sql` + INSERT INTO feedback (user_id, rating, comments, challenges, improvements, malfunctions, attachment_url) + VALUES (${session.user.id}, ${rating ?? null}, ${comments ?? null}, ${challenges ?? null}, ${improvements ?? null}, ${malfunctions ?? null}, ${attachment_url ?? null}) + `; + + return NextResponse.json({ success: true }); +} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 456833d..758baeb 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -10,9 +10,11 @@ const defaultLinks: Record = { instructor: [ { href: '/instructor', label: 'Dashboard' }, { href: '/instructor/users', label: 'Users' }, + { href: '/instructor/feedback', label: 'Feedback' }, ], participant: [ { href: '/participant', label: 'Exercises' }, + { href: '/participant/feedback', label: 'Feedback' }, { href: '/participant/settings', label: 'Settings' }, ], }; diff --git a/app/instructor/feedback/page.tsx b/app/instructor/feedback/page.tsx new file mode 100644 index 0000000..9d264c3 --- /dev/null +++ b/app/instructor/feedback/page.tsx @@ -0,0 +1,144 @@ +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { sql } from '@/lib/db'; +import Navbar from '@/app/components/Navbar'; +import Link from 'next/link'; + +export default async function FeedbackPage() { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') redirect('/login'); + + let feedbackRows: any[] = []; + let tableExists = true; + + try { + feedbackRows = await sql` + SELECT f.id, f.rating, f.comments, f.challenges, f.improvements, f.malfunctions, f.attachment_url, f.submitted_at, u.username + FROM feedback f + INNER JOIN users u ON u.id = f.user_id + ORDER BY f.submitted_at DESC + `; + } catch (err) { + // Table doesn't exist yet — migration not run + if ((err as Error).message.includes('does not exist')) { + tableExists = false; + } else { + throw err; + } + } + + if (!tableExists) { + return ( +
+ +
+
+
+

Participant Feedback

+
+
+

+ Feedback table not found. Run the migration: psql $DATABASE_URL < migrations/0007_feedback.sql +

+
+
+
+
+ ); + } + + const avgRating = feedbackRows.length > 0 + ? (feedbackRows.reduce((sum, f) => sum + (f.rating as number || 0), 0) / feedbackRows.filter(f => f.rating).length).toFixed(1) + : 'N/A'; + + return ( +
+ +
+
+
+ Dashboard + / + Feedback +
+
+

Participant Feedback

+

{feedbackRows.length} responses · Avg rating: {avgRating}

+
+ + {feedbackRows.length === 0 ? ( +
+

No feedback yet.

+
+ ) : ( +
+ {feedbackRows.map((f) => ( +
+
+
+ {f.username as string} + {f.rating && ( + + Rating: {f.rating}/5 + + )} +
+ + {new Date(f.submitted_at as string).toLocaleString()} + +
+ + {f.challenges && ( +
+
Challenges
+

+ {f.challenges as string} +

+
+ )} + + {f.malfunctions && ( +
+
Malfunctions
+

+ {f.malfunctions as string} +

+
+ )} + + {f.improvements && ( +
+
Improvements
+

+ {f.improvements as string} +

+
+ )} + + {f.comments && ( +
+
Additional Comments
+

+ {f.comments as string} +

+
+ )} + + {f.attachment_url && ( +
+
Attachment
+ + View Document → + +
+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/app/participant/feedback/FeedbackForm.tsx b/app/participant/feedback/FeedbackForm.tsx new file mode 100644 index 0000000..1950873 --- /dev/null +++ b/app/participant/feedback/FeedbackForm.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { Frown, Meh, Smile, Laugh, Heart } from 'lucide-react'; + +export default function FeedbackForm() { + const router = useRouter(); + const [rating, setRating] = useState(null); + const [comments, setComments] = useState(''); + const [challenges, setChallenges] = useState(''); + const [improvements, setImprovements] = useState(''); + const [malfunctions, setMalfunctions] = useState(''); + const [attachmentUrl, setAttachmentUrl] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const ratingConfig = [ + { value: 1, icon: Frown, label: 'Poor', color: '#ef4444' }, + { value: 2, icon: Meh, label: 'Fair', color: '#f97316' }, + { value: 3, icon: Smile, label: 'Good', color: '#eab308' }, + { value: 4, icon: Laugh, label: 'Great', color: '#84cc16' }, + { value: 5, icon: Heart, label: 'Excellent', color: '#10b981' }, + ]; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + + try { + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rating, + comments, + challenges, + improvements, + malfunctions, + attachment_url: attachmentUrl || null, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error ?? 'Failed to submit feedback'); + } + + toast.success('Thank you for your feedback!'); + router.push('/participant'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to submit'); + } finally { + setSubmitting(false); + } + } + + return ( +
+ {/* Rating */} +
+
+ Overall Experience +
+
+ {ratingConfig.map(({ value, icon: Icon, label, color }) => ( + + ))} +
+
+ + {/* Challenges */} +
+
+ Challenges Encountered +
+