SolyQuiz/app/quiz/[code]/exam/ExamClient.tsx

310 lines
12 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { ChevronLeft, ChevronRight, Send, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Answer {
id: string
text: string
}
interface Question {
id: string
text: string
order: number
answers: Answer[]
selectedAnswerId: string | null
}
interface Props {
sessionCode: string
participationId: string
studentName: string
questions: Question[]
}
export default function ExamClient({ sessionCode, participationId, studentName, questions: initialQuestions }: Props) {
const router = useRouter()
const [questions, setQuestions] = useState(initialQuestions)
const [currentIndex, setCurrentIndex] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [finishing, setFinishing] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentQuestion = questions[currentIndex]
const progress = Math.round(((currentIndex + 1) / questions.length) * 100)
const answeredCount = questions.filter((q) => q.selectedAnswerId !== null).length
const handleSelectAnswer = async (answerId: string) => {
if (submitting) return
const previousAnswerId = currentQuestion.selectedAnswerId
setQuestions((prev) =>
prev.map((q, i) =>
i === currentIndex ? { ...q, selectedAnswerId: answerId } : q
)
)
setSubmitting(true)
setError(null)
try {
const res = await fetch('/api/student/submit-answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
participation_id: participationId,
question_id: currentQuestion.id,
answer_id: answerId,
}),
})
const data = await res.json()
if (!res.ok) {
// Rollback
setQuestions((prev) =>
prev.map((q, i) =>
i === currentIndex ? { ...q, selectedAnswerId: previousAnswerId } : q
)
)
if (data.code === 'SESSION_INACTIVE') {
setError('Ce quiz a été terminé par le formateur.')
} else {
setError(data.error ?? 'Erreur enregistrement')
}
}
} catch {
setQuestions((prev) =>
prev.map((q, i) =>
i === currentIndex ? { ...q, selectedAnswerId: previousAnswerId } : q
)
)
setError('Erreur réseau')
} finally {
setSubmitting(false)
}
}
const handleFinish = async () => {
setFinishing(true)
setError(null)
try {
const res = await fetch('/api/student/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ participation_id: participationId }),
})
const data = await res.json()
if (res.ok) {
router.push(`/quiz/${sessionCode}/results?pid=${participationId}`)
} else {
setError(data.error ?? 'Erreur lors de la finalisation')
setFinishing(false)
}
} catch {
setError('Erreur réseau')
setFinishing(false)
}
}
const isLastQuestion = currentIndex === questions.length - 1
const allAnswered = answeredCount === questions.length
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-background-secondary px-4 md:px-8 py-3 md:py-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z" fill="white"/>
</svg>
</div>
<span className="font-bold text-text-primary">SolyQuiz</span>
</div>
<div className="flex items-center gap-3 md:gap-6">
<div className="flex items-center gap-1.5 bg-background-card border border-border rounded-lg px-2.5 py-1.5">
<span className="text-xs text-text-muted hidden sm:inline">Session</span>
<span className="font-mono font-bold text-primary text-sm">{sessionCode}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-7 h-7 md:w-8 md:h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary text-xs font-bold flex-shrink-0">
{studentName.split(' ').map((n) => n[0]).join('')}
</div>
<span className="text-xs md:text-sm text-text-secondary hidden sm:inline">{studentName}</span>
</div>
</div>
</header>
<div className="max-w-4xl mx-auto px-3 md:px-4 py-4 md:py-8">
{/* Progress */}
<div className="mb-8">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-text-secondary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="2" width="6" height="6" rx="1" /><path d="M12 8v4l3 3" /><circle cx="12" cy="14" r="7" />
</svg>
<span className="text-sm font-medium">
Question <span className="text-text-primary font-bold">{currentIndex + 1}</span> sur {questions.length}
</span>
</div>
<span className="text-sm font-semibold text-text-primary">{progress}%</span>
</div>
<div className="h-2 bg-background-elevated rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Question layout */}
<div className="flex flex-col md:flex-row gap-4 md:gap-6">
{/* Image placeholder — masquée sur mobile */}
<div className="hidden md:block w-72 flex-shrink-0">
<div className="h-80 rounded-xl overflow-hidden bg-gradient-to-br from-background-elevated to-background-card border border-border flex items-end p-4 relative">
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-primary to-blue-700" />
<div className="relative z-10">
<span className="text-xs bg-primary/80 text-white px-2 py-0.5 rounded mb-1 block w-fit">
Question {currentIndex + 1}
</span>
<p className="text-sm font-semibold text-text-primary line-clamp-2">
{currentQuestion.text}
</p>
</div>
</div>
</div>
{/* Question & Answers */}
<div className="flex-1 min-w-0">
<div className="card p-6 mb-4">
<h2 className="text-lg font-semibold text-text-primary mb-1">
Question {currentIndex + 1}
</h2>
<p className="text-text-secondary mb-6">{currentQuestion.text}</p>
<div className="space-y-3">
{currentQuestion.answers.map((answer) => {
const isSelected = currentQuestion.selectedAnswerId === answer.id
return (
<button
key={answer.id}
onClick={() => handleSelectAnswer(answer.id)}
disabled={submitting}
className={cn(
'w-full flex items-center gap-3 p-4 rounded-lg border text-left transition-all',
isSelected
? 'border-primary bg-primary/10 text-text-primary'
: 'border-border bg-background-elevated hover:border-border-light text-text-secondary hover:text-text-primary',
submitting && 'cursor-not-allowed opacity-70'
)}
>
<div className={cn(
'w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center transition-all',
isSelected ? 'border-primary bg-primary' : 'border-border-light'
)}>
{isSelected && (
<div className="w-2 h-2 bg-white rounded-full" />
)}
</div>
<span className="text-sm">{answer.text}</span>
</button>
)
})}
</div>
</div>
{error && (
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg mb-4">
<AlertCircle size={14} />
{error}
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between gap-2">
<button
onClick={() => setCurrentIndex((i) => Math.max(0, i - 1))}
disabled={currentIndex === 0}
className={cn(
'flex items-center gap-1.5 text-sm px-3 md:px-4 py-2.5 rounded-lg border transition-all',
currentIndex === 0
? 'border-border text-text-muted cursor-not-allowed opacity-40'
: 'border-border-light text-text-secondary hover:text-text-primary hover:border-border-light bg-background-card hover:bg-background-elevated'
)}
>
<ChevronLeft size={16} />
Précédent
</button>
{isLastQuestion ? (
<button
onClick={handleFinish}
disabled={finishing || !allAnswered}
className={cn(
'flex items-center gap-2 text-sm px-6 py-2.5 rounded-lg font-semibold transition-all',
allAnswered && !finishing
? 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-background-elevated text-text-muted cursor-not-allowed',
finishing && 'opacity-70'
)}
>
{finishing ? (
<>
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Finalisation...
</>
) : (
<>
<Send size={14} />
{allAnswered ? 'Terminer le quiz' : `Répondre à toutes les questions (${answeredCount}/${questions.length})`}
</>
)}
</button>
) : (
<button
onClick={() => setCurrentIndex((i) => Math.min(questions.length - 1, i + 1))}
className="btn-primary text-sm px-6 py-2.5"
>
Suivant
<ChevronRight size={16} />
</button>
)}
</div>
{/* Question dots */}
<div className="flex items-center gap-1.5 justify-center mt-6 flex-wrap">
{questions.map((q, i) => (
<button
key={q.id}
onClick={() => setCurrentIndex(i)}
className={cn(
'w-6 h-6 rounded-full text-xs font-medium transition-all',
i === currentIndex
? 'bg-primary text-white scale-110'
: q.selectedAnswerId
? 'bg-primary/30 text-primary hover:bg-primary/50'
: 'bg-background-elevated text-text-muted hover:bg-background-card'
)}
>
{i + 1}
</button>
))}
</div>
</div>
</div>
</div>
</div>
)
}