310 lines
12 KiB
TypeScript
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">SkillQuiz</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>
|
|
)
|
|
}
|