Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/api/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
2 changes: 2 additions & 0 deletions app/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ const defaultLinks: Record<string, NavLink[]> = {
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' },
],
};
Expand Down
144 changes: 144 additions & 0 deletions app/instructor/feedback/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="page">
<Navbar username={session.user.name ?? undefined} role="instructor" />
<main className="main">
<div className="container">
<div className="page-header">
<h1 className="page-title">Participant Feedback</h1>
</div>
<div className="card">
<p style={{ color: 'var(--text3)', textAlign: 'center', padding: '2rem' }}>
Feedback table not found. Run the migration: <code style={{ background: 'var(--bg3)', padding: '2px 6px', borderRadius: 4 }}>psql $DATABASE_URL &lt; migrations/0007_feedback.sql</code>
</p>
</div>
</div>
</main>
</div>
);
}

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 (
<div className="page">
<Navbar username={session.user.name ?? undefined} role="instructor" />
<main className="main">
<div className="container">
<div className="breadcrumb">
<Link href="/instructor">Dashboard</Link>
<span className="breadcrumb-sep">/</span>
<span>Feedback</span>
</div>
<div className="page-header">
<h1 className="page-title">Participant Feedback</h1>
<p className="page-sub">{feedbackRows.length} responses · Avg rating: {avgRating}</p>
</div>

{feedbackRows.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text3)', textAlign: 'center', padding: '2rem' }}>No feedback yet.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{feedbackRows.map((f) => (
<div key={f.id as string} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
<div>
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: 15 }}>{f.username as string}</span>
{f.rating && (
<span style={{ marginLeft: '0.75rem', fontSize: 13, color: 'var(--text3)' }}>
Rating: {f.rating}/5
</span>
)}
</div>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{new Date(f.submitted_at as string).toLocaleString()}
</span>
</div>

{f.challenges && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.4rem' }}>Challenges</div>
<p style={{ color: 'var(--text2)', fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0 }}>
{f.challenges as string}
</p>
</div>
)}

{f.malfunctions && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--red)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.4rem' }}>Malfunctions</div>
<p style={{ color: 'var(--text2)', fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0 }}>
{f.malfunctions as string}
</p>
</div>
)}

{f.improvements && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--accent2)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.4rem' }}>Improvements</div>
<p style={{ color: 'var(--text2)', fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0 }}>
{f.improvements as string}
</p>
</div>
)}

{f.comments && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.4rem' }}>Additional Comments</div>
<p style={{ color: 'var(--text2)', fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0 }}>
{f.comments as string}
</p>
</div>
)}

{f.attachment_url && (
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.4rem' }}>Attachment</div>
<a href={f.attachment_url as string} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-ghost" style={{ fontSize: 12 }}>
View Document →
</a>
</div>
)}
</div>
))}
</div>
)}
</div>
</main>
</div>
);
}
181 changes: 181 additions & 0 deletions app/participant/feedback/FeedbackForm.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Rating */}
<div className="card">
<div className="card-header">
<span className="card-title">Overall Experience</span>
</div>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center', padding: '1.5rem 0' }}>
{ratingConfig.map(({ value, icon: Icon, label, color }) => (
<button
key={value}
type="button"
onClick={() => setRating(value)}
className="btn"
style={{
width: 80,
height: 80,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
background: rating === value ? `${color}15` : 'var(--bg3)',
border: rating === value ? `2px solid ${color}` : '1px solid var(--border)',
color: rating === value ? color : 'var(--text3)',
transition: 'all 0.2s',
}}
>
<Icon size={28} strokeWidth={2} />
<span style={{ fontSize: 11, fontWeight: 600 }}>{label}</span>
</button>
))}
</div>
</div>

{/* Challenges */}
<div className="card">
<div className="card-header">
<span className="card-title">Challenges Encountered</span>
</div>
<textarea
className="form-textarea"
rows={4}
value={challenges}
onChange={(e) => setChallenges(e.target.value)}
placeholder="Describe any difficulties you faced during the exercise..."
style={{ border: 'none', background: 'var(--bg2)' }}
/>
</div>

{/* Malfunctions */}
<div className="card">
<div className="card-header">
<span className="card-title">Bugs & Technical Issues</span>
</div>
<textarea
className="form-textarea"
rows={4}
value={malfunctions}
onChange={(e) => setMalfunctions(e.target.value)}
placeholder="Report any errors, crashes, or unexpected behavior..."
style={{ border: 'none', background: 'var(--bg2)' }}
/>
</div>

{/* Improvements */}
<div className="card">
<div className="card-header">
<span className="card-title">Suggested Improvements</span>
</div>
<textarea
className="form-textarea"
rows={4}
value={improvements}
onChange={(e) => setImprovements(e.target.value)}
placeholder="Share your ideas for making the platform better..."
style={{ border: 'none', background: 'var(--bg2)' }}
/>
</div>

{/* Additional Comments */}
<div className="card">
<div className="card-header">
<span className="card-title">Additional Comments</span>
<span className="badge badge-gray">Optional</span>
</div>
<textarea
className="form-textarea"
rows={3}
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Any other feedback you'd like to share..."
style={{ border: 'none', background: 'var(--bg2)' }}
/>
</div>

{/* Attachment */}
<div className="card">
<div className="card-header">
<span className="card-title">Supporting Document</span>
<span className="badge badge-gray">Optional</span>
</div>
<input
type="url"
className="form-input"
value={attachmentUrl}
onChange={(e) => setAttachmentUrl(e.target.value)}
placeholder="Paste a link to a screenshot or document (Google Drive, Imgur, etc.)"
style={{ border: 'none', background: 'var(--bg2)' }}
/>
</div>

{/* Submit */}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button type="submit" disabled={submitting} className="btn btn-primary btn-lg">
{submitting ? 'Submitting…' : 'Submit Feedback'}
</button>
<button type="button" onClick={() => router.push('/participant')} className="btn btn-ghost btn-lg">
Cancel
</button>
</div>
</form>
);
}
Loading
Loading