mirror of
https://github.com/th30d4y/OpenLearnX.git
synced 2026-05-26 11:25:49 +00:00
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
'use client'
|
|
import React, { useState, useEffect } from 'react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import { Brain, Trophy, Target, ArrowRight, CheckCircle, XCircle } from 'lucide-react'
|
|
|
|
interface Question {
|
|
question_id: string
|
|
question_text: string
|
|
options: string[]
|
|
correct_answer: string
|
|
difficulty: string
|
|
points: number
|
|
explanation: string
|
|
}
|
|
|
|
interface SessionStats {
|
|
current_difficulty?: string
|
|
consecutive_correct?: {
|
|
easy: number
|
|
medium: number
|
|
hard: number
|
|
}
|
|
total_questions?: number
|
|
correct_answers?: number
|
|
score?: number
|
|
accuracy?: number
|
|
}
|
|
|
|
export default function QuizPlayPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const sessionId = params.sessionId as string
|
|
|
|
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null)
|
|
const [sessionStats, setSessionStats] = useState<SessionStats>({
|
|
current_difficulty: 'easy',
|
|
consecutive_correct: { easy: 0, medium: 0, hard: 0 },
|
|
total_questions: 0,
|
|
correct_answers: 0,
|
|
score: 0,
|
|
accuracy: 0
|
|
})
|
|
const [selectedAnswer, setSelectedAnswer] = useState<string>('')
|
|
const [showResult, setShowResult] = useState(false)
|
|
const [lastResult, setLastResult] = useState<any>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [quizCompleted, setQuizCompleted] = useState(false)
|
|
|
|
// ✅ Safe getter for current difficulty with fallback
|
|
const getCurrentDifficulty = () => {
|
|
return sessionStats?.current_difficulty || 'easy'
|
|
}
|
|
|
|
// ✅ Safe getter for consecutive correct with fallback
|
|
const getConsecutiveCorrect = () => {
|
|
return sessionStats?.consecutive_correct || { easy: 0, medium: 0, hard: 0 }
|
|
}
|
|
|
|
// Fetch next question
|
|
const fetchNextQuestion = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
|
|
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/next-question`, {
|
|
headers: {
|
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
}
|
|
})
|
|
const data = await response.json()
|
|
|
|
console.log('Next question response:', data) // ✅ Debug log
|
|
|
|
if (data.success) {
|
|
if (data.quiz_completed) {
|
|
setQuizCompleted(true)
|
|
setCurrentQuestion(null)
|
|
} else {
|
|
setCurrentQuestion(data.question)
|
|
// ✅ Safely update session stats with fallbacks
|
|
setSessionStats(prev => ({
|
|
current_difficulty: data.session_stats?.current_difficulty || prev.current_difficulty || 'easy',
|
|
consecutive_correct: data.session_stats?.consecutive_correct || prev.consecutive_correct || { easy: 0, medium: 0, hard: 0 },
|
|
total_questions: data.session_stats?.total_questions || prev.total_questions || 0,
|
|
correct_answers: data.session_stats?.correct_answers || prev.correct_answers || 0,
|
|
score: data.session_stats?.score || prev.score || 0,
|
|
accuracy: data.session_stats?.accuracy || prev.accuracy || 0
|
|
}))
|
|
}
|
|
} else {
|
|
alert(`Error: ${data.error}`)
|
|
}
|
|
} catch (error) {
|
|
console.error('Fetch question error:', error)
|
|
alert('Failed to fetch next question')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Submit answer
|
|
const submitAnswer = async () => {
|
|
if (!selectedAnswer || !currentQuestion) return
|
|
|
|
try {
|
|
setLoading(true)
|
|
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
|
|
const storedUserRaw = localStorage.getItem("openlearnx_user")
|
|
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
|
|
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/submit-answer`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
},
|
|
body: JSON.stringify({
|
|
answer: selectedAnswer,
|
|
question_data: currentQuestion,
|
|
user_id: storedUser?.id,
|
|
wallet_address: storedUser?.wallet_address
|
|
})
|
|
})
|
|
|
|
const data = await response.json()
|
|
console.log('Submit answer response:', data) // ✅ Debug log
|
|
|
|
if (data.success) {
|
|
setLastResult(data)
|
|
setShowResult(true)
|
|
|
|
// ✅ Safely update session stats with fallbacks
|
|
setSessionStats(prev => ({
|
|
current_difficulty: data.session_stats?.current_difficulty || prev.current_difficulty || 'easy',
|
|
consecutive_correct: data.session_stats?.consecutive_correct || prev.consecutive_correct || { easy: 0, medium: 0, hard: 0 },
|
|
total_questions: data.session_stats?.total_questions || prev.total_questions || 0,
|
|
correct_answers: data.session_stats?.correct_answers || prev.correct_answers || 0,
|
|
score: data.session_stats?.score || prev.score || 0,
|
|
accuracy: data.session_stats?.accuracy || prev.accuracy || 0
|
|
}))
|
|
} else {
|
|
alert(`Error: ${data.error}`)
|
|
}
|
|
} catch (error) {
|
|
console.error('Submit answer error:', error)
|
|
alert('Failed to submit answer')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Continue to next question
|
|
const continueToNext = () => {
|
|
setShowResult(false)
|
|
setSelectedAnswer('')
|
|
setLastResult(null)
|
|
fetchNextQuestion()
|
|
}
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
if (sessionId) {
|
|
fetchNextQuestion()
|
|
}
|
|
}, [sessionId])
|
|
|
|
const getDifficultyColor = (difficulty: string) => {
|
|
switch (difficulty) {
|
|
case 'easy': return 'text-green-400 bg-green-900'
|
|
case 'medium': return 'text-yellow-400 bg-yellow-900'
|
|
case 'hard': return 'text-red-400 bg-red-900'
|
|
default: return 'text-gray-400 bg-gray-700'
|
|
}
|
|
}
|
|
|
|
if (loading && !currentQuestion) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto mb-4"></div>
|
|
<p>Loading question...</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (quizCompleted) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
|
<div className="max-w-2xl mx-auto p-6 text-center">
|
|
<Trophy className="h-16 w-16 text-yellow-400 mx-auto mb-4" />
|
|
<h1 className="text-3xl font-bold mb-4">🎉 Quiz Completed!</h1>
|
|
|
|
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
|
<h2 className="text-xl font-bold mb-4">Final Results</h2>
|
|
<div className="grid grid-cols-2 gap-4 text-center">
|
|
<div className="bg-gray-700 p-4 rounded">
|
|
<div className="text-2xl font-bold text-blue-400">{sessionStats.score || 0}</div>
|
|
<div className="text-gray-400">Final Score</div>
|
|
</div>
|
|
<div className="bg-gray-700 p-4 rounded">
|
|
<div className="text-2xl font-bold text-green-400">{sessionStats.accuracy || 0}%</div>
|
|
<div className="text-gray-400">Accuracy</div>
|
|
</div>
|
|
<div className="bg-gray-700 p-4 rounded">
|
|
<div className="text-2xl font-bold text-purple-400">{sessionStats.total_questions || 0}</div>
|
|
<div className="text-gray-400">Questions</div>
|
|
</div>
|
|
<div className="bg-gray-700 p-4 rounded">
|
|
<div className={`text-2xl font-bold px-3 py-1 rounded ${getDifficultyColor(getCurrentDifficulty())}`}>
|
|
{getCurrentDifficulty().toUpperCase()}
|
|
</div>
|
|
<div className="text-gray-400">Final Level</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => router.push('/quizzes')}
|
|
className="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-semibold"
|
|
>
|
|
Back to Quizzes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (showResult && lastResult) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
|
<div className="max-w-2xl mx-auto p-6">
|
|
<div className="text-center mb-6">
|
|
{lastResult.is_correct ? (
|
|
<CheckCircle className="h-16 w-16 text-green-400 mx-auto mb-4" />
|
|
) : (
|
|
<XCircle className="h-16 w-16 text-red-400 mx-auto mb-4" />
|
|
)}
|
|
<h1 className="text-3xl font-bold mb-2">
|
|
{lastResult.is_correct ? '✅ Correct!' : '❌ Incorrect'}
|
|
</h1>
|
|
<p className="text-gray-400">
|
|
{lastResult.is_correct ? 'Great job!' : 'Keep trying!'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
|
<h3 className="font-semibold mb-2">Correct Answer:</h3>
|
|
<p className="text-green-400 mb-4">{lastResult.correct_answer}</p>
|
|
|
|
{lastResult.explanation && (
|
|
<div>
|
|
<h3 className="font-semibold mb-2">Explanation:</h3>
|
|
<p className="text-gray-300">{lastResult.explanation}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Difficulty Change Notification */}
|
|
{lastResult.difficulty_changed && (
|
|
<div className="bg-blue-900 border border-blue-600 p-4 rounded-lg mb-6">
|
|
<h3 className="font-semibold mb-2">📈 Difficulty Updated!</h3>
|
|
<p>
|
|
Moved from <span className={`px-2 py-1 rounded ${getDifficultyColor(lastResult.previous_difficulty)}`}>
|
|
{lastResult.previous_difficulty}
|
|
</span> to <span className={`px-2 py-1 rounded ${getDifficultyColor(lastResult.new_difficulty)}`}>
|
|
{lastResult.new_difficulty}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Session Stats */}
|
|
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
|
<h3 className="font-semibold mb-4">📊 Your Progress</h3>
|
|
<div className="grid grid-cols-2 gap-4 text-center">
|
|
<div>
|
|
<div className="text-xl font-bold text-blue-400">{sessionStats.score || 0}</div>
|
|
<div className="text-gray-400 text-sm">Score</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xl font-bold text-green-400">{sessionStats.accuracy || 0}%</div>
|
|
<div className="text-gray-400 text-sm">Accuracy</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AI Feedback */}
|
|
{lastResult.ai_feedback && (
|
|
<div className="bg-purple-900 border border-purple-600 p-4 rounded-lg mb-6">
|
|
<h3 className="font-semibold mb-2 flex items-center space-x-2">
|
|
<Brain className="h-5 w-5" />
|
|
<span>🤖 AI Analysis</span>
|
|
</h3>
|
|
<p className="text-purple-200">
|
|
AI predicted: <span className="font-semibold">{lastResult.ai_feedback.ai_prediction}</span>
|
|
{lastResult.ai_feedback.ai_agrees ? ' ✅ (Agrees with correct answer)' : ' ❌ (Disagrees)'}
|
|
</p>
|
|
<p className="text-xs text-purple-300 mt-1">
|
|
Confidence: {Math.round(lastResult.ai_feedback.ai_confidence * 100)}%
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={continueToNext}
|
|
className="w-full bg-blue-600 hover:bg-blue-700 p-4 rounded-lg font-semibold flex items-center justify-center space-x-2"
|
|
>
|
|
<span>Continue to Next Question</span>
|
|
<ArrowRight className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!currentQuestion) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
|
<div className="text-center">
|
|
<p>No question available</p>
|
|
<button
|
|
onClick={() => router.push('/quizzes')}
|
|
className="mt-4 bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded"
|
|
>
|
|
Back to Quizzes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 text-white">
|
|
<div className="max-w-4xl mx-auto p-6">
|
|
{/* Header with Stats */}
|
|
<div className="bg-gray-800 p-4 rounded-lg mb-6">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center space-x-4">
|
|
<span className={`px-3 py-1 rounded font-semibold ${getDifficultyColor(getCurrentDifficulty())}`}>
|
|
{getCurrentDifficulty().toUpperCase()}
|
|
</span>
|
|
<span className="text-gray-400">
|
|
Question {(sessionStats.total_questions || 0) + 1}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center space-x-6 text-sm">
|
|
<div className="text-center">
|
|
<div className="font-bold text-blue-400">{sessionStats.score || 0}</div>
|
|
<div className="text-gray-400">Score</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="font-bold text-green-400">{sessionStats.accuracy || 0}%</div>
|
|
<div className="text-gray-400">Accuracy</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="font-bold text-purple-400">{getConsecutiveCorrect()[getCurrentDifficulty()] || 0}</div>
|
|
<div className="text-gray-400">Streak</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Question */}
|
|
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
|
<h1 className="text-2xl font-bold mb-6">{currentQuestion.question_text}</h1>
|
|
|
|
<div className="space-y-3">
|
|
{currentQuestion.options.map((option, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => setSelectedAnswer(option)}
|
|
className={`w-full p-4 text-left rounded-lg border-2 transition-colors ${
|
|
selectedAnswer === option
|
|
? 'border-blue-500 bg-blue-900'
|
|
: 'border-gray-600 bg-gray-700 hover:border-gray-500'
|
|
}`}
|
|
>
|
|
<span className="font-semibold mr-3">{String.fromCharCode(65 + index)})</span>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<button
|
|
onClick={submitAnswer}
|
|
disabled={!selectedAnswer || loading}
|
|
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 p-4 rounded-lg font-semibold flex items-center justify-center space-x-2"
|
|
>
|
|
{loading ? (
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
|
) : (
|
|
<>
|
|
<span>Submit Answer</span>
|
|
<ArrowRight className="h-5 w-5" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|