React frontend with: - Authentication UI - Trading dashboard - ML signals display - Portfolio management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
542 lines
19 KiB
TypeScript
542 lines
19 KiB
TypeScript
/**
|
|
* Quiz Page
|
|
* Displays quiz questions and handles submission
|
|
*/
|
|
|
|
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
Award,
|
|
Loader2,
|
|
AlertCircle,
|
|
Play,
|
|
RotateCcw,
|
|
Home,
|
|
Trophy,
|
|
Zap,
|
|
} from 'lucide-react';
|
|
import { useEducationStore } from '../../../stores/educationStore';
|
|
import type { Quiz as QuizType, QuizQuestion } from '../../../types/education.types';
|
|
|
|
type QuizState = 'intro' | 'in_progress' | 'submitted';
|
|
|
|
export default function Quiz() {
|
|
const { courseSlug, lessonId, quizId } = useParams<{
|
|
courseSlug: string;
|
|
lessonId: string;
|
|
quizId?: string;
|
|
}>();
|
|
const navigate = useNavigate();
|
|
|
|
const {
|
|
currentQuiz,
|
|
currentAttempt,
|
|
quizResult,
|
|
loadingQuiz,
|
|
submittingQuiz,
|
|
error,
|
|
fetchQuiz,
|
|
startQuizAttempt,
|
|
submitQuiz,
|
|
resetQuizState,
|
|
} = useEducationStore();
|
|
|
|
const [quizState, setQuizState] = useState<QuizState>('intro');
|
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
const [answers, setAnswers] = useState<Map<string, string | string[]>>(new Map());
|
|
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
|
const [startTime, setStartTime] = useState<Date | null>(null);
|
|
|
|
// Load quiz
|
|
useEffect(() => {
|
|
if (lessonId) {
|
|
fetchQuiz(lessonId);
|
|
}
|
|
return () => {
|
|
resetQuizState();
|
|
};
|
|
}, [lessonId, fetchQuiz, resetQuizState]);
|
|
|
|
// Timer
|
|
useEffect(() => {
|
|
if (quizState !== 'in_progress' || !currentQuiz?.timeLimitMinutes) return;
|
|
|
|
const interval = setInterval(() => {
|
|
if (startTime && currentQuiz.timeLimitMinutes) {
|
|
const elapsed = Math.floor((Date.now() - startTime.getTime()) / 1000);
|
|
const remaining = currentQuiz.timeLimitMinutes * 60 - elapsed;
|
|
|
|
if (remaining <= 0) {
|
|
handleSubmit();
|
|
} else {
|
|
setTimeRemaining(remaining);
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [quizState, startTime, currentQuiz?.timeLimitMinutes]);
|
|
|
|
const currentQuestion = useMemo(() => {
|
|
if (!currentQuiz?.questions) return null;
|
|
return currentQuiz.questions[currentQuestionIndex];
|
|
}, [currentQuiz, currentQuestionIndex]);
|
|
|
|
const totalQuestions = currentQuiz?.questions?.length || 0;
|
|
const answeredCount = answers.size;
|
|
const progress = totalQuestions > 0 ? (answeredCount / totalQuestions) * 100 : 0;
|
|
|
|
const handleStart = async () => {
|
|
if (!currentQuiz) return;
|
|
try {
|
|
await startQuizAttempt(currentQuiz.id);
|
|
setQuizState('in_progress');
|
|
setStartTime(new Date());
|
|
if (currentQuiz.timeLimitMinutes) {
|
|
setTimeRemaining(currentQuiz.timeLimitMinutes * 60);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error starting quiz:', err);
|
|
}
|
|
};
|
|
|
|
const handleAnswer = (questionId: string, answer: string | string[]) => {
|
|
setAnswers((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(questionId, answer);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!currentAttempt) return;
|
|
|
|
const answersArray = Array.from(answers.entries()).map(([questionId, answer]) => ({
|
|
questionId,
|
|
answer,
|
|
}));
|
|
|
|
try {
|
|
await submitQuiz(currentAttempt.id, answersArray);
|
|
setQuizState('submitted');
|
|
} catch (err) {
|
|
console.error('Error submitting quiz:', err);
|
|
}
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
setQuizState('intro');
|
|
setCurrentQuestionIndex(0);
|
|
setAnswers(new Map());
|
|
setTimeRemaining(null);
|
|
setStartTime(null);
|
|
resetQuizState();
|
|
if (lessonId) {
|
|
fetchQuiz(lessonId);
|
|
}
|
|
};
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// Loading state
|
|
if (loadingQuiz) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen bg-gray-900">
|
|
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (error || !currentQuiz) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-900">
|
|
<AlertCircle className="w-16 h-16 text-red-400 mb-4" />
|
|
<h2 className="text-xl font-bold text-white mb-2">Quiz no encontrado</h2>
|
|
<p className="text-gray-400 mb-6">{error || 'El quiz no existe o no tienes acceso'}</p>
|
|
<Link
|
|
to={`/education/courses/${courseSlug}/lesson/${lessonId}`}
|
|
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors"
|
|
>
|
|
<ArrowLeft className="w-5 h-5" />
|
|
Volver a la Lección
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Intro State
|
|
if (quizState === 'intro') {
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
|
<div className="max-w-lg w-full bg-gray-800 rounded-xl border border-gray-700 p-8 text-center">
|
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-purple-500/20 mb-6">
|
|
<Award className="w-10 h-10 text-purple-400" />
|
|
</div>
|
|
|
|
<h1 className="text-2xl font-bold text-white mb-2">{currentQuiz.title}</h1>
|
|
|
|
{currentQuiz.description && (
|
|
<p className="text-gray-400 mb-6">{currentQuiz.description}</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
|
<div className="bg-gray-900 rounded-lg p-4">
|
|
<p className="text-2xl font-bold text-white">{totalQuestions}</p>
|
|
<p className="text-sm text-gray-400">Preguntas</p>
|
|
</div>
|
|
<div className="bg-gray-900 rounded-lg p-4">
|
|
<p className="text-2xl font-bold text-white">{currentQuiz.passingScore}%</p>
|
|
<p className="text-sm text-gray-400">Para aprobar</p>
|
|
</div>
|
|
{currentQuiz.timeLimitMinutes && (
|
|
<div className="bg-gray-900 rounded-lg p-4">
|
|
<p className="text-2xl font-bold text-white">{currentQuiz.timeLimitMinutes}</p>
|
|
<p className="text-sm text-gray-400">Minutos</p>
|
|
</div>
|
|
)}
|
|
{currentQuiz.maxAttempts && (
|
|
<div className="bg-gray-900 rounded-lg p-4">
|
|
<p className="text-2xl font-bold text-white">{currentQuiz.maxAttempts}</p>
|
|
<p className="text-sm text-gray-400">Intentos máx.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<button
|
|
onClick={handleStart}
|
|
className="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<Play className="w-5 h-5" />
|
|
Comenzar Quiz
|
|
</button>
|
|
<Link
|
|
to={`/education/courses/${courseSlug}/lesson/${lessonId}`}
|
|
className="w-full py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
|
>
|
|
Volver a la Lección
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Results State
|
|
if (quizState === 'submitted' && quizResult) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
|
<div className="max-w-lg w-full bg-gray-800 rounded-xl border border-gray-700 p-8 text-center">
|
|
<div
|
|
className={`inline-flex items-center justify-center w-20 h-20 rounded-full mb-6 ${
|
|
quizResult.passed ? 'bg-green-500/20' : 'bg-red-500/20'
|
|
}`}
|
|
>
|
|
{quizResult.passed ? (
|
|
<Trophy className="w-10 h-10 text-green-400" />
|
|
) : (
|
|
<XCircle className="w-10 h-10 text-red-400" />
|
|
)}
|
|
</div>
|
|
|
|
<h1 className="text-2xl font-bold text-white mb-2">
|
|
{quizResult.passed ? 'Felicidades!' : 'Sigue intentando'}
|
|
</h1>
|
|
|
|
<p className="text-gray-400 mb-6">
|
|
{quizResult.passed
|
|
? 'Has aprobado el quiz exitosamente'
|
|
: 'No alcanzaste el puntaje mínimo para aprobar'}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
|
<div className="bg-gray-900 rounded-lg p-4">
|
|
<p
|
|
className={`text-3xl font-bold ${
|
|
quizResult.passed ? 'text-green-400' : 'text-red-400'
|
|
}`}
|
|
>
|
|
{quizResult.percentage.toFixed(0)}%
|
|
</p>
|
|
<p className="text-sm text-gray-400">Tu puntaje</p>
|
|
</div>
|
|
<div className="bg-gray-900 rounded-lg p-4">
|
|
<p className="text-3xl font-bold text-white">
|
|
{quizResult.score}/{quizResult.maxScore}
|
|
</p>
|
|
<p className="text-sm text-gray-400">Puntos</p>
|
|
</div>
|
|
</div>
|
|
|
|
{quizResult.xpAwarded && quizResult.xpAwarded > 0 && (
|
|
<div className="bg-purple-500/20 rounded-lg p-4 mb-6 flex items-center justify-center gap-2">
|
|
<Zap className="w-5 h-5 text-purple-400" />
|
|
<span className="text-purple-400 font-medium">+{quizResult.xpAwarded} XP ganados</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Question Results */}
|
|
{quizResult.results && (
|
|
<div className="text-left mb-6">
|
|
<h3 className="font-medium text-white mb-3">Resumen de respuestas:</h3>
|
|
<div className="space-y-2">
|
|
{quizResult.results.map((result, index) => (
|
|
<div
|
|
key={result.questionId}
|
|
className={`flex items-center gap-3 p-3 rounded-lg ${
|
|
result.isCorrect ? 'bg-green-500/10' : 'bg-red-500/10'
|
|
}`}
|
|
>
|
|
{result.isCorrect ? (
|
|
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0" />
|
|
) : (
|
|
<XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
|
)}
|
|
<span className="text-sm text-gray-300">
|
|
Pregunta {index + 1}: {result.pointsEarned}/{result.maxPoints} pts
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{!quizResult.passed && (
|
|
<button
|
|
onClick={handleRetry}
|
|
className="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<RotateCcw className="w-5 h-5" />
|
|
Intentar de nuevo
|
|
</button>
|
|
)}
|
|
<Link
|
|
to={`/education/courses/${courseSlug}/lesson/${lessonId}`}
|
|
className="w-full py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<ArrowLeft className="w-5 h-5" />
|
|
Volver a la Lección
|
|
</Link>
|
|
<Link
|
|
to={`/education/courses/${courseSlug}`}
|
|
className="w-full py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<Home className="w-5 h-5" />
|
|
Ir al Curso
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// In Progress State
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 flex flex-col">
|
|
{/* Header */}
|
|
<header className="bg-gray-800 border-b border-gray-700 p-4">
|
|
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
|
<div>
|
|
<h1 className="font-bold text-white">{currentQuiz.title}</h1>
|
|
<p className="text-sm text-gray-400">
|
|
Pregunta {currentQuestionIndex + 1} de {totalQuestions}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
{timeRemaining !== null && (
|
|
<div
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${
|
|
timeRemaining < 60 ? 'bg-red-500/20 text-red-400' : 'bg-gray-700 text-gray-300'
|
|
}`}
|
|
>
|
|
<Clock className="w-4 h-4" />
|
|
<span className="font-mono font-medium">{formatTime(timeRemaining)}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-sm text-gray-400">
|
|
{answeredCount}/{totalQuestions} respondidas
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="h-1 bg-gray-800">
|
|
<div
|
|
className="h-full bg-purple-500 transition-all duration-300"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Question */}
|
|
<main className="flex-1 flex items-center justify-center p-4">
|
|
{currentQuestion && (
|
|
<QuestionCard
|
|
question={currentQuestion}
|
|
answer={answers.get(currentQuestion.id)}
|
|
onAnswer={(answer) => handleAnswer(currentQuestion.id, answer)}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
{/* Navigation */}
|
|
<footer className="bg-gray-800 border-t border-gray-700 p-4">
|
|
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
|
<button
|
|
onClick={() => setCurrentQuestionIndex((prev) => Math.max(0, prev - 1))}
|
|
disabled={currentQuestionIndex === 0}
|
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Anterior
|
|
</button>
|
|
|
|
{/* Question Dots */}
|
|
<div className="flex items-center gap-1 overflow-x-auto px-4">
|
|
{Array.from({ length: totalQuestions }, (_, i) => {
|
|
const questionId = currentQuiz.questions[i]?.id;
|
|
const isAnswered = questionId && answers.has(questionId);
|
|
const isCurrent = i === currentQuestionIndex;
|
|
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => setCurrentQuestionIndex(i)}
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
|
isCurrent
|
|
? 'bg-purple-600 text-white'
|
|
: isAnswered
|
|
? 'bg-green-500/20 text-green-400'
|
|
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{i + 1}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{currentQuestionIndex < totalQuestions - 1 ? (
|
|
<button
|
|
onClick={() => setCurrentQuestionIndex((prev) => prev + 1)}
|
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg font-medium transition-colors flex items-center gap-2"
|
|
>
|
|
Siguiente
|
|
<ArrowRight className="w-4 h-4" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={submittingQuiz || answeredCount < totalQuestions}
|
|
className="px-6 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
{submittingQuiz ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Enviando...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="w-4 h-4" />
|
|
Enviar Quiz
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Question Card Component
|
|
interface QuestionCardProps {
|
|
question: QuizQuestion;
|
|
answer?: string | string[];
|
|
onAnswer: (answer: string | string[]) => void;
|
|
}
|
|
|
|
function QuestionCard({ question, answer, onAnswer }: QuestionCardProps) {
|
|
const handleOptionClick = (optionId: string) => {
|
|
if (question.questionType === 'multiple_answer') {
|
|
const currentAnswers = Array.isArray(answer) ? answer : [];
|
|
if (currentAnswers.includes(optionId)) {
|
|
onAnswer(currentAnswers.filter((a) => a !== optionId));
|
|
} else {
|
|
onAnswer([...currentAnswers, optionId]);
|
|
}
|
|
} else {
|
|
onAnswer(optionId);
|
|
}
|
|
};
|
|
|
|
const isOptionSelected = (optionId: string) => {
|
|
if (Array.isArray(answer)) {
|
|
return answer.includes(optionId);
|
|
}
|
|
return answer === optionId;
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-2xl w-full bg-gray-800 rounded-xl border border-gray-700 p-6">
|
|
<h2 className="text-lg font-medium text-white mb-6">{question.questionText}</h2>
|
|
|
|
{question.questionType === 'short_answer' ? (
|
|
<input
|
|
type="text"
|
|
value={(answer as string) || ''}
|
|
onChange={(e) => onAnswer(e.target.value)}
|
|
placeholder="Escribe tu respuesta..."
|
|
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-purple-500 focus:outline-none"
|
|
/>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{question.options?.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
onClick={() => handleOptionClick(option.id)}
|
|
className={`w-full flex items-center gap-4 p-4 rounded-lg border transition-colors text-left ${
|
|
isOptionSelected(option.id)
|
|
? 'bg-purple-500/20 border-purple-500 text-white'
|
|
: 'bg-gray-900 border-gray-700 text-gray-300 hover:border-gray-600'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
|
isOptionSelected(option.id)
|
|
? 'border-purple-500 bg-purple-500'
|
|
: 'border-gray-600'
|
|
}`}
|
|
>
|
|
{isOptionSelected(option.id) && <CheckCircle className="w-4 h-4 text-white" />}
|
|
</div>
|
|
<span>{option.text}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{question.questionType === 'multiple_answer' && (
|
|
<p className="mt-4 text-sm text-gray-400">
|
|
Selecciona todas las respuestas correctas
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|