feat: add PWA support with service worker, offline sync, and connectivity banners

- Added PWA icons for 192x192 and 512x512 resolutions.
- Implemented service worker (sw.js) for caching static assets and handling fetch requests.
- Created ConnectivityBanner component to notify users of online/offline status.
- Developed OfflineSyncPanel component to manage and display offline sync status.
- Introduced PwaInstallPrompt component to prompt users for PWA installation.
- Added PwaRegistration component to handle service worker registration and online event handling.
- Created AdminAiAuditPage for AI audit logs with filtering and review functionality.
- Developed AdminDataEthicsPage to display AI data ethics summary and recent events.
This commit is contained in:
2026-04-24 09:59:57 -04:00
parent 4148de5d66
commit e72f479639
32 changed files with 2332 additions and 74 deletions
@@ -0,0 +1,25 @@
{
"name": "OpenCCB Experience",
"short_name": "OpenCCB",
"description": "Aprendizaje en linea con soporte offline basico.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0b1220",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"icons": [
{
"src": "/pwa-icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/pwa-icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sin conexion | OpenCCB</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: Arial, sans-serif;
background: radial-gradient(circle at 20% 20%, #1e40af, #0b1220 70%);
color: #f8fafc;
}
.card {
width: min(560px, 92vw);
padding: 2rem;
border-radius: 16px;
background: rgba(15, 23, 42, 0.82);
border: 1px solid rgba(148, 163, 184, 0.3);
}
h1 { margin: 0 0 0.75rem; font-size: 1.6rem; }
p { margin: 0.5rem 0; line-height: 1.5; color: #cbd5e1; }
button {
margin-top: 1rem;
border: 0;
border-radius: 10px;
padding: 0.75rem 1rem;
background: #2563eb;
color: #fff;
font-weight: 700;
cursor: pointer;
}
</style>
</head>
<body>
<main class="card">
<h1>No hay conexion a internet</h1>
<p>Puedes seguir usando partes ya cargadas de la plataforma mientras vuelve la conexion.</p>
<p>Cuando tengas internet, intenta recargar para sincronizar el contenido nuevo.</p>
<button onclick="location.reload()">Reintentar</button>
</main>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="OpenCCB">
<rect width="192" height="192" rx="32" fill="#2563eb" />
<path d="M56 96c0-22.091 17.909-40 40-40h40v24H96c-8.837 0-16 7.163-16 16s7.163 16 16 16h40v24H96c-22.091 0-40-17.909-40-40z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="OpenCCB">
<rect width="512" height="512" rx="84" fill="#2563eb" />
<path d="M149.333 256c0-58.909 47.758-106.667 106.667-106.667h106.667v64H256c-23.564 0-42.667 19.102-42.667 42.667 0 23.564 19.103 42.667 42.667 42.667h106.667v64H256c-58.909 0-106.667-47.758-106.667-106.667z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

+120
View File
@@ -0,0 +1,120 @@
const SW_VERSION = "openccb-experience-v1";
const STATIC_CACHE = `${SW_VERSION}-static`;
const PAGE_CACHE = `${SW_VERSION}-pages`;
const API_CACHE = `${SW_VERSION}-api`;
const STATIC_ASSETS = [
"/offline.html",
"/manifest.webmanifest",
"/pwa-icon-192.svg",
"/pwa-icon-512.svg",
"/next.svg",
"/window.svg",
"/globe.svg",
"/grid.svg",
"/file.svg"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => !key.startsWith(SW_VERSION))
.map((key) => caches.delete(key))
)
)
.then(async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
await self.clients.claim();
})
);
});
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
const isApiRequest = (url) => url.pathname.startsWith("/lms-api") || url.pathname.startsWith("/cms-api");
const isStaticRequest = (request) => ["style", "script", "image", "font"].includes(request.destination);
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(
(async () => {
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
const preloadClone = preloadResponse.clone();
caches.open(PAGE_CACHE).then((cache) => cache.put(request, preloadClone));
return preloadResponse;
}
return fetch(request)
.then((response) => {
const clone = response.clone();
caches.open(PAGE_CACHE).then((cache) => cache.put(request, clone));
return response;
})
.catch(async () => {
const cached = await caches.match(request);
return cached || caches.match("/offline.html");
});
})()
);
return;
}
if (isApiRequest(url)) {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(API_CACHE).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(async () => {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ message: "Offline" }), {
status: 503,
headers: { "Content-Type": "application/json" }
});
})
);
return;
}
if (isStaticRequest(request)) {
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (!response || !response.ok) return response;
const clone = response.clone();
caches.open(STATIC_CACHE).then((cache) => cache.put(request, clone));
return response;
});
})
);
}
});
@@ -200,6 +200,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
{/* Navigation Sidebar */}
<aside
aria-label="Navegación de lecciones"
className={`hidden lg:flex glass border-r border-black/5 dark:border-white/5 transition-all duration-500 bg-black/5 dark:bg-black/40 flex-col ${sidebarOpen ? 'w-80' : 'w-0 overflow-hidden border-none'}`}
>
<div className="p-6 border-b border-black/5 dark:border-white/5">
@@ -207,7 +208,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
<p className="text-sm font-bold text-gray-900 dark:text-white truncate">{course.title}</p>
</div>
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-6" aria-label="Módulos y lecciones">
{course.modules.map((module) => (
<div key={module.id} className="space-y-2">
<div className="flex items-center justify-between px-3 mb-2">
@@ -219,7 +220,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
<Link
key={l.id}
href={`/courses/${params.id}/lessons/${l.id}`}
className={`sidebar-link ${l.id === params.lessonId ? 'sidebar-link-active' : 'sidebar-link-inactive'}`}
aria-current={l.id === params.lessonId ? 'page' : undefined}
className={`sidebar-link focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${l.id === params.lessonId ? 'sidebar-link-active' : 'sidebar-link-inactive'}`}
>
<div className="flex-1 truncate">{l.title}</div>
<div className={`w-2.5 h-2.5 rounded-full transition-all duration-500 ${getStatusColor(getLessonStatus(l))}`} />
@@ -228,45 +230,57 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</div>
</div>
))}
</div>
</nav>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col relative overflow-hidden">
<div className="relative z-10 flex flex-wrap gap-2 px-6 pt-4 pb-2">
<button
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="hidden lg:block p-3 rounded-xl glass border-black/10 dark:border-white/10 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-all bg-black/5 dark:bg-black/40"
className="hidden lg:block p-3 rounded-xl glass border-black/10 dark:border-white/10 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
title="Alternar Barra Lateral"
aria-label="Alternar barra lateral"
aria-pressed={sidebarOpen}
>
<Menu size={20} />
</button>
<button
type="button"
onClick={handleToggleBookmark}
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 ${isBookmarked ? 'text-yellow-600 dark:text-yellow-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${isBookmarked ? 'text-yellow-600 dark:text-yellow-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
title={isBookmarked ? "Quitar Marcador" : "Guardar Marcador"}
aria-label={isBookmarked ? "Quitar marcador" : "Guardar marcador"}
aria-pressed={isBookmarked}
>
<Bookmark size={20} fill={isBookmarked ? "currentColor" : "none"} />
</button>
{hasTranscription && (
<button
type="button"
onClick={() => {
setTranscriptOpen(!transcriptOpen);
if (!transcriptOpen) setNotesOpen(false);
}}
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 ${transcriptOpen ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${transcriptOpen ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
title="Alternar Transcripción"
aria-label="Alternar transcripción"
aria-pressed={transcriptOpen}
>
<ListMusic size={20} />
</button>
)}
<button
type="button"
onClick={() => {
setNotesOpen(!notesOpen);
if (!notesOpen) setTranscriptOpen(false);
}}
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 ${notesOpen ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${notesOpen ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
title="Alternar Notas"
aria-label="Alternar notas"
aria-pressed={notesOpen}
>
<StickyNote size={20} />
</button>
@@ -274,7 +288,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-12">
<div className="max-w-4xl mx-auto space-y-20 pb-40">
<article className="max-w-4xl mx-auto space-y-20 pb-40" aria-labelledby="lesson-title">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">
@@ -287,7 +301,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
{getLessonStatus(lesson) === 'not-started' && "No Iniciada"}
</div>
</div>
<h1 className="text-4xl font-black tracking-tighter text-gray-900 dark:text-white">{lesson.title}</h1>
<h1 id="lesson-title" className="text-4xl font-black tracking-tighter text-gray-900 dark:text-white">{lesson.title}</h1>
</div>
{lesson.summary && (
@@ -389,7 +403,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
userGrade?.metadata?.quiz_answers
? {
score: userGrade.score,
answers: userGrade.metadata.quiz_answers[block.id] || userGrade.metadata.quiz_answers,
answers: ((userGrade.metadata.quiz_answers as Record<string, unknown>)[block.id] || userGrade.metadata.quiz_answers),
created_at: userGrade.created_at,
}
: undefined
@@ -564,6 +578,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : (
<>
<button
type="button"
onClick={async () => {
if (user) {
try {
@@ -577,7 +592,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
}
}
}}
className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn"
className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>
<span className="flex items-center gap-2 font-black italic">
{userGrade ? `ENVIAR INTENTO ${userGrade.attempts_count + 1}` : 'ENVIAR PARA CALIFICAR'}
@@ -593,28 +608,32 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
)}
</div>
)}
</div>
</article>
</div>
{/* Right Side Panels */}
{((hasTranscription && transcriptOpen) || notesOpen) && (
<aside className="hidden xl:flex w-[400px] border-l border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/20 flex-col animate-in slide-in-from-right duration-500">
<aside aria-label="Panel lateral de apoyo" className="hidden xl:flex w-[400px] border-l border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/20 flex-col animate-in slide-in-from-right duration-500">
{/* Panel Tabs */}
<div className="flex border-b border-black/5 dark:border-white/5">
{hasTranscription && (
<button
type="button"
onClick={() => setTranscriptOpen(true)}
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${transcriptOpen ? 'text-blue-600 dark:text-blue-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${transcriptOpen ? 'text-blue-600 dark:text-blue-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
aria-pressed={transcriptOpen}
>
Transcripción
</button>
)}
<button
type="button"
onClick={() => {
setNotesOpen(true);
if (hasTranscription) setTranscriptOpen(false);
}}
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${notesOpen && (!transcriptOpen || !hasTranscription) ? 'text-indigo-600 dark:text-indigo-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${notesOpen && (!transcriptOpen || !hasTranscription) ? 'text-indigo-600 dark:text-indigo-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
aria-pressed={notesOpen && (!transcriptOpen || !hasTranscription)}
>
Notas
</button>
@@ -638,9 +657,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</div>
{/* Footer Controls */}
<footer className="h-20 glass border-t border-black/5 dark:border-white/5 px-6 flex items-center justify-between bg-white/60 dark:bg-black/60 backdrop-blur-3xl shrink-0">
<footer className="h-20 glass border-t border-black/5 dark:border-white/5 px-6 flex items-center justify-between bg-white/60 dark:bg-black/60 backdrop-blur-3xl shrink-0" aria-label="Navegación entre lecciones">
{prevLesson ? (
<Link href={`/courses/${params.id}/lessons/${prevLesson.id}`} className="group flex items-center gap-3">
<Link href={`/courses/${params.id}/lessons/${prevLesson.id}`} className="group flex items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 rounded-xl" aria-label={`Ir a la lección anterior: ${prevLesson.title}`}>
<div className="w-10 h-10 rounded-xl glass border-black/10 dark:border-white/10 flex items-center justify-center group-hover:bg-black/5 dark:group-hover:bg-white/5 transition-all text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
<ChevronLeft size={20} />
</div>
@@ -663,11 +682,11 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</div>
{nextLesson ? (
<Link href={`/courses/${params.id}/lessons/${nextLesson.id}`} className="btn-premium !py-3 !px-6 text-xs !shadow-none">
<Link href={`/courses/${params.id}/lessons/${nextLesson.id}`} className="btn-premium !py-3 !px-6 text-xs !shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70" aria-label={`Ir a la siguiente lección: ${nextLesson.title}`}>
Siguiente Lección <ChevronRight size={18} />
</Link>
) : (
<Link href="/" className="btn-premium !bg-green-600 !py-3 !px-6 text-xs !shadow-none">
<Link href="/" className="btn-premium !bg-green-600 !py-3 !px-6 text-xs !shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70" aria-label="Finalizar curso y volver al catálogo">
Finalizar Curso <CheckCircle2 size={18} />
</Link>
)}
+2 -1
View File
@@ -5,6 +5,7 @@ import { lmsApi, Course, Module, Recommendation, UserGrade, Meeting, normalizePr
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react";
import Link from "next/link";
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
import { Award } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import DiscussionBoard from "@/components/DiscussionBoard";
import { AnnouncementsList } from "@/components/AnnouncementsList";
@@ -79,7 +80,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
// Load organization settings (branding includes the certificates_enabled flag)
lmsApi.getBranding()
.then(res => setOrgSettings(res.organization))
.then(res => setOrgSettings(res))
.catch(console.error);
}, [params.id, user]);
+9
View File
@@ -6,10 +6,15 @@ import { I18nProvider } from "@/context/I18nContext";
import { BrandingProvider } from "@/context/BrandingContext";
import AuthGuard from "@/components/AuthGuard";
import { ThemeProvider } from "@/context/ThemeContext";
import PwaRegistration from "@/components/PwaRegistration";
import PwaInstallPrompt from "@/components/PwaInstallPrompt";
import ConnectivityBanner from "@/components/ConnectivityBanner";
import OfflineSyncPanel from "@/components/OfflineSyncPanel";
export const metadata: Metadata = {
title: "Experiencia de Aprendizaje",
description: "Consume contenido educativo de alta fidelidad.",
manifest: "/manifest.webmanifest",
};
import AppHeader from "@/components/AppHeader";
@@ -27,6 +32,10 @@ export default function RootLayout({
<AuthProvider>
<I18nProvider>
<AuthGuard>
<PwaRegistration />
<PwaInstallPrompt />
<ConnectivityBanner />
<OfflineSyncPanel />
<AppHeader />
<main className="flex-1">
{children}
+41 -4
View File
@@ -18,6 +18,8 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
const [isOpen, setIsOpen] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const scrollRef = useRef<HTMLUListElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Load session from localStorage on mount
@@ -33,6 +35,28 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
}
}, [messages]);
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
return;
}
triggerRef.current?.focus();
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen]);
const handleSend = async () => {
if (!input.trim() || isLoading) return;
@@ -60,10 +84,13 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
if (!isOpen) {
return (
<button
ref={triggerRef}
type="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"
aria-label="Abrir Tutor de IA"
aria-expanded="false"
aria-controls="ai-tutor-dialog"
>
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" aria-hidden="true" />
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" aria-hidden="true" />
@@ -73,9 +100,12 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
return (
<div
id="ai-tutor-dialog"
className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-white/95 dark:bg-black/80 backdrop-blur-2xl border border-black/5 dark:border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden"
role="dialog"
aria-label="Tutor de IA"
aria-modal="true"
aria-labelledby="ai-tutor-title"
aria-describedby="ai-tutor-description"
>
{/* Header */}
<div className="p-4 border-b border-black/5 dark:border-white/5 bg-blue-600/5 dark:bg-blue-600/10 flex items-center justify-between">
@@ -84,7 +114,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-widest">Tutor de IA</h3>
<h3 id="ai-tutor-title" className="text-sm font-black text-gray-900 dark: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-500 dark:text-gray-400 uppercase tracking-tighter">En Línea</span>
@@ -92,6 +122,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
</div>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
aria-label="Cerrar Tutor de IA"
@@ -104,6 +135,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
<ul
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
role="log"
aria-live="polite"
aria-relevant="additions"
>
@@ -120,7 +152,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-black/5 dark:bg-white/5 text-gray-800 dark:text-gray-200 border border-black/5 dark:border-white/5 rounded-tl-none'
}`}
aria-label={msg.role === 'user' ? 'Tú dijeste' : 'El tutor dijo'}
aria-label={msg.role === 'user' ? 'Tú dijiste' : 'El tutor dijo'}
>
{msg.content}
</div>
@@ -128,7 +160,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
</li>
))}
{isLoading && (
<li className="flex justify-start animate-in fade-in duration-300" aria-busy="true">
<li className="flex justify-start animate-in fade-in duration-300" aria-busy="true" aria-live="assertive">
<div className="flex gap-2 max-w-[85%]">
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-600 dark:text-blue-400 flex items-center justify-center" aria-hidden="true">
<Bot size={16} />
@@ -146,6 +178,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
<div className="p-4 border-t border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/40">
<div className="relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -155,6 +188,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
className="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-600/50 dark:focus:border-blue-500/50 transition-colors placeholder:text-gray-400 dark:placeholder:text-gray-600 text-gray-900 dark:text-gray-100"
/>
<button
type="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-400 dark:disabled:bg-gray-600 transition-all hover:bg-blue-500"
@@ -166,6 +200,9 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
<p className="mt-2 text-[9px] text-gray-500 dark:text-gray-600 font-bold uppercase tracking-widest text-center">
IA entrenada con el contenido de esta lección
</p>
<p id="ai-tutor-description" className="sr-only">
El tutor responde solo con contenido de la lección actual.
</p>
</div>
</div>
);
+57 -5
View File
@@ -26,6 +26,7 @@ export default function AppHeader() {
const [searchOpen, setSearchOpen] = useState(false);
const [searchResults, setSearchResults] = useState<Array<{ id: string; kind: string; title: string; snippet?: string; url: string; course_title?: string }>>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [searchActiveIndex, setSearchActiveIndex] = useState(-1);
const searchRef = useRef<HTMLDivElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -63,9 +64,45 @@ export default function AppHeader() {
setSearchOpen(false);
setSearchQuery("");
setSearchResults([]);
setSearchActiveIndex(-1);
router.push(url);
}
function handleSearchKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!searchOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
setSearchOpen(true);
return;
}
if (e.key === 'Escape') {
setSearchOpen(false);
setSearchActiveIndex(-1);
return;
}
if (!searchResults.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSearchActiveIndex((prev) => (prev + 1) % searchResults.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSearchActiveIndex((prev) => (prev <= 0 ? searchResults.length - 1 : prev - 1));
return;
}
if (e.key === 'Enter' && searchActiveIndex >= 0) {
e.preventDefault();
const selected = searchResults[searchActiveIndex];
if (selected) {
handleSearchSelect(selected.url);
}
}
}
const kindLabel: Record<string, string> = {
course: "Curso",
lesson: "Lección",
@@ -106,11 +143,23 @@ export default function AppHeader() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
<input
id="global-search"
type="text"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setSearchOpen(true); }}
onChange={e => {
setSearchQuery(e.target.value);
setSearchOpen(true);
setSearchActiveIndex(-1);
}}
onFocus={() => setSearchOpen(true)}
onKeyDown={handleSearchKeyDown}
placeholder="Buscar cursos, lecciones..."
role="combobox"
aria-expanded={searchOpen}
aria-controls="global-search-results"
aria-autocomplete="list"
aria-activedescendant={searchActiveIndex >= 0 ? `search-option-${searchActiveIndex}` : undefined}
aria-label="Buscar cursos, lecciones, foros y anuncios"
className="w-56 lg:w-72 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-2 pl-9 pr-3 text-sm text-slate-700 dark:text-white placeholder-gray-400 focus:outline-none focus:border-blue-500/50 transition-all"
/>
</div>
@@ -121,12 +170,15 @@ export default function AppHeader() {
) : searchResults.length === 0 ? (
<div className="p-4 text-sm text-gray-400 text-center">Sin resultados para &quot;{searchQuery}&quot;</div>
) : (
<ul className="max-h-80 overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
{searchResults.map(r => (
<li key={`${r.kind}-${r.id}`}>
<ul id="global-search-results" role="listbox" className="max-h-80 overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
{searchResults.map((r, idx) => (
<li key={`${r.kind}-${r.id}`} role="presentation">
<button
id={`search-option-${idx}`}
role="option"
aria-selected={searchActiveIndex === idx}
onClick={() => handleSearchSelect(r.url)}
className="w-full px-4 py-3 text-left hover:bg-blue-50 dark:hover:bg-white/5 transition-colors flex flex-col gap-0.5"
className={`w-full px-4 py-3 text-left transition-colors flex flex-col gap-0.5 ${searchActiveIndex === idx ? 'bg-blue-50 dark:bg-white/10' : 'hover:bg-blue-50 dark:hover:bg-white/5'}`}
>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 px-1.5 py-0.5 rounded">
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useState } from "react";
export default function ConnectivityBanner() {
const [online, setOnline] = useState(true);
const [visible, setVisible] = useState(false);
useEffect(() => {
setOnline(navigator.onLine);
const onOnline = () => {
setOnline(true);
setVisible(true);
window.setTimeout(() => setVisible(false), 2500);
};
const onOffline = () => {
setOnline(false);
setVisible(true);
};
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, []);
if (!visible && online) return null;
return (
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[260] px-4" role="status" aria-live="polite" aria-atomic="true">
<div
className={`rounded-xl px-4 py-2 text-xs font-bold shadow-lg border ${
online
? "bg-emerald-600 text-white border-emerald-500"
: "bg-amber-500 text-slate-950 border-amber-400"
}`}
>
{online ? "Conexion restablecida" : "Sin conexion: usando contenido en cache"}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { lmsApi, OfflineSyncStatus } from "@/lib/api";
const initialStatus: OfflineSyncStatus = {
pending: 0,
isFlushing: false,
lastSyncAt: null,
lastFlushedCount: 0,
lastError: null,
};
export default function OfflineSyncPanel() {
const [status, setStatus] = useState<OfflineSyncStatus>(initialStatus);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setStatus(lmsApi.getOfflineSyncStatus());
const unsubscribe = lmsApi.subscribeOfflineSync((next) => setStatus(next));
return unsubscribe;
}, []);
const lastSyncLabel = useMemo(() => {
if (!status.lastSyncAt) return "Aun sin sincronizacion";
const date = new Date(status.lastSyncAt);
return date.toLocaleString();
}, [status.lastSyncAt]);
const shouldShow = status.pending > 0 || status.isFlushing || expanded;
if (!shouldShow) return null;
return (
<section
className="fixed bottom-6 left-6 z-[290] w-[min(360px,92vw)] rounded-2xl border border-slate-300/30 bg-white/95 dark:bg-slate-900/95 backdrop-blur shadow-2xl"
aria-label="Estado de sincronizacion offline"
>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full px-4 py-3 flex items-center justify-between text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 rounded-2xl"
aria-expanded={expanded}
>
<div>
<p className="text-xs font-black uppercase tracking-widest text-slate-700 dark:text-slate-200">
Sync Offline
</p>
<p className="text-xs text-slate-600 dark:text-slate-300 mt-0.5">
{status.isFlushing
? "Sincronizando..."
: `${status.pending} pendiente${status.pending === 1 ? "" : "s"}`}
</p>
</div>
<span className="text-[11px] px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-bold">
{status.pending}
</span>
</button>
{expanded && (
<div className="px-4 pb-4 border-t border-slate-200/60 dark:border-slate-700/60">
<p className="text-[11px] text-slate-600 dark:text-slate-300 mt-3">
Ultimo sync: <span className="font-semibold">{lastSyncLabel}</span>
</p>
<p className="text-[11px] text-slate-600 dark:text-slate-300 mt-1">
Ultimo lote enviado: <span className="font-semibold">{status.lastFlushedCount}</span>
</p>
{status.lastError && status.lastError !== "offline" && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 mt-1 font-semibold">
Quedaron eventos pendientes; se reintentara automaticamente.
</p>
)}
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => lmsApi.flushOfflineQueue()}
disabled={status.isFlushing}
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-xs font-bold hover:bg-blue-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>
{status.isFlushing ? "Sincronizando..." : "Sincronizar ahora"}
</button>
<button
type="button"
onClick={() => setExpanded(false)}
className="px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold hover:bg-slate-200 dark:hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>
Ocultar
</button>
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
export default function PwaInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const onAppInstalled = () => {
setIsInstalled(true);
setDeferredPrompt(null);
};
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
if (isStandalone) {
setIsInstalled(true);
}
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onAppInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onAppInstalled);
};
}, []);
if (!deferredPrompt || isInstalled) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[300] px-4">
<div className="rounded-2xl border border-blue-300/40 bg-white/95 dark:bg-slate-900/95 backdrop-blur px-4 py-3 shadow-2xl max-w-md w-[92vw]">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-200">
Instala OpenCCB para acceso rapido y soporte offline.
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === "accepted") {
setDeferredPrompt(null);
}
}}
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-xs font-bold hover:bg-blue-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>
Instalar App
</button>
<button
type="button"
onClick={() => setDeferredPrompt(null)}
className="px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>
Ahora no
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useEffect } from "react";
import { lmsApi } from "@/lib/api";
export default function PwaRegistration() {
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
const register = async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
// Try to flush queued progress events at startup.
lmsApi.flushOfflineQueue().catch(() => undefined);
registration.addEventListener("updatefound", () => {
const installingWorker = registration.installing;
if (!installingWorker) return;
installingWorker.addEventListener("statechange", () => {
if (installingWorker.state === "installed" && navigator.serviceWorker.controller) {
installingWorker.postMessage({ type: "SKIP_WAITING" });
window.location.reload();
}
});
});
} catch (error) {
console.warn("Service worker registration failed", error);
}
};
const onOnline = () => {
lmsApi.flushOfflineQueue().catch(() => undefined);
};
window.addEventListener("online", onOnline);
register();
return () => {
window.removeEventListener("online", onOnline);
};
}, []);
return null;
}
@@ -37,6 +37,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
const [handledMarkers, setHandledMarkers] = useState<Set<number>>(new Set());
const [lastTime, setLastTime] = useState(0);
const [feedback, setFeedback] = useState<{ isCorrect: boolean } | null>(null);
const [a11yStatus, setA11yStatus] = useState("");
useEffect(() => {
if (initialPlayCount !== undefined) {
@@ -83,6 +84,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
if (!hasStarted) {
setPlayCount(prev => prev + 1);
setHasStarted(true);
setA11yStatus("Reproducción iniciada.");
if (lessonId) {
await lmsApi.recordInteraction(lessonId, {
video_timestamp: 0,
@@ -97,6 +99,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
if (locked) {
return (
<div className="space-y-4" id={id}>
<p className="sr-only" aria-live="polite" aria-atomic="true">Contenido bloqueado por límite de reproducciones.</p>
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
<div className="glass-card aspect-video flex flex-col items-center justify-center gap-6 border-red-500/10 dark:border-red-500/20 bg-red-500/5">
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center text-red-600 dark:text-red-500">
@@ -141,6 +144,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
return (
<div className="space-y-6" id={id}>
<p className="sr-only" aria-live="polite" aria-atomic="true">{a11yStatus}</p>
<div className="flex items-center justify-between">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
{maxPlays > 0 && (
@@ -163,6 +167,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
controls
crossOrigin="anonymous"
className="w-full h-full rounded-xl"
aria-label={title || "Reproductor de video"}
onPlay={handlePlay}
onTimeUpdate={(e) => {
const time = e.currentTarget.currentTime;
@@ -178,6 +183,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
if (time >= marker.timestamp && lastTime < marker.timestamp && !handledMarkers.has(marker.timestamp)) {
e.currentTarget.pause();
setActiveMarker(marker);
setA11yStatus("Pausa por pregunta interactiva.");
setHandledMarkers(prev => new Set(prev).add(marker.timestamp));
break;
}
@@ -201,20 +207,23 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
<iframe
src={getEmbedUrl(url)}
className="w-full h-full rounded-xl"
title={title || "Contenido embebido"}
allowFullScreen
/>
)}
{/* Simulated play tracker overlay for iframes (invisible but catches first click) */}
{!isLocalFile && playCount === 0 && (
<div
<button
type="button"
onClick={handlePlay}
className="absolute inset-0 bg-white/40 dark:bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-white/20 dark:group-hover:bg-black/20 transition-all"
className="absolute inset-0 bg-white/40 dark:bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-white/20 dark:group-hover:bg-black/20 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
aria-label="Iniciar reproducción del contenido"
>
<div className="w-20 h-20 rounded-full bg-blue-600 dark:bg-blue-500 flex items-center justify-center shadow-2xl shadow-blue-500/40 group-hover:scale-110 transition-transform">
<Play size={32} className="text-white fill-white ml-2" />
</div>
</div>
</button>
)}
</div>
@@ -227,7 +236,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
{/* Question Overlay */}
{activeMarker && (
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-white/60 dark:bg-black/60 backdrop-blur-md rounded-xl animate-in fade-in duration-300">
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4 border border-black/5 dark:border-white/5">
<div role="dialog" aria-modal="true" aria-label="Pregunta interactiva del video" className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4 border border-black/5 dark:border-white/5">
<div className="flex items-center gap-2 text-blue-600 font-bold text-xs uppercase tracking-widest">
<AlertCircle size={16} />
<span>Quick Check</span>
@@ -237,6 +246,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
{activeMarker.options.map((option, idx) => (
<button
key={idx}
type="button"
disabled={!!feedback}
onClick={() => {
const isCorrect = idx === activeMarker.correctIndex;
@@ -244,6 +254,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
if (isGraded) {
// Graded Mode: Show feedback then continue
setFeedback({ isCorrect });
setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta.");
// Save answer to backend (mocked for now)
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
@@ -257,6 +268,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
// Formative Mode: Block until correct
if (isCorrect) {
setFeedback({ isCorrect: true });
setA11yStatus("Respuesta correcta.");
setTimeout(() => {
setFeedback(null);
setActiveMarker(null);
@@ -265,12 +277,13 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
}, 1000);
} else {
setFeedback({ isCorrect: false });
setA11yStatus("Respuesta incorrecta. Intenta nuevamente.");
alert("Try again! (This is just practice)");
setFeedback(null);
}
}
}}
className={`px-4 py-3 rounded-xl font-medium transition-all text-left ${feedback
className={`px-4 py-3 rounded-xl font-medium transition-all text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${feedback
? idx === activeMarker.correctIndex
? "bg-green-600 dark:bg-green-500 text-white"
: feedback.isCorrect === false && "bg-red-600 dark:bg-red-500 text-white"
@@ -20,6 +20,7 @@ interface QuizPlayerProps {
title?: string;
quizData: {
questions: QuizQuestion[];
test_type?: string;
instructions?: string;
passing_score?: number;
total_points?: number;
@@ -56,6 +57,7 @@ export default function QuizPlayer({
const [submitting, setSubmitting] = useState(false);
const [score, setScore] = useState<number | null>(null);
const [showHistory, setShowHistory] = useState(false);
const [a11yStatus, setA11yStatus] = useState("");
const questions = quizData?.questions || [];
const isSingleAttempt = maxAttempts === 1 || quizData.max_attempts === 1;
@@ -118,11 +120,13 @@ export default function QuizPlayer({
const handleValidate = async () => {
if (maxAttempts > 0 && attempts >= maxAttempts) return;
setA11yStatus("Enviando respuestas del cuestionario.");
// 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?')) {
setA11yStatus("Envío cancelado. Puedes completar las preguntas pendientes.");
return;
}
}
@@ -150,11 +154,9 @@ export default function QuizPlayer({
quiz_type: quizData.test_type || 'quiz',
};
// Submit to LMS API
await lmsApi.submitScore(userId, courseId, lessonId, calculatedScore, answersMetadata);
setSubmitted(true);
setAttempts(prev => prev + 1);
setA11yStatus(`Prueba enviada correctamente. Puntuación ${scorePercent} por ciento.`);
if (onAttempt) {
onAttempt(calculatedScore, answersMetadata);
@@ -164,6 +166,7 @@ export default function QuizPlayer({
alert(`¡Prueba enviada! Tu puntuación: ${scorePercent}%`);
} catch (error) {
console.error('Error submitting quiz:', error);
setA11yStatus('Error al enviar la prueba.');
alert('Error al enviar la prueba. Por favor, inténtalo de nuevo.');
} finally {
setSubmitting(false);
@@ -301,6 +304,7 @@ export default function QuizPlayer({
// Normal quiz mode (not yet submitted or allows retry)
return (
<div className="space-y-8 notranslate" id={id} translate="no">
<p className="sr-only" aria-live="polite" aria-atomic="true">{a11yStatus}</p>
<div className="space-y-2">
<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]">
@@ -345,7 +349,8 @@ export default function QuizPlayer({
>
{q.options.map((opt, oIdx) => {
const isSelected = userAnswers[q.id]?.includes(oIdx);
const isCorrect = q.correct?.includes(oIdx);
const correctAnswers = Array.isArray(q.correct) ? q.correct : [q.correct];
const isCorrect = correctAnswers.includes(oIdx);
const isActuallyCorrect = isCorrect && isSelected;
const isWrongSelection = !isCorrect && isSelected;
const missedCorrect = isCorrect && !isSelected;
@@ -367,8 +372,9 @@ export default function QuizPlayer({
role={q.type === 'multiple-select' ? 'checkbox' : 'radio'}
aria-checked={isSelected}
aria-disabled={submitted || submitting}
aria-label={`Opción ${oIdx + 1}: ${opt}`}
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 disabled:cursor-not-allowed ${style}`}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 disabled:cursor-not-allowed ${style}`}
disabled={submitted || submitting}
>
<div className="flex items-center justify-between">
@@ -404,9 +410,10 @@ export default function QuizPlayer({
<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 ${
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${
isSingleAttempt ? 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800' : ''
}`}
aria-describedby={`quiz-attempts-${id}`}
>
{submitting ? 'Enviando...' : (
isSingleAttempt
@@ -417,6 +424,9 @@ export default function QuizPlayer({
)}
</button>
)}
<p id={`quiz-attempts-${id}`} className="sr-only">
Intentos usados: {attempts}. Máximo permitido: {maxAttempts > 0 ? maxAttempts : 'sin límite'}.
</p>
{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">
@@ -34,9 +34,8 @@ export function useCourseLanguage(courseId: string | null) {
try {
// Importar dinámicamente para evitar circular dependencies
const { lmsApi } = await import('@/lib/api');
const response = await lmsApi.get(`/courses/${courseId}/language-config`);
const data = response.data;
const data = await lmsApi.getCourseLanguageConfig(courseId);
setConfig(data);
+347 -16
View File
@@ -184,7 +184,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm';
title: string;
content?: string;
url?: string;
@@ -192,6 +192,7 @@ export interface Block {
config?: Record<string, unknown>;
quiz_data?: {
questions: QuizQuestion[];
test_type?: string;
};
pairs?: { left: string; right: string }[];
items?: string[];
@@ -563,16 +564,243 @@ const getToken = () => {
return sessionStorage.getItem('preview_token') || localStorage.getItem('experience_token');
};
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
const OFFLINE_QUEUE_KEY = 'experience_offline_mutation_queue_v1';
const OFFLINE_SYNC_META_KEY = 'experience_offline_sync_meta_v1';
type OfflineMutationKind = 'grade' | 'interaction' | 'xapi';
type OfflineMutationItem = {
id: string;
dedupeKey: string;
kind: OfflineMutationKind;
url: string;
method: 'POST' | 'PUT' | 'DELETE';
isCMS: boolean;
body: string;
createdAt: string;
};
export type OfflineSyncStatus = {
pending: number;
isFlushing: boolean;
lastSyncAt: string | null;
lastFlushedCount: number;
lastError: string | null;
};
const offlineSyncListeners = new Set<(status: OfflineSyncStatus) => void>();
let offlineFlushPromise: Promise<{ flushed: number; pending: number }> | null = null;
let inMemoryOfflineStatus: OfflineSyncStatus = {
pending: 0,
isFlushing: false,
lastSyncAt: null,
lastFlushedCount: 0,
lastError: null,
};
const loadOfflineQueue = (): OfflineMutationItem[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return (parsed as OfflineMutationItem[]).map((item) => ({
...item,
dedupeKey: item.dedupeKey || `${item.method}:${item.url}:${item.body}`,
}));
} catch {
return [];
}
};
const loadOfflineSyncMeta = (): Partial<OfflineSyncStatus> => {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(OFFLINE_SYNC_META_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
return parsed as Partial<OfflineSyncStatus>;
} catch {
return {};
}
};
const saveOfflineSyncMeta = (status: OfflineSyncStatus) => {
if (typeof window === 'undefined') return;
const persistable = {
lastSyncAt: status.lastSyncAt,
lastFlushedCount: status.lastFlushedCount,
lastError: status.lastError,
};
localStorage.setItem(OFFLINE_SYNC_META_KEY, JSON.stringify(persistable));
};
const emitOfflineSyncStatus = (partial: Partial<OfflineSyncStatus>) => {
const queue = loadOfflineQueue();
inMemoryOfflineStatus = {
...inMemoryOfflineStatus,
...partial,
pending: queue.length,
};
saveOfflineSyncMeta(inMemoryOfflineStatus);
offlineSyncListeners.forEach((listener) => listener(inMemoryOfflineStatus));
};
const hydrateOfflineSyncStatus = () => {
const meta = loadOfflineSyncMeta();
const queue = loadOfflineQueue();
inMemoryOfflineStatus = {
pending: queue.length,
isFlushing: false,
lastSyncAt: typeof meta.lastSyncAt === 'string' ? meta.lastSyncAt : null,
lastFlushedCount: typeof meta.lastFlushedCount === 'number' ? meta.lastFlushedCount : 0,
lastError: typeof meta.lastError === 'string' ? meta.lastError : null,
};
};
hydrateOfflineSyncStatus();
const saveOfflineQueue = (queue: OfflineMutationItem[]) => {
if (typeof window === 'undefined') return;
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
emitOfflineSyncStatus({ pending: queue.length });
};
const enqueueOfflineMutation = (item: OfflineMutationItem) => {
const queue = loadOfflineQueue();
const duplicate = queue.some((queued) => queued.dedupeKey === item.dedupeKey);
if (duplicate) return;
queue.push(item);
saveOfflineQueue(queue);
};
const getOfflineSyncStatusSnapshot = (): OfflineSyncStatus => {
const queue = loadOfflineQueue();
return {
...inMemoryOfflineStatus,
pending: queue.length,
};
};
const subscribeOfflineSync = (listener: (status: OfflineSyncStatus) => void) => {
offlineSyncListeners.add(listener);
listener(getOfflineSyncStatusSnapshot());
return () => {
offlineSyncListeners.delete(listener);
};
};
const buildApiHeaders = (options: RequestInit = {}) => {
const token = getToken();
const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
const isFormData = options.body instanceof FormData;
const headers: Record<string, string> = {
return {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...Object.fromEntries(Object.entries(options.headers || {}).map(([k, v]) => [k, String(v)])),
...(token ? { 'Authorization': `Bearer ${token}` } : {})
} as Record<string, string>;
};
const flushOfflineQueueInternal = async () => {
if (offlineFlushPromise) {
return offlineFlushPromise;
}
const run = async () => {
if (typeof window === 'undefined') return { flushed: 0, pending: 0 };
emitOfflineSyncStatus({ isFlushing: true, lastError: null });
if (!navigator.onLine) {
const pending = loadOfflineQueue().length;
emitOfflineSyncStatus({ isFlushing: false, pending, lastError: 'offline' });
return { flushed: 0, pending };
}
const queue = loadOfflineQueue();
if (!queue.length) {
emitOfflineSyncStatus({
isFlushing: false,
pending: 0,
lastSyncAt: new Date().toISOString(),
lastFlushedCount: 0,
lastError: null,
});
return { flushed: 0, pending: 0 };
}
const stillPending: OfflineMutationItem[] = [];
let flushed = 0;
for (const item of queue) {
try {
const baseUrl = item.isCMS ? getCmsApiUrl() : getLmsApiUrl();
const response = await fetch(`${baseUrl}${item.url}`, {
method: item.method,
body: item.body,
headers: buildApiHeaders({ body: item.body })
});
if (!response.ok) {
// 4xx validation/auth errors should not block the queue forever.
if (response.status >= 500) {
stillPending.push(item);
}
continue;
}
flushed += 1;
} catch {
stillPending.push(item);
}
}
saveOfflineQueue(stillPending);
emitOfflineSyncStatus({
isFlushing: false,
pending: stillPending.length,
lastSyncAt: new Date().toISOString(),
lastFlushedCount: flushed,
lastError: stillPending.length ? 'partial' : null,
});
return { flushed, pending: stillPending.length };
};
offlineFlushPromise = run().finally(() => {
offlineFlushPromise = null;
});
return offlineFlushPromise;
};
const enqueueIfOffline = async (
kind: OfflineMutationKind,
url: string,
method: 'POST' | 'PUT' | 'DELETE',
body: string,
isCMS = false
) => {
if (typeof window !== 'undefined' && !navigator.onLine) {
enqueueOfflineMutation({
id: crypto.randomUUID(),
dedupeKey: `${method}:${url}:${body}`,
kind,
url,
method,
isCMS,
body,
createdAt: new Date().toISOString(),
});
return true;
}
return false;
};
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
const headers = buildApiHeaders(options);
const response = await fetch(`${baseUrl}${url}`, { ...options, headers });
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
@@ -583,6 +811,18 @@ const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean =
};
export const lmsApi = {
subscribeOfflineSync(listener: (status: OfflineSyncStatus) => void): () => void {
return subscribeOfflineSync(listener);
},
getOfflineSyncStatus(): OfflineSyncStatus {
return getOfflineSyncStatusSnapshot();
},
async flushOfflineQueue(): Promise<{ flushed: number; pending: number }> {
return flushOfflineQueueInternal();
},
async getCatalog(orgId?: string, userId?: string): Promise<Course[]> {
const params = new URLSearchParams();
if (orgId) params.append('organization_id', orgId);
@@ -639,10 +879,37 @@ export const lmsApi = {
},
async trackXapiStatement(payload: TrackXapiPayload): Promise<{ id: string; message: string }> {
return apiFetch('/xapi/statements', {
method: 'POST',
body: JSON.stringify(payload)
});
const url = '/xapi/statements';
const body = JSON.stringify(payload);
if (await enqueueIfOffline('xapi', url, 'POST', body)) {
return {
id: `offline-${Date.now()}`,
message: 'xAPI statement queued for sync',
};
}
try {
return await apiFetch(url, { method: 'POST', body });
} catch (error) {
if (typeof window !== 'undefined' && !navigator.onLine) {
enqueueOfflineMutation({
id: crypto.randomUUID(),
dedupeKey: `POST:${url}:${body}`,
kind: 'xapi',
url,
method: 'POST',
isCMS: false,
body,
createdAt: new Date().toISOString(),
});
return {
id: `offline-${Date.now()}`,
message: 'xAPI statement queued for sync',
};
}
throw error;
}
},
async getMe(): Promise<User> {
@@ -672,10 +939,49 @@ export const lmsApi = {
},
async submitScore(userId: string, course_id: string, lessonId: string, score: number, metadata: Record<string, unknown> = {}): Promise<UserGrade> {
return apiFetch('/grades', {
method: 'POST',
body: JSON.stringify({ user_id: userId, course_id, lesson_id: lessonId, score, metadata })
});
const url = '/grades';
const body = JSON.stringify({ user_id: userId, course_id, lesson_id: lessonId, score, metadata });
if (await enqueueIfOffline('grade', url, 'POST', body)) {
return {
id: `offline-${Date.now()}`,
user_id: userId,
course_id,
lesson_id: lessonId,
score,
attempts_count: 0,
metadata: { ...metadata, sync_pending: true },
created_at: new Date().toISOString(),
};
}
try {
return await apiFetch(url, { method: 'POST', body });
} catch (error) {
if (typeof window !== 'undefined' && !navigator.onLine) {
enqueueOfflineMutation({
id: crypto.randomUUID(),
dedupeKey: `POST:${url}:${body}`,
kind: 'grade',
url,
method: 'POST',
isCMS: false,
body,
createdAt: new Date().toISOString(),
});
return {
id: `offline-${Date.now()}`,
user_id: userId,
course_id,
lesson_id: lessonId,
score,
attempts_count: 0,
metadata: { ...metadata, sync_pending: true },
created_at: new Date().toISOString(),
};
}
throw error;
}
},
async getUserGrades(userId: string, courseId: string): Promise<UserGrade[]> {
@@ -694,6 +1000,10 @@ export const lmsApi = {
return apiFetch('/branding', {}, true);
},
async getCourseLanguageConfig(courseId: string): Promise<{ language_setting: 'auto' | 'fixed'; fixed_language: string | null }> {
return apiFetch(`/courses/${courseId}/language-config`);
},
async updateUser(userId: string, payload: { full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise<void> {
return apiFetch(`/users/${userId}`, {
method: 'POST',
@@ -715,10 +1025,31 @@ export const lmsApi = {
},
async recordInteraction(lessonId: string, payload: { video_timestamp?: number, event_type: string, metadata?: any }): Promise<void> {
return apiFetch(`/lessons/${lessonId}/interactions`, {
method: 'POST',
body: JSON.stringify(payload)
});
const url = `/lessons/${lessonId}/interactions`;
const body = JSON.stringify(payload);
if (await enqueueIfOffline('interaction', url, 'POST', body)) {
return;
}
try {
await apiFetch(url, { method: 'POST', body });
} catch (error) {
if (typeof window !== 'undefined' && !navigator.onLine) {
enqueueOfflineMutation({
id: crypto.randomUUID(),
dedupeKey: `POST:${url}:${body}`,
kind: 'interaction',
url,
method: 'POST',
isCMS: false,
body,
createdAt: new Date().toISOString(),
});
return;
}
throw error;
}
},
async getHeatmap(lessonId: string): Promise<{ second: number, count: number }[]> {