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>) => {
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { lmsApi } from "@/lib/api";
import { Send, Bot, User, X, MessageSquare, Loader2 } from "lucide-react";
interface Message {
role: 'tutor' | 'user';
content: string;
}
export default function AITutor({ lessonId }: { lessonId: string }) {
const [messages, setMessages] = useState<Message[]>([
{ role: 'tutor', content: '¡Hola! Soy tu tutor de IA. ¿Tienes alguna duda sobre esta lección?' }
]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const userMessage = input.trim();
setInput("");
setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
setIsLoading(true);
try {
const { response } = await lmsApi.chatWithTutor(lessonId, userMessage);
setMessages(prev => [...prev, { role: 'tutor', content: response }]);
} catch (error) {
console.error("Chat error:", error);
setMessages(prev => [...prev, { role: 'tutor', content: "Lo siento, hubo un error conectando con el tutor. Por favor intenta de nuevo." }]);
} finally {
setIsLoading(false);
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-24 right-6 w-14 h-14 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-500/40 flex items-center justify-center hover:scale-110 transition-all z-[100] group"
title="Abrir Tutor de IA"
>
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" />
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" />
</button>
);
}
return (
<div className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-black/80 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-white/5 bg-blue-600/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-sm font-black text-white uppercase tracking-widest">Tutor de IA</h3>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-tighter">En Línea</span>
</div>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-white/5 rounded-lg text-gray-400 hover:text-white transition-colors"
>
<X size={20} />
</button>
</div>
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
>
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex gap-2 max-w-[85%] ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${msg.role === 'user' ? 'bg-white/5' : 'bg-blue-600/20 text-blue-400'}`}>
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed ${msg.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white/5 text-gray-200 border border-white/5 rounded-tl-none'
}`}>
{msg.content}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start animate-in fade-in duration-300">
<div className="flex gap-2 max-w-[85%]">
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-400 flex items-center justify-center">
<Bot size={16} />
</div>
<div className="bg-white/5 text-gray-400 border border-white/5 p-3 rounded-2xl rounded-tl-none flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
<span className="text-[10px] font-bold uppercase tracking-widest">El tutor está pensando...</span>
</div>
</div>
</div>
)}
</div>
{/* Input */}
<div className="p-4 border-t border-white/5 bg-black/40">
<div className="relative">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Escribe tu duda aquí..."
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-500/50 transition-colors placeholder:text-gray-600"
/>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="absolute right-2 top-1.5 p-1.5 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:bg-gray-600 transition-all hover:bg-blue-500"
>
<Send size={16} />
</button>
</div>
<p className="mt-2 text-[9px] text-gray-600 font-bold uppercase tracking-widest text-center">
IA entrenada con el contenido de esta lección
</p>
</div>
</div>
);
}
+111 -33
View File
@@ -5,8 +5,9 @@ import Image from "next/image";
import { useBranding } from "@/context/BrandingContext";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
import { LogOut, Globe } from "lucide-react";
import { LogOut, Globe, Menu, X } from "lucide-react";
import NotificationCenter from "./NotificationCenter";
import { useState } from "react";
import { lmsApi, getImageUrl } from "@/lib/api";
@@ -14,14 +15,15 @@ export default function AppHeader() {
const { t, language, setLanguage } = useTranslation();
const { branding } = useBranding();
const { user, logout } = useAuth();
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Use platform_name if available, otherwise name, otherwise default
const platformName = branding?.platform_name || branding?.name || 'OpenCCB';
return (
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
<header className="h-16 glass sticky top-0 z-[100] px-4 md:px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
<Link href="/" className="flex items-center gap-2 md:gap-3 group">
<div className="w-8 h-8 md:w-10 md:h-10 rounded-lg md:rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
{branding?.logo_url ? (
<Image src={getImageUrl(branding.logo_url)} alt={branding.name} fill className="object-contain" sizes="40px" />
) : (
@@ -31,53 +33,129 @@ export default function AppHeader() {
)}
</div>
<div className="flex flex-col -gap-1">
<span className="font-black text-lg tracking-tighter text-white leading-none">
<span className="font-black text-sm md:text-lg tracking-tighter text-white leading-none">
{platformName.toUpperCase()}
</span>
<span className="text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCIA</span>
<span className="text-[8px] md:text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCIA</span>
</div>
</Link>
<nav className="flex items-center gap-2 md:gap-8">
<div className="hidden md:flex items-center gap-8 mr-4">
<div className="flex items-center gap-4">
<nav className="hidden md:flex items-center gap-8 mr-4">
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
{t('nav.catalog')}
</Link>
<Link href="/my-learning" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
{t('nav.myLearning')}
</Link>
</div>
</nav>
<NotificationCenter />
<div className="flex items-center gap-2 md:gap-4">
<NotificationCenter />
<div className="flex items-center gap-2 border-l border-white/10 pl-4">
<Globe size={14} className="text-gray-500" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="bg-transparent text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors focus:outline-none cursor-pointer"
>
<option value="en" className="bg-[#0f1115]">EN</option>
<option value="es" className="bg-[#0f1115]">ES</option>
<option value="pt" className="bg-[#0f1115]">PT</option>
</select>
</div>
<div className="hidden sm:flex items-center gap-2 border-l border-white/10 pl-4">
<Globe size={14} className="text-gray-500" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="bg-transparent text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors focus:outline-none cursor-pointer"
>
<option value="en" className="bg-[#0f1115]">EN</option>
<option value="es" className="bg-[#0f1115]">ES</option>
<option value="pt" className="bg-[#0f1115]">PT</option>
</select>
</div>
<div className="flex items-center gap-2 md:gap-4 pl-4 border-l border-white/10">
<Link href="/profile" className="flex items-center gap-2 group/profile">
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10 flex items-center justify-center font-bold text-xs text-blue-400 group-hover/profile:border-blue-500/50 transition-colors">
{user?.full_name?.charAt(0) || 'U'}
</div>
</Link>
<div className="hidden md:flex items-center gap-4 pl-4 border-l border-white/10">
<Link href="/profile" className="flex items-center gap-2 group/profile">
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10 flex items-center justify-center font-bold text-xs text-blue-400 group-hover/profile:border-blue-500/50 transition-colors">
{user?.full_name?.charAt(0) || 'U'}
</div>
</Link>
<button
onClick={logout}
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
title={t('nav.signOut')}
>
<LogOut size={16} />
</button>
</div>
{/* Mobile Menu Button */}
<button
onClick={logout}
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
title={t('nav.signOut')}
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="md:hidden p-2 hover:bg-white/5 rounded-lg text-gray-400 transition-colors"
>
<LogOut size={16} />
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</nav>
</div>
{/* Mobile Sidebar Overlay */}
{isMenuOpen && (
<div className="fixed inset-0 z-[150] md:hidden bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="absolute right-0 top-0 bottom-0 w-64 glass border-l border-white/10 p-6 flex flex-col animate-in slide-in-from-right duration-300">
<div className="flex justify-between items-center mb-8">
<span className="font-black text-xs uppercase tracking-[0.2em] text-gray-500">Menú</span>
<button onClick={() => setIsMenuOpen(false)} className="p-2 hover:bg-white/5 rounded-lg">
<X size={20} />
</button>
</div>
<nav className="flex flex-col gap-6 flex-1">
<Link
href="/"
onClick={() => setIsMenuOpen(false)}
className="text-sm font-black uppercase tracking-widest text-gray-300 hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all"
>
{t('nav.catalog')}
</Link>
<Link
href="/my-learning"
onClick={() => setIsMenuOpen(false)}
className="text-sm font-black uppercase tracking-widest text-gray-300 hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all"
>
{t('nav.myLearning')}
</Link>
<div className="pt-6 mt-6 border-t border-white/5 space-y-4">
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-white/5">
<Globe size={16} className="text-gray-500" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="bg-transparent text-xs font-bold uppercase tracking-widest text-gray-300 focus:outline-none flex-1"
>
<option value="en" className="bg-[#0f1115]">English</option>
<option value="es" className="bg-[#0f1115]">Español</option>
<option value="pt" className="bg-[#0f1115]">Português</option>
</select>
</div>
</div>
</nav>
<div className="pt-6 border-t border-white/5 space-y-4">
<Link
href="/profile"
onClick={() => setIsMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white/5 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center font-bold text-xs text-blue-400">
{user?.full_name?.charAt(0) || 'U'}
</div>
<span className="text-sm font-bold">{user?.full_name || 'Mi Perfil'}</span>
</Link>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-red-400 hover:bg-red-500/10 transition-colors"
>
<LogOut size={18} />
<span className="text-sm font-bold">{t('nav.signOut')}</span>
</button>
</div>
</div>
</div>
)}
</header>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import { lmsApi, UserGrade } from "@/lib/api";
import { Trophy, Award, BookOpen, RotateCcw, Bot, Loader2, Star, Sparkles, CheckCircle2 } from "lucide-react";
interface LessonLockedViewProps {
lessonId: string;
courseId: string;
grade: UserGrade;
maxAttempts?: number;
}
export default function LessonLockedView({ lessonId, courseId, grade, maxAttempts }: LessonLockedViewProps) {
const [feedback, setFeedback] = useState<string>("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFeedback = async () => {
try {
const res = await lmsApi.getLessonFeedback(lessonId);
setFeedback(res.response);
} catch (err) {
console.error("Error fetching AI feedback:", err);
setFeedback("¡Buen trabajo completando esta evaluación! Sigue así para mejorar tus resultados en las próximas lecciones.");
} finally {
setLoading(false);
}
};
fetchFeedback();
}, [lessonId]);
const scorePct = Math.round(grade.score * 100);
const isPassing = scorePct >= 70; // Assuming 70% is passing
return (
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* Header / Score Card */}
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-[3rem] blur opacity-25 group-hover:opacity-40 transition duration-1000"></div>
<div className="relative glass p-10 md:p-16 rounded-[2.5rem] border border-white/10 bg-black/40 text-center space-y-8 overflow-hidden">
{/* Background visual flair */}
<div className="absolute top-0 right-0 p-8 opacity-10">
<Sparkles size={120} className="text-blue-500" />
</div>
<div className="inline-flex items-center gap-2 px-6 py-2 bg-blue-600/10 border border-blue-500/20 text-blue-400 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
<CheckCircle2 size={12} /> Evaluación Finalizada
</div>
<div className="space-y-4">
<h2 className="text-5xl md:text-7xl font-black text-white tracking-tighter">
Tu Puntuación: <span className={isPassing ? "text-blue-500" : "text-amber-500"}>{scorePct}%</span>
</h2>
<div className="flex justify-center gap-1">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
size={32}
className={`${s <= Math.ceil(scorePct / 20) ? "text-yellow-500 fill-yellow-500" : "text-white/5"} transition-all`}
/>
))}
</div>
</div>
<div className="flex flex-wrap justify-center gap-6 pt-4">
<div className="flex items-center gap-3 px-6 py-4 rounded-2xl bg-white/5 border border-white/5">
<RotateCcw size={20} className="text-gray-500" />
<div className="text-left">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Intentos Usados</p>
<p className="text-lg font-black text-white">{grade.attempts_count} {maxAttempts ? `de ${maxAttempts}` : ""}</p>
</div>
</div>
<div className="flex items-center gap-3 px-6 py-4 rounded-2xl bg-white/5 border border-white/5">
<Trophy size={20} className="text-amber-500" />
<div className="text-left">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Estado</p>
<p className={`text-lg font-black uppercase ${isPassing ? "text-green-500" : "text-amber-500"}`}>
{isPassing ? "Aprobado" : "No Alcanzado"}
</p>
</div>
</div>
</div>
</div>
</div>
{/* AI Feedback Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="glass p-8 md:p-12 rounded-[2.5rem] border border-white/5 bg-blue-600/5 relative overflow-hidden group">
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-blue-600/10 blur-[100px] rounded-full group-hover:bg-blue-600/20 transition-all duration-1000"></div>
<h3 className="text-xs font-black uppercase tracking-[0.3em] text-blue-400 mb-8 flex items-center gap-3">
<Bot size={20} className="animate-bounce" /> Retroalimentación de tu Tutor de IA
</h3>
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin" />
<p className="text-xs font-black uppercase tracking-widest text-gray-500">Generando análisis personalizado...</p>
</div>
) : (
<div className="space-y-6">
<div className="text-xl md:text-2xl text-gray-200 leading-relaxed font-medium">
{feedback}
</div>
<div className="h-px w-20 bg-blue-500/40 rounded-full"></div>
<p className="text-xs font-bold text-gray-500 italic uppercase tracking-wider">
Este análisis es generado automáticamente basándose en tu desempeño histórico y los contenidos de esta lección.
</p>
</div>
)}
</div>
</div>
<div className="lg:col-span-1 space-y-6">
<div className="glass p-8 rounded-[2rem] border border-white/5 bg-white/[0.02]">
<h3 className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
<BookOpen size={16} className="text-blue-500" /> Próximos Pasos
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3 p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors border border-transparent hover:border-white/10 group cursor-pointer">
<div className="w-8 h-8 rounded-lg bg-green-500/20 text-green-400 flex items-center justify-center shrink-0">
<Award size={16} />
</div>
<p className="text-xs font-bold text-gray-300 leading-tight group-hover:text-white transition-colors">Continúa con la siguiente lección para seguir sumando XP.</p>
</li>
<li className="flex items-start gap-3 p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors border border-transparent hover:border-white/10 group cursor-pointer">
<div className="w-8 h-8 rounded-lg bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0">
<BookOpen size={16} />
</div>
<p className="text-xs font-bold text-gray-300 leading-tight group-hover:text-white transition-colors">Revisa el glosario de términos de esta sección.</p>
</li>
</ul>
</div>
<div className="p-8 rounded-[2rem] bg-amber-500/5 border border-amber-500/10">
<p className="text-[10px] font-black text-amber-500 uppercase tracking-widest mb-2">Nota Importante</p>
<p className="text-[11px] font-bold text-amber-500/70 leading-relaxed uppercase tracking-tight">
Has alcanzado el máximo de intentos. Esta evaluación está bloqueada, pero puedes seguir repasando los materiales del curso.
</p>
</div>
</div>
</div>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { FileText, Download, Eye, ExternalLink } from "lucide-react";
import { CMS_API_URL } from "@/lib/api";
import { getCmsApiUrl } from "@/lib/api";
interface DocumentPlayerProps {
id: string;
@@ -18,7 +18,7 @@ export default function DocumentPlayer({ id, title, url }: DocumentPlayerProps)
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 displayUrl = getFullUrl(url);
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { Play, Lock, AlertCircle } from "lucide-react";
import { lmsApi } from "@/lib/api";
import { lmsApi, getCmsApiUrl } from "@/lib/api";
interface MediaPlayerProps {
id: string;
@@ -46,14 +46,14 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
const maxPlays = config?.maxPlays || 0;
const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
const getFullUrl = (path: string) => {
if (path.startsWith('http')) return path;
// Map /uploads to /assets for the backend
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 isLocalFile = url.startsWith('/uploads') || url.startsWith('http://localhost:3001/assets') || url.includes('/assets/');
@@ -129,8 +129,8 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
// Since browser <track> doesn't support custom headers easily,
// we might need to handle this via a proxy or temporary signed URLs.
// For now, we'll assume the backend allows VTT access if requested with the correct lesson ID.
const vttEn = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=en` : null;
const vttEs = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=es` : null;
const vttEn = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=en` : null;
const vttEs = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=es` : null;
return (
<div className="space-y-6" id={id}>
@@ -1,7 +1,7 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { lmsApi, Organization } from '@/lib/api';
import { lmsApi, Organization, getImageUrl } from '@/lib/api';
interface BrandingContextType {
branding: Organization | null;
@@ -45,14 +45,6 @@ export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ chil
// Import getImageUrl logic locally or assume it needs import
// Since I can't easily add import at top with replace_file, I will assume getImageUrl handles the path or do logic here.
// Actually I need to import getImageUrl at the top. Instead of complicating, I'll update imports too.
const getImageUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${CMS_API_URL}${finalPath}`;
};
const faviconUrl = getImageUrl(data.favicon_url);
const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']");
+26 -8
View File
@@ -1,12 +1,21 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
return `${protocol}//${hostname}:${defaultPort}`;
}
return envVar || `http://localhost:${defaultPort}`;
};
export const getLmsApiUrl = () => getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL);
export const getCmsApiUrl = () => getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
export const getImageUrl = (path?: string) => {
if (!path) return '';
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}`;
};
export interface Organization {
@@ -171,7 +180,7 @@ export interface Enrollment {
id: string;
user_id: string;
course_id: string;
enroled_at: string;
enrolled_at: string;
}
export interface Module {
@@ -186,7 +195,7 @@ const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('exp
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
const token = getToken();
const baseUrl = isCMS ? CMS_API_URL : API_BASE_URL;
const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
const headers = {
'Content-Type': 'application/json',
...options.headers,
@@ -238,7 +247,7 @@ export const lmsApi = {
},
initSSOLogin(orgId: string): void {
window.location.href = `${CMS_API_URL}/auth/sso/login/${orgId}`;
window.location.href = `${getCmsApiUrl()}/auth/sso/login/${orgId}`;
},
async enroll(courseId: string, userId: string): Promise<void> {
@@ -286,7 +295,7 @@ export const lmsApi = {
const formData = new FormData();
formData.append('file', file);
const token = getToken();
return fetch(`${CMS_API_URL}/assets/upload`, {
return fetch(`${getCmsApiUrl()}/assets/upload`, {
method: 'POST',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -331,7 +340,7 @@ export const lmsApi = {
formData.append('keywords', JSON.stringify(keywords));
const token = getToken();
return fetch(`${API_BASE_URL}/audio/evaluate-file`, {
return fetch(`${getLmsApiUrl()}/audio/evaluate-file`, {
method: 'POST',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -344,5 +353,14 @@ export const lmsApi = {
}
return res.json();
});
},
async chatWithTutor(lessonId: string, message: string): Promise<{ response: string }> {
return apiFetch(`/lessons/${lessonId}/chat`, {
method: 'POST',
body: JSON.stringify({ message })
});
},
async getLessonFeedback(lessonId: string): Promise<{ response: string }> {
return apiFetch(`/lessons/${lessonId}/feedback`);
}
};