Files
starr-philproperties/src/modules/client/pages/UnitList.jsx
T
kennethobsequio 536c644623 add: more things
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-04 06:48:04 +08:00

1039 lines
52 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<div className="w-16 h-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center mb-4">
<Lock className="size-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold mb-2">
Complete Previous {previousQuizzes.length > 1 ? "Quizzes" : "Quiz"} First
</h2>
<p className="text-muted-foreground mb-4 max-w-sm text-sm">
You must pass the required {previousQuizzes.length > 1 ? "quizzes" : "quiz"} before you can take this one.
</p>
{useModal ? (
<>
<p className="text-sm mb-4">{passed} of {previousQuizzes.length} quizzes passed</p>
<Button onClick={() => setOpen(true)}>
<ClipboardList className="size-4" />
View Required Quizzes
</Button>
<ResponsiveModal
open={open}
onOpenChange={setOpen}
title="Required Quizzes"
description={`Pass the required quizzes to unlock this one.`}
hideDrawerClose={false}
>
<ScrollArea className="max-h-72 pr-1">
<QuizGateList
requiredQuizzes={previousQuizzes}
units={units}
onQuizClick={onQuizClick}
onItemClick={() => setOpen(false)}
/>
</ScrollArea>
</ResponsiveModal>
</>
) : (
<div className="w-full max-w-sm text-left">
<QuizGateList
requiredQuizzes={previousQuizzes}
units={units}
onQuizClick={onQuizClick}
/>
</div>
)}
</div>
);
};
// ─── Assessment Gate ───────────────────────────────────────────────────────
const QUIZ_MODAL_THRESHOLD = 1;
const QuizGateList = ({ requiredQuizzes, units, onQuizClick, onItemClick }) => (
<div className="space-y-2">
{requiredQuizzes.map((q) => (
<div
key={q.quizId}
onClick={q.has_passed ? undefined : () => {
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
? <CheckCircle2 className="size-5 text-emerald-500 shrink-0" />
: <Circle className="size-5 text-muted-foreground shrink-0" />
}
<span className={`text-sm font-medium flex-1 ${q.has_passed ? "text-emerald-700 dark:text-emerald-400" : ""}`}>
{q.title}
</span>
{!q.has_passed && <ArrowRight className="size-4 text-muted-foreground shrink-0" />}
</div>
))}
</div>
);
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 (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<div className="w-16 h-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center mb-4">
<Lock className="size-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold mb-2">Complete All Unit Quizzes First</h2>
<p className="text-muted-foreground mb-4 max-w-sm text-sm">
You must pass every required unit quiz before you can attempt the Final Assessment.
</p>
{useModal ? (
<>
<p className="text-sm mb-4">
{passed} of {requiredQuizzes.length} quizzes passed
</p>
<Button onClick={() => setOpen(true)}>
<ClipboardList className="size-4" />
View Required Quizzes
</Button>
<ResponsiveModal
open={open}
onOpenChange={setOpen}
title="Required Quizzes"
description={`Complete all ${requiredQuizzes.length} quizzes before attempting the Final Assessment.`}
hideDrawerClose={false}
>
<ScrollArea className="max-h-72 pr-1">
<QuizGateList
requiredQuizzes={requiredQuizzes}
units={units}
onQuizClick={onQuizClick}
onItemClick={() => setOpen(false)}
/>
</ScrollArea>
</ResponsiveModal>
</>
) : (
<div className="w-full max-w-sm text-left">
<QuizGateList
requiredQuizzes={requiredQuizzes}
units={units}
onQuizClick={onQuizClick}
/>
</div>
)}
</div>
);
};
// ─── Sidebar ───────────────────────────────────────────────────────────────
const SidebarContent = ({
units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick,
courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked,
isCompleted, selectedCompletion, onCompletionClick,
getLessonCompleted, getUnitCompleted, isQuizLocked,
loading,
}) => (
<ScrollArea className="h-full p-3 md:p-4">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2 px-2">
Course Content
</p>
{loading ? (
<div className="space-y-2 px-2 pt-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full rounded-md" />
))}
</div>
) : (
<>
<Accordion type="multiple" defaultValue={units[0] ? [`unit-${units[0].unit_id}`] : []} className="space-y-1">
{units.map((unit, index) => (
<AccordionItem key={unit.unit_id} value={`unit-${unit.unit_id}`} className="border-none">
<AccordionTrigger className="px-3 py-2 text-sm font-semibold rounded-lg hover:bg-muted-foreground/10 hover:no-underline">
<span className="flex items-center gap-2 min-w-0">
{getUnitCompleted?.(unit)
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <Circle className="size-4 text-muted-foreground/40 shrink-0" />
}
<span className="truncate lg:w-40">Unit {index + 1}: {unit.title}</span>
</span>
</AccordionTrigger>
<AccordionContent className="pb-1">
<ul className="space-y-0.5">
{(unit.lessons ?? []).map((lesson) => {
const lessonDone = getLessonCompleted?.(lesson);
return (
<li
key={lesson.lesson_id}
onClick={() => 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
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
: <Circle className="size-3.5 text-muted-foreground/30 shrink-0" />
}
<span className="truncate lg:w-40">{lesson.title}</span>
</li>
);
})}
{unit.quiz && (() => {
const quizLocked = isQuizLocked?.(unit);
return (
<li
onClick={() => 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
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
: quizLocked
? <Lock className="size-3.5 text-amber-500 shrink-0" />
: <ClipboardList className="size-3.5 shrink-0" />
}
{"Quiz"}
</li>
);
})()}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
{courseAssessment && (
<div className="mt-3 border-t pt-3">
<div
onClick={onAssessmentClick}
className={`flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold cursor-pointer transition-colors ${selectedAssessment
? "bg-muted-foreground/10 text-foreground"
: "hover:bg-muted-foreground/10 text-foreground"
}`}
>
{assessmentLocked
? <Lock className="size-4 shrink-0 text-amber-500" />
: <GraduationCap className="size-4 shrink-0 text-muted-foreground" />
}
Assessment
</div>
</div>
)}
{isCompleted && (
<div className="mt-1">
<div
onClick={onCompletionClick}
className={`flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold cursor-pointer transition-colors ${selectedCompletion
? "bg-muted-foreground/10 text-foreground"
: "hover:bg-muted-foreground/10 text-foreground"
}`}
>
<Trophy className="size-4 shrink-0 text-amber-500" />
Course Complete
</div>
</div>
)}
</>
)}
<ScrollBar orientation="vertical" />
</ScrollArea>
);
// ─── 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,
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);
}, []);
// ── Mark lesson completed when user scrolls to the bottom ─────────────
useEffect(() => {
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.success(`"${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: <House className="size-4" />, 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: <House className="size-4" />, 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 (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<Lock className="size-7 text-amber-500" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
To take this course, we advise you to subscribe to one of our available tier plans and unlock access to this content.
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
);
}
// ── Content not ready (duration_seconds === 0 — no lesson content authored yet) ──
if (!courseLoading && course && !course.duration_seconds) {
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Hourglass className="size-7 text-muted-foreground" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Course Not Yet Available</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
This course is currently being prepared. Please check back later.
</p>
</div>
<Button variant="outline" onClick={() => navigate(`/course/${courseId}`)} className="gap-1.5">
Back to Course Details
</Button>
</div>
);
}
return (
<>
<PageMeta title={pageTitle} />
{/* ── Session-guard dialog — shown when user tries to navigate away mid-quiz ── */}
<ResponsiveModal
open={!!pendingNav || blocker.state === "blocked"}
onOpenChange={(open) => !open && handleCancelNav()}
title={`Leave ${selectedAssessment ? "Assessment" : "Quiz"}?`}
description=""
footer={
<>
<Button variant="outline" onClick={handleCancelNav}>
Stay
</Button>
<Button variant="destructive" onClick={handleConfirmNav}>
Leave Anyway
</Button>
</>
}
>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
You have an ongoing <strong className="text-foreground">{selectedAssessment ? "assessment" : "quiz"}</strong> session in progress.
Leaving now will not submit your answers — your session will remain open and the administrator can see it.
</p>
{selectedAssessment && (
<p className="text-amber-600 dark:text-amber-400 font-medium">
Your assessment timer will keep counting while you're away.
</p>
)}
</div>
</ResponsiveModal>
{/* ── No-assessment notice — informational, shown once per course visit ── */}
<InfoDialog
open={noAssessmentDialogOpen}
onOpenChange={setNoAssessmentDialogOpen}
title="Assessment Coming Soon"
description="This course does not have an assessment built yet — we are currently working on it. You may proceed with your studies. Enjoy!"
/>
{/* ── Task-mode banner ─────────────────────────────────────────── */}
{taskCtx?.has_task && (
<div className={`fixed top-[112px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
course?.is_completed ? 'bg-green-600' : 'bg-blue-600'
}`}>
<ListChecks className="size-3.5 shrink-0" />
<span>
{course?.is_completed
? 'Course complete — tracking finished'
: 'Task mode — reading progress is being tracked automatically'
}
</span>
</div>
)}
{/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
onClick={handleNextContentClick}
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start">
<span className="text-sm text-muted-foreground font-normal">Up next</span>
{nextContent.type === "lesson" ? nextContent.lesson.title
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
: (nextContent.assessment?.title || "Course Assessment")}
</div>
<ArrowRight className="size-4 text-muted-foreground" />
</div>
</div>
)}
{/* ── Up next after passing a quiz ── */}
{selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
onClick={handleNextContentClick}
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start">
<span className="text-sm text-muted-foreground font-normal">Up next</span>
{nextContent.type === "lesson" ? nextContent.lesson.title
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
: (nextContent.assessment?.title || "Course Assessment")}
</div>
<ArrowRight className="size-4 text-muted-foreground" />
</div>
</div>
)}
{/* ── Top sticky bar ── */}
<div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6">
<div className="w-full flex items-center justify-between gap-2">
<div className="flex items-center gap-2 justify-between w-full">
{/* Breadcrumb */}
<div className="hidden sm:block min-w-0">
<AppBreadcrumb items={breadcrumbItemsFull} />
</div>
<div className="sm:hidden min-w-0 truncate">
<AppBreadcrumb items={breadcrumbItemsMobile} />
</div>
{/* Mobile hamburger */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="lg:hidden flex-shrink-0"
aria-label="Open course content"
>
<TableOfContents className="size-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-80">
<SidebarContent
units={units}
selectedLessonId={selectedLessonId}
selectedQuizId={selectedQuizId}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
courseAssessment={course?.assessment}
selectedAssessment={selectedAssessment}
onAssessmentClick={handleAssessmentClick}
assessmentLocked={!allRequiredQuizzesPassed}
isCompleted={course?.is_completed}
selectedCompletion={selectedCompletion}
onCompletionClick={handleCompletionClick}
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
loading={courseLoading}
/>
</SheetContent>
</Sheet>
</div>
</div>
{/* Scroll progress bar */}
{lesson && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-border">
<div
className="h-full bg-primary transition-all duration-150 ease-out"
style={{ width: `${scrollProgress}%` }}
/>
</div>
)}
</div>
{/* ── Desktop sidebar: rail + collapsible content panel (desktop only) ── */}
<div className={`hidden lg:flex flex-row fixed ${taskCtx?.has_task ? "top-[140px]" : "top-[112px]"} bottom-0 left-0 z-30 bg-muted border-r`}>
{/* Rail — always visible */}
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setDesktopSidebarOpen((prev) => !prev)}
disabled={quizSessionActive}
title={showSidebarContent ? "Collapse sidebar" : "Expand sidebar"}
>
{showSidebarContent
? <ChevronsLeft className="size-4" />
: <ChevronsRight className="size-4" />
}
</Button>
</div>
{/* Content panel — hidden during quiz / assessment session */}
{showSidebarContent && (
<div className="w-[266px] border-l overflow-hidden">
<SidebarContent
units={units}
selectedLessonId={selectedLessonId}
selectedQuizId={selectedQuizId}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
courseAssessment={course?.assessment}
selectedAssessment={selectedAssessment}
onAssessmentClick={handleAssessmentClick}
assessmentLocked={!allRequiredQuizzesPassed}
isCompleted={course?.is_completed}
selectedCompletion={selectedCompletion}
onCompletionClick={handleCompletionClick}
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
loading={courseLoading}
/>
</div>
)}
</div>
{/* ── Main content ── */}
<div className={`${taskCtx?.has_task ? "mt-[9.5rem]" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
<div className="relative w-full h-full">
{selectedCompletion ? (
<CourseCompleteBlock course={course} />
) : selectedAssessment ? (
!allRequiredQuizzesPassed ? (
<AssessmentGate
requiredQuizzes={requiredQuizzes}
units={units}
onQuizClick={handleQuizClick}
/>
) : (
<QuizBlock
quiz={assessment ? { ...assessment, quiz_id: assessment.assessment_id } : null}
loading={assessmentLoading}
label="Assessment"
onStart={() => 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);
await getCourse(courseId);
return result;
}}
onRetake={() => getCourseAssessment(courseId)}
onActiveChange={setQuizActive}
onNextContent={nextContent ? handleNextContentClick : undefined}
nextLabel={nextLabel}
/>
)
) : selectedQuizId ? (
lockedQuizUnitIds.has(selectedUnitId) ? (
<QuizPrerequisiteGate
previousQuizzes={previousRequiredQuizzes}
units={units}
onQuizClick={handleQuizClick}
/>
) : (
<QuizBlock
quiz={quiz}
loading={quizLoading}
onDraft={handleQuizDraft}
onSubmit={async (answers) => {
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
await getCourse(courseId);
return result;
}}
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
onActiveChange={setQuizActive}
onNextContent={nextContent ? handleNextContentClick : undefined}
nextLabel={nextLabel}
/>
)
) : (
<LessonBlock
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
loading={lessonLoading}
/>
)}
</div>
</div>
</>
);
};
export default UnitList;