trading-platform-frontend-v2/src/modules/education/pages/Quiz.tsx
rckrdmrd 5b53c2539a feat: Initial commit - Trading Platform Frontend
React frontend with:
- Authentication UI
- Trading dashboard
- ML signals display
- Portfolio management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:30:39 -06:00

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>
);
}