feat: Implement AI tutor functionality, add branding fields, and improve API URL handling.

This commit is contained in:
2026-01-23 14:48:41 -03:00
parent 60e2af72f0
commit 470c7f0172
30 changed files with 1352 additions and 274 deletions
+1 -1
View File
@@ -236,7 +236,7 @@ export default function ExperienceLoginPage() {
<div className="mt-6 pt-6 border-t border-white/10 text-center">
<p className="text-sm text-gray-400">
¿Eres un instructor?{" "}
<a href="http://localhost:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
<a href="http://192.168.0.254:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
Ir al Portal de Instructores
</a>
</p>
@@ -19,6 +19,8 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer";
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
import InteractiveTranscript from "@/components/InteractiveTranscript";
import AITutor from "@/components/AITutor";
import LessonLockedView from "@/components/LessonLockedView";
import { ListMusic } from "lucide-react";
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
@@ -188,179 +190,178 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</div>
)}
{/* Render Blocks */}
{(lesson.metadata?.blocks || []).length > 0 ? (
<div className="space-y-24">
{lesson.metadata?.blocks?.map((block) => {
const renderBlock = () => {
switch (block.type) {
case 'description':
return <DescriptionPlayer id={block.id} title={block.title} content={block.content || ""} />;
case 'media':
return (
<MediaPlayer
id={block.id}
lessonId={params.lessonId}
title={block.title}
url={block.url || ""}
media_type={block.media_type || 'video'}
config={block.config}
onTimeUpdate={setCurrentTime}
initialPlayCount={
userGrade?.metadata?.play_counts
? (userGrade.metadata.play_counts as Record<string, number>)[block.id] || 0
: 0
}
onPlay={async () => {
if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) {
const currentPlayCounts = (userGrade?.metadata?.play_counts as Record<string, number>) || {};
const newPlayCounts = {
...currentPlayCounts,
[block.id]: (currentPlayCounts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, play_counts: newPlayCounts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar el recuento de reproducciones", err);
}
}
}}
isGraded={lesson.is_graded}
hasTranscription={!!lesson.transcription}
/>
);
case 'document':
return <DocumentPlayer id={block.id} title={block.title} url={block.url || ""} />;
case 'quiz':
return (
<QuizPlayer
id={block.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
allowRetry={lesson.allow_retry}
maxAttempts={lesson.max_attempts || undefined}
initialAttempts={
userGrade?.metadata?.block_attempts
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
: 0
}
onAttempt={async () => {
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(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, block_attempts: newAttempts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar los intentos del bloque", err);
}
}
}}
/>
);
case 'fill-in-the-blanks':
return <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />;
case 'matching':
return <MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} allowRetry={lesson.allow_retry} />;
case 'ordering':
return <OrderingPlayer id={block.id} title={block.title} items={block.items || []} allowRetry={lesson.allow_retry} />;
case 'short-answer':
return (
<ShortAnswerPlayer
id={block.id}
title={block.title}
prompt={block.prompt || ""}
correctAnswers={block.correctAnswers || []}
allowRetry={lesson.allow_retry}
/>
);
case 'audio-response':
return (
<AudioResponsePlayer
id={block.id}
prompt={block.prompt || ""}
keywords={block.keywords}
timeLimit={block.timeLimit}
isGraded={lesson.is_graded}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'code':
return (
<CodeExercisePlayer
title={block.title}
instructions={block.instructions || ""}
initialCode={block.initialCode || ""}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'hotspot':
return (
<HotspotPlayer
title={block.title}
description={block.description || ""}
imageUrl={block.imageUrl || ""}
hotspots={block.hotspots || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'memory-match':
return (
<MemoryPlayer
title={block.title}
pairs={block.pairs || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
default:
return <div className="p-4 bg-white/5 border border-white/10 rounded-xl text-xs font-bold text-gray-500 uppercase tracking-widest">Tipo de Bloque Desconocido: {block.type}</div>;
}
};
return (
<div key={block.id} className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
{renderBlock()}
</div>
);
})}
</div>
{/* Render Blocks or Locked View */}
{lesson.is_graded && userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? (
<LessonLockedView
lessonId={params.lessonId}
courseId={params.id}
grade={userGrade}
maxAttempts={lesson.max_attempts}
/>
) : (
<div className="py-20 text-center glass-card border-dashed border-white/10">
<p className="text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
</div>
(lesson.metadata?.blocks || []).length > 0 ? (
<div className="space-y-24">
{lesson.metadata?.blocks?.map((block) => {
const renderBlock = () => {
switch (block.type) {
case 'description':
return <DescriptionPlayer id={block.id} title={block.title} content={block.content || ""} />;
case 'media':
return (
<MediaPlayer
id={block.id}
lessonId={params.lessonId}
title={block.title}
url={block.url || ""}
media_type={block.media_type || 'video'}
config={block.config}
onTimeUpdate={setCurrentTime}
initialPlayCount={
userGrade?.metadata?.play_counts
? (userGrade.metadata.play_counts as Record<string, number>)[block.id] || 0
: 0
}
onPlay={async () => {
if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) {
const currentPlayCounts = (userGrade?.metadata?.play_counts as Record<string, number>) || {};
const newPlayCounts = {
...currentPlayCounts,
[block.id]: (currentPlayCounts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, play_counts: newPlayCounts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar el recuento de reproducciones", err);
}
}
}}
isGraded={lesson.is_graded}
hasTranscription={!!lesson.transcription}
/>
);
case 'document':
return <DocumentPlayer id={block.id} title={block.title} url={block.url || ""} />;
case 'quiz':
return (
<QuizPlayer
id={block.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
allowRetry={lesson.allow_retry}
maxAttempts={lesson.max_attempts || undefined}
initialAttempts={
userGrade?.metadata?.block_attempts
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
: 0
}
onAttempt={async () => {
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(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, block_attempts: newAttempts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar los intentos del bloque", err);
}
}
}}
/>
);
case 'fill-in-the-blanks':
return <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />;
case 'matching':
return <MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} allowRetry={lesson.allow_retry} />;
case 'ordering':
return <OrderingPlayer id={block.id} title={block.title} items={block.items || []} allowRetry={lesson.allow_retry} />;
case 'short-answer':
return (
<ShortAnswerPlayer
id={block.id}
title={block.title}
prompt={block.prompt || ""}
correctAnswers={block.correctAnswers || []}
allowRetry={lesson.allow_retry}
/>
);
case 'audio-response':
return (
<AudioResponsePlayer
id={block.id}
prompt={block.prompt || ""}
keywords={block.keywords}
timeLimit={block.timeLimit}
isGraded={lesson.is_graded}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'code':
return (
<CodeExercisePlayer
title={block.title}
instructions={block.instructions || ""}
initialCode={block.initialCode || ""}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'hotspot':
return (
<HotspotPlayer
title={block.title}
description={block.description || ""}
imageUrl={block.imageUrl || ""}
hotspots={block.hotspots || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'memory-match':
return (
<MemoryPlayer
title={block.title}
pairs={block.pairs || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
default:
return <div className="p-4 bg-white/5 border border-white/10 rounded-xl text-xs font-bold text-gray-500 uppercase tracking-widest">Tipo de Bloque Desconocido: {block.type}</div>;
}
};
return (
<div key={block.id} className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
{renderBlock()}
</div>
);
})}
</div>
) : (
<div className="py-20 text-center glass-card border-dashed border-white/10">
<p className="text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
</div>
)
)}
{lesson.is_graded && (
<div className="pt-20 border-t border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? (
<div className="space-y-4">
<div className="inline-flex items-center gap-2 px-6 py-2 bg-amber-500/10 border border-amber-500/30 text-amber-400 rounded-full text-xs font-black uppercase tracking-widest">
Bloqueado: Se alcanzó el máximo de intentos ({lesson.max_attempts})
</div>
<div className="text-4xl font-black text-white">
Puntuación: <span className="text-blue-500">{userGrade.score * 100}%</span>
</div>
<p className="text-gray-500 text-xs italic">Esta evaluación ya está cerrada para futuras entregas.</p>
</div>
) : (
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : (
<>
<button
onClick={async () => {
@@ -442,6 +443,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</Link>
)}
</footer>
{/* AI Tutor Bubble/Panel */}
<AITutor lessonId={params.lessonId} />
</main>
</div>
);
+2 -2
View File
@@ -33,7 +33,7 @@ body {
}
.glass-card {
@apply glass rounded-2xl p-6 transition-all duration-300;
@apply glass rounded-2xl p-4 md:p-6 transition-all duration-300;
}
.glass-card:hover {
@@ -88,4 +88,4 @@ body {
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export default function MyLearningPage() {
enrichedEnrollments.push({
course: { ...course, modules },
progress,
lastAccessed: enrollment.enroled_at
lastAccessed: enrollment.enrolled_at
});
} catch (err) {
console.error(`Error loading course ${enrollment.course_id}`, err);
+7 -7
View File
@@ -85,22 +85,22 @@ export default function CatalogPage() {
}
return (
<div className="max-w-7xl mx-auto px-6 py-20">
<div className="mb-20 flex flex-col md:flex-row md:items-end justify-between gap-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 md:py-20">
<div className="mb-12 md:mb-20 flex flex-col md:flex-row md:items-end justify-between gap-8 text-center md:text-left">
<div className="space-y-4">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">
<div className="flex items-center justify-center md:justify-start gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">
<Star size={14} className="fill-blue-500" />
<span>Currículo Premier</span>
</div>
<h1 className="text-6xl font-black tracking-tighter leading-none">
Explorar <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-600">Cursos</span>
<h1 className="text-4xl md:text-6xl font-black tracking-tighter leading-tight md:leading-none">
Explorar <span className="block sm:inline text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-600">Cursos</span>
</h1>
<p className="text-gray-500 font-medium max-w-xl text-lg">
<p className="text-gray-500 font-medium max-w-xl text-base md:text-lg mx-auto md:mx-0">
Domina las habilidades del futuro con nuestro contenido educativo de alta fidelidad.
</p>
</div>
{!user && (
<Link href="/auth/register" className="btn-premium !bg-white !text-black shadow-none !px-8">
<Link href="/auth/register" className="btn-premium !bg-white !text-black shadow-none !px-8 w-full sm:w-auto">
Comienza Gratis
</Link>
)}
+2 -2
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import { useState, useEffect, useRef, useCallback } from "react";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
import { lmsApi, CMS_API_URL } from "@/lib/api";
import { lmsApi, getCmsApiUrl } from "@/lib/api";
import {
Save,
Shield,
@@ -60,7 +60,7 @@ export default function ProfilePage() {
if (path.startsWith('http')) return path;
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${CMS_API_URL}${finalPath}`;
return `${getCmsApiUrl()}${finalPath}`;
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {