feat: token count implement

This commit is contained in:
2026-03-17 12:07:56 -03:00
parent 41279585f6
commit be699ad6ab
44 changed files with 9032 additions and 167 deletions
@@ -364,6 +364,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
return (
<QuizPlayer
id={block.id}
lessonId={params.lessonId}
courseId={params.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
allowRetry={lesson.allow_retry}
@@ -373,25 +375,32 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
: 0
}
onAttempt={async () => {
existingGrade={
userGrade?.metadata?.quiz_answers
? {
score: userGrade.score,
answers: userGrade.metadata.quiz_answers[block.id] || userGrade.metadata.quiz_answers,
created_at: userGrade.created_at,
}
: undefined
}
onAttempt={async (score, answers) => {
if (user) {
const currentAttempts = (userGrade?.metadata?.block_attempts as Record<string, number>) || {};
const newAttempts = {
...currentAttempts,
[block.id]: (currentAttempts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
// Submit the score for this specific quiz block
const res = await lmsApi.submitLessonScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, block_attempts: newAttempts }
score,
{
quiz_answers: { [block.id]: answers },
quiz_type: block.quiz_data?.test_type || 'quiz',
}
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar los intentos del bloque", err);
console.error("Error al guardar el puntaje del quiz", err);
}
}
}}
@@ -1,33 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import { lmsApi } from "@/lib/api";
interface QuizQuestion {
id: string;
question: string;
options: string[];
correct: number[];
correct: number | number[];
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
explanation?: string;
points?: number;
}
interface QuizPlayerProps {
id: string;
lessonId: string;
courseId: string;
title?: string;
quizData: {
questions: QuizQuestion[];
instructions?: string;
passing_score?: number;
total_points?: number;
max_attempts?: number;
show_feedback?: boolean;
permanent_history?: boolean;
};
allowRetry?: boolean;
maxAttempts?: number;
initialAttempts?: number;
onAttempt?: () => void;
existingGrade?: {
score: number;
answers: any;
created_at: string;
};
onAttempt?: (score: number, answers: any) => void;
}
export default function QuizPlayer({ id, title, quizData, allowRetry = true, maxAttempts = 0, initialAttempts = 0, onAttempt }: QuizPlayerProps) {
export default function QuizPlayer({
id,
lessonId,
courseId,
title,
quizData,
allowRetry = true,
maxAttempts = 0,
initialAttempts = 0,
existingGrade,
onAttempt
}: QuizPlayerProps) {
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
const [submitted, setSubmitted] = useState(false);
const [attempts, setAttempts] = useState(initialAttempts || 0);
const [submitting, setSubmitting] = useState(false);
const [score, setScore] = useState<number | null>(null);
const [showHistory, setShowHistory] = useState(false);
const questions = quizData?.questions || [];
const isSingleAttempt = maxAttempts === 1 || quizData.max_attempts === 1;
const hasExistingGrade = existingGrade !== undefined && existingGrade !== null;
// Sync attempts with prop
useEffect(() => {
@@ -36,8 +68,18 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
}
}, [initialAttempts]);
// Load existing grade if available
useEffect(() => {
if (hasExistingGrade && existingGrade?.answers) {
setUserAnswers(existingGrade.answers);
setSubmitted(true);
setScore(existingGrade.score * 100);
}
}, [existingGrade]);
const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => {
if (submitted) return;
if (submitted) return; // No changes after submission
setUserAnswers(prev => {
const current = prev[qId] || [];
if (isMulti) {
@@ -51,22 +93,234 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
});
};
const handleValidate = () => {
const calculateScore = () => {
let totalPoints = 0;
let earnedPoints = 0;
questions.forEach(q => {
const points = q.points || 1;
totalPoints += points;
const userAnswer = userAnswers[q.id] || [];
const correctAnswer = Array.isArray(q.correct) ? q.correct : [q.correct];
// Check if all correct answers are selected and no incorrect ones
const allCorrectSelected = correctAnswer.every(idx => userAnswer.includes(idx));
const noIncorrectSelected = userAnswer.every(idx => correctAnswer.includes(idx));
if (allCorrectSelected && noIncorrectSelected) {
earnedPoints += points;
}
});
return totalPoints > 0 ? earnedPoints / totalPoints : 0;
};
const handleValidate = async () => {
if (maxAttempts > 0 && attempts >= maxAttempts) return;
setSubmitted(true);
if (onAttempt) {
onAttempt();
// Check if all questions are answered
const allAnswered = questions.every(q => userAnswers[q.id] && userAnswers[q.id].length > 0);
if (!allAnswered) {
if (!confirm('Hay preguntas sin responder. ¿Estás seguro de que deseas enviar?')) {
return;
}
}
try {
setSubmitting(true);
// Calculate score
const calculatedScore = calculateScore();
const scorePercent = Math.round(calculatedScore * 100);
setScore(scorePercent);
// Prepare answers metadata
const answersMetadata = {
answers: userAnswers,
questions: questions.map(q => ({
id: q.id,
question: q.question,
options: q.options,
correct: q.correct,
explanation: q.explanation,
points: q.points,
})),
submitted_at: new Date().toISOString(),
quiz_type: quizData.test_type || 'quiz',
};
// Submit to LMS API
await lmsApi.submitLessonScore(courseId, lessonId, calculatedScore, answersMetadata);
setSubmitted(true);
setAttempts(prev => prev + 1);
if (onAttempt) {
onAttempt(calculatedScore, answersMetadata);
}
// Show success message
alert(`¡Prueba enviada! Tu puntuación: ${scorePercent}%`);
} catch (error) {
console.error('Error submitting quiz:', error);
alert('Error al enviar la prueba. Por favor, inténtalo de nuevo.');
} finally {
setSubmitting(false);
}
};
const getQuestionScore = (q: QuizQuestion) => {
if (!submitted) return null;
const userAnswer = userAnswers[q.id] || [];
const correctAnswer = Array.isArray(q.correct) ? q.correct : [q.correct];
const allCorrectSelected = correctAnswer.every(idx => userAnswer.includes(idx));
const noIncorrectSelected = userAnswer.every(idx => correctAnswer.includes(idx));
if (allCorrectSelected && noIncorrectSelected) {
return 'correct';
} else if (userAnswer.length === 0) {
return 'unanswered';
} else {
return 'incorrect';
}
};
// If already submitted and single attempt, show read-only view
if (hasExistingGrade && isSingleAttempt) {
return (
<div className="space-y-6 notranslate" id={id} translate="no">
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">
{title || "Prueba Completada"}
</h3>
<div className="text-right">
<div className="text-3xl font-black text-blue-600 dark:text-blue-400">
{existingGrade?.score ? Math.round(existingGrade.score * 100) : 0}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{new Date(existingGrade?.created_at || Date.now()).toLocaleDateString()}
</div>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Completado
</span>
<span className="text-gray-500 dark:text-gray-400">
1 intento permitido
</span>
<button
onClick={() => setShowHistory(!showHistory)}
className="text-blue-600 hover:text-blue-700 font-medium"
>
{showHistory ? 'Ocultar detalles' : 'Ver mis respuestas'}
</button>
</div>
</div>
{showHistory && (
<div className="space-y-4">
{questions.map((q, qIdx) => {
const userAnswer = userAnswers[q.id] || [];
const correctAnswer = Array.isArray(q.correct) ? q.correct : [q.correct];
const questionScore = getQuestionScore(q);
return (
<div
key={q.id}
className={`border rounded-xl p-5 ${
questionScore === 'correct'
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/20'
: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20'
}`}
>
<div className="flex items-start gap-3 mb-3">
<span className={`text-lg font-bold ${
questionScore === 'correct' ? 'text-green-600' : 'text-red-600'
}`}>
{questionScore === 'correct' ? '✓' : '✗'}
</span>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white mb-3">
{qIdx + 1}. {q.question}
</p>
<div className="space-y-2">
{q.options.map((opt, oIdx) => {
const isSelected = userAnswer.includes(oIdx);
const isCorrect = correctAnswer.includes(oIdx);
let optionClass = "p-3 rounded-lg border text-sm ";
if (isCorrect) {
optionClass += "border-green-500 bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200";
} else if (isSelected && !isCorrect) {
optionClass += "border-red-500 bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200";
} else {
optionClass += "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 opacity-60";
}
return (
<div key={oIdx} className={optionClass}>
<div className="flex items-center justify-between">
<span>{opt}</span>
{isCorrect && <span className="text-green-600"> Correcta</span>}
{isSelected && !isCorrect && <span className="text-red-600"> Tu respuesta</span>}
</div>
</div>
);
})}
</div>
{q.explanation && (
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
📝 Explicación:
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
{q.explanation}
</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// Normal quiz mode (not yet submitted or allows retry)
return (
<div className="space-y-8 notranslate" id={id} translate="no">
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-600 dark:border-blue-500 pl-4 py-1 tracking-tight text-gray-900 dark:text-white uppercase tracking-widest text-[10px]">
{title || "Verificación de Conocimientos"}
</h3>
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold border-l-4 border-blue-600 dark:border-blue-500 pl-4 py-1 tracking-tight text-gray-900 dark:text-white uppercase tracking-widest text-[10px]">
{title || "Verificación de Conocimientos"}
</h3>
{isSingleAttempt && (
<span className="px-3 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 text-xs font-black uppercase tracking-widest rounded-full border border-red-200 dark:border-red-800">
1 Solo Intento
</span>
)}
</div>
{quizData.instructions && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Instrucciones:</strong> {quizData.instructions}
</p>
</div>
)}
{maxAttempts > 0 && (
<span className="text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 text-gray-500 dark:text-gray-400">
Intento {attempts} / {maxAttempts}
@@ -75,9 +329,15 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
</div>
<div className="space-y-8">
{questions.map((q) => (
<fieldset key={q.id} className="space-y-4 p-8 glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20" aria-labelledby={`q-${q.id}-text`}>
<legend id={`q-${q.id}-text`} className="font-bold text-xl text-gray-900 dark:text-gray-100 leading-tight mb-4">{q.question}</legend>
{questions.map((q, qIdx) => (
<fieldset
key={q.id}
className="space-y-4 p-8 glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20"
aria-labelledby={`q-${q.id}-text`}
>
<legend id={`q-${q.id}-text`} className="font-bold text-xl text-gray-900 dark:text-gray-100 leading-tight mb-4">
{qIdx + 1}. {q.question}
</legend>
<div
className="grid gap-3"
role={q.type === 'multiple-select' ? 'group' : 'radiogroup'}
@@ -103,11 +363,13 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
return (
<button
key={oIdx}
type="button"
role={q.type === 'multiple-select' ? 'checkbox' : 'radio'}
aria-checked={isSelected}
aria-disabled={submitted}
aria-disabled={submitted || submitting}
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus:ring-2 focus:ring-blue-500/50 ${style}`}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed ${style}`}
disabled={submitted || submitting}
>
<div className="flex items-center justify-between">
<span>{opt}</span>
@@ -123,34 +385,61 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
);
})}
</div>
{/* Show explanation after submission if enabled */}
{submitted && q.explanation && quizData.show_feedback && (
<div className="mt-4 p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<p className="text-sm font-medium text-purple-900 dark:text-purple-100 mb-1">
💡 Explicación:
</p>
<p className="text-sm text-purple-700 dark:text-purple-300">
{q.explanation}
</p>
</div>
)}
</fieldset>
))}
{allowRetry && (
<>
{!submitted && questions.length > 0 && (
<button
onClick={handleValidate}
disabled={maxAttempts > 0 && attempts >= maxAttempts}
className={`btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 ${maxAttempts > 0 && attempts >= maxAttempts ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{maxAttempts > 0 && attempts >= maxAttempts ? 'Máximo de Intentos Alcanzado' : 'Validar Respuestas'}
</button>
{!submitted && questions.length > 0 && (
<button
onClick={handleValidate}
disabled={submitting || (maxAttempts > 0 && attempts >= maxAttempts)}
className={`btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed ${
isSingleAttempt ? 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800' : ''
}`}
>
{submitting ? 'Enviando...' : (
isSingleAttempt
? 'Enviar Respuestas (1 Solo Intento)'
: maxAttempts > 0 && attempts >= maxAttempts
? 'Máximo de Intentos Alcanzado'
: 'Validar Respuestas'
)}
{submitted && (
<button
onClick={() => {
if (maxAttempts > 0 && attempts >= maxAttempts) return;
setSubmitted(false);
setUserAnswers({});
}}
disabled={maxAttempts > 0 && attempts >= maxAttempts}
className={`w-full py-5 glass text-blue-600 dark:text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-black/5 dark:hover:bg-white/5 transition-all rounded-3xl border-black/5 dark:border-white/5 ${maxAttempts > 0 && attempts >= maxAttempts ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{maxAttempts > 0 && attempts >= maxAttempts ? 'Máximo de Intentos Alcanzado' : 'Intentar de Nuevo'}
</button>
</button>
)}
{submitted && score !== null && (
<div className="bg-gradient-to-r from-green-50 to-blue-50 border-2 border-green-300 dark:border-green-700 rounded-2xl p-6 text-center">
<div className="text-sm font-bold text-gray-600 dark:text-gray-300 mb-2">Tu Puntuación</div>
<div className="text-5xl font-black text-green-600 dark:text-green-400 mb-4">
{score}%
</div>
{quizData.passing_score && (
<div className={`text-sm font-bold ${
score >= quizData.passing_score
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
{score >= quizData.passing_score ? '✓ Aprobado' : '✗ No Aprobado'}
(Mínimo: {quizData.passing_score}%)
</div>
)}
</>
{quizData.show_feedback && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
📍 Revisa las explicaciones de cada pregunta más arriba
</p>
)}
</div>
)}
</div>
</div>