import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks, ChevronsLeft, ChevronsRight, Hourglass } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { InfoDialog } from "@/components/generic/Dialogs/Client/InfoDialog";
import LessonBlock from "../components/LessonBlock.jsx";
import QuizBlock from "../components/blocks/QuizBlock.jsx";
import CourseCompleteBlock from "../components/blocks/CourseCompleteBlock.jsx";
import MarkCompleteButton from "../components/MarkCompleteButton.jsx";
import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import api from "@/utils/api.util";
// ─── Quiz Prerequisite Gate ────────────────────────────────────────────────
const QuizPrerequisiteGate = ({ previousQuizzes, units, onQuizClick }) => {
const [open, setOpen] = useState(false);
const passed = previousQuizzes.filter((q) => q.has_passed).length;
const useModal = previousQuizzes.length > QUIZ_MODAL_THRESHOLD;
return (
Complete Previous {previousQuizzes.length > 1 ? "Quizzes" : "Quiz"} First
You must pass the required {previousQuizzes.length > 1 ? "quizzes" : "quiz"} before you can take this one.
{useModal ? (
<>
{passed} of {previousQuizzes.length} quizzes passed
setOpen(true)}>
View Required Quizzes
setOpen(false)}
/>
>
) : (
)}
);
};
// ─── Assessment Gate ───────────────────────────────────────────────────────
const QUIZ_MODAL_THRESHOLD = 1;
const QuizGateList = ({ requiredQuizzes, units, onQuizClick, onItemClick }) => (
{requiredQuizzes.map((q) => (
{
onQuizClick({ unit: units.find((u) => u.unit_id === q.unitId), quiz: { quiz_id: q.quizId } });
onItemClick?.();
}}
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
q.has_passed
? "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
: "border-border hover:bg-muted cursor-pointer"
}`}
>
{q.has_passed
?
:
}
{q.title}
{!q.has_passed &&
}
))}
);
const AssessmentGate = ({ requiredQuizzes, units, onQuizClick }) => {
const [open, setOpen] = useState(false);
const passed = requiredQuizzes.filter((q) => q.has_passed).length;
const useModal = requiredQuizzes.length > QUIZ_MODAL_THRESHOLD;
return (
Complete All Unit Quizzes First
You must pass every required unit quiz before you can attempt the Final Assessment.
{useModal ? (
<>
{passed} of {requiredQuizzes.length} quizzes passed
setOpen(true)}>
View Required Quizzes
setOpen(false)}
/>
>
) : (
)}
);
};
// ─── Sidebar ───────────────────────────────────────────────────────────────
const SidebarContent = ({
units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick,
courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked,
isCompleted, selectedCompletion, onCompletionClick,
getLessonCompleted, getUnitCompleted, isQuizLocked,
loading,
}) => (
Course Content
{loading ? (
{Array.from({ length: 4 }).map((_, i) => (
))}
) : (
<>
{units.map((unit, index) => (
{getUnitCompleted?.(unit)
?
:
}
Unit {index + 1}: {unit.title}
{(unit.lessons ?? []).map((lesson) => {
const lessonDone = getLessonCompleted?.(lesson);
return (
onLessonClick({ unit, lesson })}
className={`flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 hover:text-foreground cursor-pointer transition-colors ${selectedLessonId === lesson.lesson_id
? "bg-muted-foreground/10 text-foreground font-medium"
: "text-muted-foreground"
}`}
>
{lessonDone
?
:
}
{lesson.title}
);
})}
{unit.quiz && (() => {
const quizLocked = isQuizLocked?.(unit);
return (
onQuizClick({ unit, quiz: unit.quiz })}
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
selectedQuizId === unit.quiz.quiz_id
? "bg-muted-foreground/10 text-foreground font-medium"
: unit.quiz.has_passed
? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10"
: quizLocked
? "text-muted-foreground/50 hover:bg-muted-foreground/5"
: "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"
}`}
>
{unit.quiz.has_passed
?
: quizLocked
?
:
}
{"Quiz"}
);
})()}
))}
{courseAssessment && (
{assessmentLocked
?
:
}
Assessment
)}
{isCompleted && (
)}
>
)}
);
// ─── UnitList ─────────────────────────────────────────────────────────────
const UnitList = () => {
const { id: courseId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const {
course, courseLoading, courseBlocked, getCourse,
lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveQuizDraft,
assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment,
} = useClientCourses();
const {
fetchCourseProgress,
upsertLessonProgress,
upsertWatchProgress,
markComplete,
isCompleted: isProgressCompleted,
isRead: isProgressRead,
completedTasks,
clearCompletedTasks,
resetProgress,
} = useCourseReadingProgress();
const { profile, getProfile } = useProfile();
// Tracks which lessons have been marked completed this session to avoid duplicate calls
const completedSessionRef = useRef(new Set());
// ── Task context — populated from navigation state or backend fallback ──
const [taskCtx, setTaskCtx] = useState(null);
// ── Local UI state ──────────────────────────────────────────────────────
const [selectedLessonId, setSelectedLessonId] = useState(null);
const [selectedQuizId, setSelectedQuizId] = useState(null);
const [selectedAssessment, setSelectedAssessment] = useState(false);
const [selectedCompletion, setSelectedCompletion] = useState(false);
const [selectedUnitId, setSelectedUnitId] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
const resetCompletion = () => setSelectedCompletion(false);
// ── No-assessment notice — shown once per course visit, informational only ──
// Gated behind the "Course notices" toggle in Account Settings.
const [noAssessmentDialogOpen, setNoAssessmentDialogOpen] = useState(false);
const noAssessmentNoticeShown = useRef(false);
useEffect(() => {
getProfile();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
noAssessmentNoticeShown.current = false;
}, [courseId]);
useEffect(() => {
const shouldShow =
course && !courseBlocked && course.duration_seconds &&
!course.assessment && !noAssessmentNoticeShown.current &&
profile && (profile.personal_info?.show_course_notices ?? true);
if (!shouldShow) return;
noAssessmentNoticeShown.current = true;
setNoAssessmentDialogOpen(true);
}, [course, courseBlocked, profile]);
// ── Session guard — block navigation while a quiz/assessment is in progress ─
const quizActiveRef = useRef(false); // sync check inside handlers
const [quizSessionActive, setQuizSessionActive] = useState(false);
const [pendingNav, setPendingNav] = useState(null); // deferred nav fn
const setQuizActive = useCallback((active) => {
quizActiveRef.current = active;
setQuizSessionActive(active);
}, []);
// ── Quiz gate — all required unit quizzes must be passed before assessment ─
const requiredQuizzes = (course?.units ?? [])
.filter((u) => u.quiz?.is_required)
.map((u) => ({
unit: u,
unitId: u.unit_id,
quizId: u.quiz.quiz_id,
title: u.quiz.title || "Quiz",
has_passed: u.quiz.has_passed ?? false,
}));
const allRequiredQuizzesPassed =
requiredQuizzes.length === 0 || requiredQuizzes.every((q) => q.has_passed);
// ── Quiz sequential lock — unit N's quiz is locked until all previous required quizzes are passed ─
const lockedQuizUnitIds = new Set(
(course?.units ?? [])
.filter((u, i, arr) =>
u.quiz && arr.slice(0, i).some(prev => prev.quiz?.is_required && !prev.quiz.has_passed)
)
.map(u => u.unit_id)
);
const selectedUnitIndex = (course?.units ?? []).findIndex(u => u.unit_id === selectedUnitId);
const previousRequiredQuizzes = selectedUnitIndex > 0
? (course?.units ?? []).slice(0, selectedUnitIndex)
.filter(u => u.quiz?.is_required)
.map(u => ({
unit: u,
unitId: u.unit_id,
quizId: u.quiz.quiz_id,
title: u.quiz.title || "Quiz",
has_passed: u.quiz.has_passed ?? false,
}))
: [];
// ── Block React Router navigation (back button / programmatic navigate) ──
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
quizSessionActive && currentLocation.pathname !== nextLocation.pathname
);
useEffect(() => {
if (blocker.state !== "blocked") return;
setPendingNav(() => () => blocker.proceed());
}, [blocker.state]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Block browser tab-close / hard navigation during active session ───────
useEffect(() => {
if (!quizSessionActive) return;
const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [quizSessionActive]);
// ── Flatten all content (lessons + quiz + final assessment) ──────────────
const allContent = (course?.units ?? []).flatMap((u) => [
...(u.lessons ?? []).map((l) => ({ type: "lesson", unit: u, lesson: l })),
...(u.quiz ? [{ type: "quiz", unit: u, quiz: u.quiz }] : []),
]);
if (course?.assessment && allRequiredQuizzesPassed) {
allContent.push({ type: "assessment", assessment: course.assessment });
}
// ── Scroll progress ────────────────────────────────────────────────────
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => {
setScrollProgress(0);
window.scrollTo(0, 0);
}, [selectedLessonId, selectedQuizId, selectedAssessment, selectedCompletion]);
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight <= 0) { setScrollProgress(100); return; }
setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// ── Lesson completion-trigger dispatch ─────────────────────────────────
// read_all_content (or unconfigured/default) → scroll-to-bottom, below.
// watch_percent / manual_complete → their own dedicated triggers further down
// (video/audio onWatchProgress callback, MarkCompleteButton) — the scroll
// trigger must NOT also fire completion for those, so it's gated here.
const selectedUnitStub = course?.units?.find((u) => u.unit_id === selectedUnitId);
const selectedLessonStub = selectedUnitStub?.lessons?.find((l) => l.lesson_id === selectedLessonId) ?? null;
const selectedLessonCompletionType = selectedLessonStub?.completion?.type ?? 'read_all_content';
// Quiz/assessment submits go through ClientCoursesContext, not
// ClientCourseReadingProgressContext, so their completed_tasks (pass_quiz can complete a
// read_unit/read_course task requirement with no lesson ever read) don't flow through the
// shared completedTasks toast effect above — surface them directly here instead.
const notifyCompletedTasks = useCallback((tasks) => {
(tasks ?? []).forEach((t) => toast(`"${t.task_name}" automatically turned in!`));
}, []);
// ── watch_percent / watch_video / listen_audio trigger: video/audio block
// reports playback progress. meta.blockId/blockType (attached by PreviewBlock)
// let the backend drive the per-block watch_video/listen_audio types alongside
// the aggregate watch_percent one — safe to always pass through, the backend
// no-ops whichever type isn't configured on the lesson.
const handleWatchProgress = useCallback((percent, meta) => {
if (!selectedLessonId || !selectedUnitId) return;
upsertWatchProgress(courseId, selectedUnitId, selectedLessonId, percent, meta);
}, [courseId, selectedUnitId, selectedLessonId, upsertWatchProgress]);
// ── manual_complete trigger: learner clicks the Mark Complete button ────
const handleMarkComplete = useCallback(async () => {
if (!selectedLessonId || !selectedUnitId) return;
const result = await markComplete(courseId, selectedUnitId, selectedLessonId);
if (result?.course?.status === 'completed') {
toast.success('All lessons read! Finish the quizzes & assessment to get certified.', { duration: 5000 });
}
}, [courseId, selectedUnitId, selectedLessonId, markComplete]);
// ── Mark lesson completed when user scrolls to the bottom ─────────────
useEffect(() => {
if (selectedLessonCompletionType !== 'read_all_content') return;
if (scrollProgress < 100 || !selectedLessonId || !selectedUnitId || !lesson?.uuid) return;
if (completedSessionRef.current.has(selectedLessonId)) return;
if (isProgressCompleted(lesson.uuid)) return;
completedSessionRef.current.add(selectedLessonId);
(async () => {
const result = await upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
if (!result) return;
// toast.success(`"${lesson.title}" marked as read.`);
// if (result.unit?.status === 'completed') {
// const unitTitle = currentUnit?.title;
// toast.success(
// unitTitle ? `Unit "${unitTitle}" complete!` : 'Unit complete!',
// { duration: 4000 }
// );
// }
// if (result.course?.status === 'completed') {
// toast.success(
// 'All lessons read! Finish the quizzes & assessment to get certified.',
// { duration: 5000 }
// );
// }
})();
}, [scrollProgress]);
// ── Fetch course + progress on mount ─────────────────────────────────
useEffect(() => {
getCourse(courseId);
fetchCourseProgress(courseId);
return () => {
resetLesson();
resetQuiz();
resetAssessment();
resetProgress();
completedSessionRef.current.clear();
};
}, [courseId]);
// ── Task context: state-first, endpoint fallback ──────────────────────
// If navigated from ReadCourse the taskCtx is in location.state;
// if the user opened this URL directly, fetch from the backend.
useEffect(() => {
const stateCtx = location.state?.taskCtx;
if (stateCtx) {
setTaskCtx(stateCtx);
return;
}
api.get(`/client/courses/${courseId}/task-context`)
.then(({ data }) => {
if (data?.data?.has_task) setTaskCtx(data.data);
})
.catch(() => {}); // non-critical — silently swallow
}, [courseId]);
// ── Toast when a task's read requirements are all done ────────────────
useEffect(() => {
if (!completedTasks.length) return;
completedTasks.forEach((t) => {
toast(`"${t.task_name}" automatically turned in!`);
});
clearCompletedTasks();
}, [completedTasks]);
// ── Auto-load lesson once course data is available ────────────────────
// If navigated from CourseDetails with a specific lesson, open that one;
// otherwise fall back to the first lesson.
useEffect(() => {
if (!course || selectedLessonId || selectedQuizId || selectedAssessment || selectedCompletion) return;
const { lessonId, unitId, seekFirstIncomplete, quizUnitId, seekAssessment } = location.state ?? {};
if (seekAssessment && course.assessment) {
setSelectedAssessment(true);
if (allRequiredQuizzesPassed) getCourseAssessment(courseId);
return;
}
if (quizUnitId) {
const targetUnit = (course.units ?? []).find((u) => String(u.unit_id) === String(quizUnitId));
if (targetUnit?.quiz) {
setSelectedQuizId(targetUnit.quiz.quiz_id);
setSelectedUnitId(targetUnit.unit_id);
if (!lockedQuizUnitIds.has(targetUnit.unit_id)) getUnitQuiz(courseId, targetUnit.unit_id);
return;
}
}
if (seekFirstIncomplete) {
const firstIncompleteUnit = (course.units ?? []).find((u) => u.quiz && !u.quiz.has_passed);
if (firstIncompleteUnit) {
setSelectedQuizId(firstIncompleteUnit.quiz.quiz_id);
setSelectedUnitId(firstIncompleteUnit.unit_id);
getUnitQuiz(courseId, firstIncompleteUnit.unit_id);
return;
}
if (course.assessment) {
setSelectedAssessment(true);
if (allRequiredQuizzesPassed) getCourseAssessment(courseId);
return;
}
}
if (lessonId && unitId) {
setSelectedLessonId(lessonId);
setSelectedUnitId(unitId);
getLesson(courseId, unitId, lessonId);
} else {
const firstUnit = course.units?.[0];
const firstLesson = firstUnit?.lessons?.[0];
if (firstUnit && firstLesson) {
setSelectedLessonId(firstLesson.lesson_id);
setSelectedUnitId(firstUnit.unit_id);
getLesson(courseId, firstUnit.unit_id, firstLesson.lesson_id);
}
}
}, [course]);
// ── Breadcrumbs ─────────────────────────────────────────────────────────
const currentUnit = course?.units?.find((u) => u.unit_id === selectedUnitId);
const currentLesson = lesson;
const currentLessonStub = currentUnit?.lessons?.find((l) => l.lesson_id === selectedLessonId) ?? null;
const pageTitle = (() => {
if (!course) return undefined;
if (selectedCompletion) return `${course.title} – Completed - STARR`;
if (selectedAssessment) return `${course.title} – Assessment - STARR`;
if (selectedQuizId && currentUnit) return `${currentUnit.title} – Quiz - STARR`;
const lessonTitle = lesson?.title ?? currentLessonStub?.title;
if (selectedLessonId && lessonTitle) return `${lessonTitle} - STARR`;
return `${course.title} - STARR`;
})();
const breadcrumbItemsFull = [
{ label: "Home", icon: , to: `/dashboard` },
{ label: "Courses", to: `/course` },
{ label: course?.title ?? "Course", to: `/course/${courseId}` },
{
label: selectedCompletion
? "Course Complete"
: selectedAssessment
? "Course Assessment"
: currentUnit ? `Unit ${(course?.units?.indexOf(currentUnit) ?? 0) + 1}: ${currentUnit.title}` : "Select a lesson"
},
];
const breadcrumbItemsMobile = [
{ label: "Home", icon: , to: `/dashboard` },
{ label: selectedCompletion ? "Course Complete" : selectedAssessment ? "Course Assessment" : (currentUnit?.title ?? "Select a lesson") },
];
// ── Session guard helpers ──────────────────────────────────────────────
const handleConfirmNav = useCallback(() => {
const fn = pendingNav;
setPendingNav(null);
quizActiveRef.current = false;
setQuizSessionActive(false);
if (blocker.state === "blocked") blocker.proceed();
else fn?.();
}, [pendingNav, blocker]);
const handleCancelNav = useCallback(() => {
setPendingNav(null);
if (blocker.state === "blocked") blocker.reset();
}, [blocker]);
// ── Lesson click ───────────────────────────────────────────────────────
const handleLessonClick = useCallback(async ({ unit, lesson: lessonStub }) => {
if (lessonStub.lesson_id === selectedLessonId) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedLessonId(lessonStub.lesson_id);
setSelectedQuizId(null);
setSelectedAssessment(false);
resetCompletion();
resetQuiz();
resetAssessment();
setSelectedUnitId(unit.unit_id);
setSidebarOpen(false);
await getLesson(courseId, unit.unit_id, lessonStub.lesson_id);
if (!isProgressRead(lessonStub.uuid)) {
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress');
}
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedLessonId, courseId, getLesson, resetQuiz, resetAssessment, isProgressRead, upsertLessonProgress]);
// ── Quiz click ─────────────────────────────────────────────────────────
const handleQuizClick = useCallback(async ({ unit, quiz: quizStub }) => {
if (quizStub.quiz_id === selectedQuizId) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedQuizId(quizStub.quiz_id);
setSelectedLessonId(null);
setSelectedAssessment(false);
resetCompletion();
resetLesson();
resetAssessment();
setSelectedUnitId(unit.unit_id);
setSidebarOpen(false);
if (!lockedQuizUnitIds.has(unit.unit_id)) {
await getUnitQuiz(courseId, unit.unit_id);
}
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment, lockedQuizUnitIds]);
// ── Course assessment click ─────────────────────────────────────────────
const handleAssessmentClick = useCallback(async () => {
if (selectedAssessment) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedAssessment(true);
setSelectedLessonId(null);
setSelectedQuizId(null);
resetCompletion();
resetLesson();
resetQuiz();
setSidebarOpen(false);
if (allRequiredQuizzesPassed) await getCourseAssessment(courseId);
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedAssessment, courseId, getCourseAssessment, resetLesson, resetQuiz, allRequiredQuizzesPassed]);
// ── Course complete click ───────────────────────────────────────────────
const handleCompletionClick = useCallback(() => {
if (selectedCompletion) { setSidebarOpen(false); return; }
const doNav = () => {
setSelectedLessonId(null);
setSelectedQuizId(null);
setSelectedAssessment(false);
resetLesson();
resetQuiz();
resetAssessment();
setSelectedCompletion(true);
setSidebarOpen(false);
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
doNav();
}, [selectedCompletion, resetLesson, resetQuiz, resetAssessment]);
// Stable draft callbacks — avoids recreating on every render (which would re-trigger QuizBlock's onDraft effect)
const handleQuizDraft = useCallback((answers) => {
saveQuizDraft(courseId, selectedUnitId, selectedQuizId, answers);
}, [courseId, selectedUnitId, selectedQuizId, saveQuizDraft]);
const handleAssessmentDraft = useCallback((answers) => {
if (!assessment?.assessment_id) return;
saveDraft(courseId, assessment.assessment_id, answers);
}, [courseId, assessment?.assessment_id, saveDraft]);
// ── Next content item (lesson, quiz, or final assessment) ─────────────
const getNextContent = useCallback(() => {
const idx = allContent.findIndex((item) =>
(item.type === "lesson" && item.lesson.lesson_id === selectedLessonId) ||
(item.type === "quiz" && item.quiz.quiz_id === selectedQuizId) ||
(item.type === "assessment" && selectedAssessment)
);
return idx !== -1 && idx < allContent.length - 1 ? allContent[idx + 1] : null;
}, [selectedLessonId, selectedQuizId, selectedAssessment, allContent]);
const units = course?.units ?? [];
const nextContent = getNextContent();
const nextLabel = nextContent
? nextContent.type === "lesson" ? nextContent.lesson.title
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
: (nextContent.assessment?.title || "Course Assessment")
: null;
const showSidebarContent = desktopSidebarOpen && !quizSessionActive;
const handleNextContentClick = () => {
if (!nextContent) return;
if (nextContent.type === "lesson") handleLessonClick(nextContent);
else if (nextContent.type === "quiz") handleQuizClick(nextContent);
else handleAssessmentClick();
};
// ── Access blocked (403 from getCourse) ──────────────────────────────────
if (courseBlocked) {
return (
Premium / Exclusive Content
To take this course, we advise you to subscribe to one of our available tier plans and unlock access to this content.
navigate('/plans')} className="gap-1.5">
View Available Plans
Already subscribed? Your plan may not cover this tier.
);
}
// ── Content not ready (duration_seconds === 0 — no lesson content authored yet) ──
if (!courseLoading && course && !course.duration_seconds) {
return (
Course Not Yet Available
This course is currently being prepared. Please check back later.
navigate(`/course/${courseId}`)} className="gap-1.5">
Back to Course Details
);
}
return (
<>
{/* ── Session-guard dialog — shown when user tries to navigate away mid-quiz ── */}
!open && handleCancelNav()}
title={`Leave ${selectedAssessment ? "Assessment" : "Quiz"}?`}
description=""
footer={
<>
Stay
Leave Anyway
>
}
>
You have an ongoing {selectedAssessment ? "assessment" : "quiz"} session in progress.
Leaving now will not submit your answers — your session will remain open and the administrator can see it.
{selectedAssessment && (
Your assessment timer will keep counting while you're away.
)}
{/* ── No-assessment notice — informational, shown once per course visit ── */}
{/* ── Task-mode banner ─────────────────────────────────────────── */}
{taskCtx?.has_task && (
{course?.is_completed
? 'Course complete — tracking finished'
: 'Task mode — reading progress is being tracked automatically'
}
)}
{/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && (
Up next
{nextContent.type === "lesson" ? nextContent.lesson.title
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
: (nextContent.assessment?.title || "Course Assessment")}
)}
{/* ── Up next after passing a quiz ── */}
{selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && (
Up next
{nextContent.type === "lesson" ? nextContent.lesson.title
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
: (nextContent.assessment?.title || "Course Assessment")}
)}
{/* ── Top sticky bar ── */}
{/* Breadcrumb */}
{/* Mobile hamburger */}
isProgressCompleted(l.uuid)}
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
loading={courseLoading}
/>
{/* Scroll progress bar */}
{lesson && (
)}
{/* ── Desktop sidebar: rail + collapsible content panel (desktop only) ── */}
{/* Rail — always visible */}
setDesktopSidebarOpen((prev) => !prev)}
disabled={quizSessionActive}
title={showSidebarContent ? "Collapse sidebar" : "Expand sidebar"}
>
{showSidebarContent
?
:
}
{/* Content panel — hidden during quiz / assessment session */}
{showSidebarContent && (
isProgressCompleted(l.uuid)}
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
loading={courseLoading}
/>
)}
{/* ── Main content ── */}
{selectedCompletion ? (
) : selectedAssessment ? (
!allRequiredQuizzesPassed ? (
) : (
startCourseAssessment(courseId, assessment.assessment_id)}
onDraft={handleAssessmentDraft}
onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)}
onSubmit={async (answers, sessionId) => {
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId);
notifyCompletedTasks(result?.completed_tasks);
await getCourse(courseId);
return result;
}}
onRetake={() => getCourseAssessment(courseId)}
onActiveChange={setQuizActive}
onNextContent={nextContent ? handleNextContentClick : undefined}
nextLabel={nextLabel}
/>
)
) : selectedQuizId ? (
lockedQuizUnitIds.has(selectedUnitId) ? (
) : (
{
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
notifyCompletedTasks(result?.completed_tasks);
await getCourse(courseId);
return result;
}}
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
onActiveChange={setQuizActive}
onNextContent={nextContent ? handleNextContentClick : undefined}
nextLabel={nextLabel}
/>
)
) : (
<>
{!lessonLoading && lesson && selectedLessonCompletionType === 'manual_complete' && (
)}
>
)}
>
);
};
export default UnitList;