import { useState, useEffect } from 'react'; import type { CourseModuleV2 } from '../../types/getcertV2'; import { NapkinSpinner } from '../NapkinSpinner'; import { Clock, CheckCircle2, ExternalLink, FileText, BookOpen, Paperclip, ListChecks, Target, FlaskConical, Lock, ArrowRight, ArrowLeft, Tv, GraduationCap, Layers, Link2 } from 'lucide-react'; import { resolveCanonicalCourseTitle } from '../../utils/courseTitleResolver'; interface LearningModuleProps { courseName: string; courseSummary: string; modules: CourseModuleV2[]; onTakeAssessment: () => void; onBackToDashboard: () => void; generatingAssessment?: boolean; completedModuleIds?: string[] | null; onCompletedModulesChange?: (ids: string[]) => void; } export function formatDurationSeconds(totalSeconds: number): string { if (!totalSeconds || isNaN(totalSeconds) || totalSeconds <= 0) return ''; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { if (minutes > 0 && seconds > 0) return `${hours}h ${minutes}m ${seconds}s`; if (minutes > 0) return `${hours}h ${minutes}m`; if (seconds > 0) return `${hours}h ${seconds}s`; return `${hours}h`; } if (minutes > 0) { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } return `${seconds}s`; } function extractYouTubeVideoId(url: string): string | null { const value = url.trim(); const patterns = [ /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/, /youtu\.be\/([a-zA-Z0-9_-]{11})/, /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/, /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/, ]; for (const pattern of patterns) { const match = value.match(pattern); if (match?.[1]) { return match[1]; } } return null; } export function LearningModule({ courseName, courseSummary, modules, onTakeAssessment, onBackToDashboard, generatingAssessment = false, completedModuleIds = null, onCompletedModulesChange, }: LearningModuleProps) { const [localCompleted, setLocalCompleted] = useState>(new Set()); const completedModules = new Set( completedModuleIds !== null && completedModuleIds !== undefined ? completedModuleIds : [...localCompleted], ); // Active module currently being watched/studied const [activeModuleId, setActiveModuleId] = useState(() => { if (!modules || modules.length === 0) return ''; const firstUncompleted = modules.find((m) => !completedModules.has(m.id)); return firstUncompleted ? firstUncompleted.id : modules[0].id; }); // Active tab in the lesson briefing section const [activeTab, setActiveTab] = useState<'notes' | 'takeaways' | 'resources' | 'syllabus'>('notes'); const activeModule = modules.find((m) => m.id === activeModuleId) || modules[0] || null; const activeModuleIndex = modules.findIndex((m) => m.id === (activeModule?.id || '')); const markModuleComplete = (moduleId: string) => { if (completedModules.has(moduleId)) return; const next = new Set([...completedModules, moduleId]); if (onCompletedModulesChange) { onCompletedModulesChange(modules.filter((m) => next.has(m.id)).map((m) => m.id)); } else { setLocalCompleted(next); } }; const allModulesCompleted = modules.length > 0 && completedModules.size === modules.length; const isActiveCompleted = activeModule ? completedModules.has(activeModule.id) : false; // Extract video and other resources for active module const activeResources = activeModule?.resources || []; const parsedVideoResources = activeResources.map((resource) => ({ resource, videoId: resource.type === 'youtube' ? extractYouTubeVideoId(resource.url) : null, })); const primaryVideo = parsedVideoResources.find((item) => !!item.videoId) || null; const secondaryVideos = parsedVideoResources.filter((item) => !!item.videoId && item !== primaryVideo); const linkedResources = parsedVideoResources.filter((item) => !item.videoId); const completionPercent = modules.length === 0 ? 0 : Math.round((completedModules.size / modules.length) * 100); // Map of videoId -> formatted duration string (e.g. '26m 52s', '20h 47m') const [videoDurations, setVideoDurations] = useState>({}); // 1. Initial resolution from pre-cached or resource duration metadata useEffect(() => { const initialMap: Record = {}; modules.forEach((mod) => { mod.resources?.forEach((res) => { if (res.type === 'youtube') { const id = extractYouTubeVideoId(res.url); if ( id && res.duration && !res.duration.includes('>20m') && !res.duration.includes('Full Course') ) { initialMap[id] = res.duration; } } }); }); if (Object.keys(initialMap).length > 0) { setVideoDurations((prev) => ({ ...initialMap, ...prev })); } }, [modules]); // 2. Query /api/v2/video-info for exact server-verified duration useEffect(() => { const videoIdsToFetch = new Set(); modules.forEach((mod) => { mod.resources?.forEach((res) => { if (res.type === 'youtube') { const id = extractYouTubeVideoId(res.url); const current = videoDurations[id] || (res as any)?.duration || ''; if ( id && (!current || current.includes('>20m') || current.includes('Full Course') || current.includes('Loading')) ) { videoIdsToFetch.add(id); } } }); }); videoIdsToFetch.forEach((vid) => { fetch(`/api/v2/video-info?videoId=${encodeURIComponent(vid)}`) .then((res) => (res.ok ? res.json() : null)) .then((payload) => { if (payload?.success && payload?.data?.durationFormatted) { setVideoDurations((prev) => ({ ...prev, [vid]: payload.data.durationFormatted, })); } }) .catch(() => {}); }); }, [modules, videoDurations]); // 3. Client-side YouTube Iframe postMessage listener (real-time zero latency) useEffect(() => { const handleMessage = (event: MessageEvent) => { try { if ( typeof event.origin === 'string' && !event.origin.includes('youtube.com') && !event.origin.includes('youtube-nocookie.com') ) { return; } const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; const currentVid = primaryVideo?.videoId; if (data?.event === 'infoDelivery' && data?.info?.duration && currentVid) { const seconds = Math.round(data.info.duration); if (seconds > 0) { const formatted = formatDurationSeconds(seconds); setVideoDurations((prev) => { if (prev[currentVid] === formatted) return prev; return { ...prev, [currentVid]: formatted }; }); } } } catch {} }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [primaryVideo?.videoId]); const activeVideoId = primaryVideo?.videoId || null; const rawActiveDur = activeVideoId ? (videoDurations[activeVideoId] || (primaryVideo?.resource as any)?.duration || '') : ''; const activeDurationFormatted = rawActiveDur && !rawActiveDur.includes('>20m') && !rawActiveDur.includes('Full Course') ? rawActiveDur : ''; const displayCourseName = resolveCanonicalCourseTitle(courseName); return (
{/* 1. Institutional Header Strip */}
{/* Back button + Course Title */}
ACTIVE ACCREDITATION TRACK

{displayCourseName}

{/* Progress Indicator */}
{completionPercent}% Ready
{/* 2. Main Studio Workspace */}
{/* LEFT: Cinema Video Stage + Interactive Lesson Workbook (12 cols mobile, 8 cols desktop) */}
{/* VIDEO PLAYER CHASSIS */}
{/* 16:9 Video Canvas */}
{primaryVideo?.videoId ? (