mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap } from "lucide-react";
|
||||
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import {
|
||||
@@ -20,6 +20,64 @@ import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,7 +172,7 @@ const SidebarContent = ({
|
||||
units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick,
|
||||
courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked,
|
||||
isCompleted, selectedCompletion, onCompletionClick,
|
||||
getLessonCompleted, getUnitCompleted,
|
||||
getLessonCompleted, getUnitCompleted, isQuizLocked,
|
||||
loading,
|
||||
}) => (
|
||||
<ScrollArea className="h-full p-3 md:p-4">
|
||||
@@ -162,24 +220,31 @@ const SidebarContent = ({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{unit.quiz && (
|
||||
<li
|
||||
onClick={() => onQuizClick({ unit, quiz: unit.quiz })}
|
||||
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 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"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{unit.quiz.has_passed
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
|
||||
: <ClipboardList className="size-3.5 shrink-0" />
|
||||
}
|
||||
{unit.quiz.title || "Quiz"}
|
||||
</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" />
|
||||
}
|
||||
{unit.quiz.title || "Quiz"}
|
||||
</li>
|
||||
);
|
||||
})()}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -234,7 +299,7 @@ const UnitList = () => {
|
||||
const {
|
||||
course, courseLoading, courseBlocked, getCourse,
|
||||
lesson, lessonLoading, getLesson, resetLesson,
|
||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz,
|
||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveQuizDraft,
|
||||
assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment,
|
||||
} = useClientCourses();
|
||||
|
||||
@@ -243,12 +308,17 @@ const UnitList = () => {
|
||||
upsertLessonProgress,
|
||||
isCompleted: isProgressCompleted,
|
||||
isRead: isProgressRead,
|
||||
completedTasks,
|
||||
clearCompletedTasks,
|
||||
resetProgress,
|
||||
} = useCourseReadingProgress();
|
||||
|
||||
// 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);
|
||||
@@ -260,6 +330,16 @@ const UnitList = () => {
|
||||
|
||||
const resetCompletion = () => setSelectedCompletion(false);
|
||||
|
||||
// ── 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)
|
||||
@@ -273,6 +353,47 @@ const UnitList = () => {
|
||||
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 })),
|
||||
@@ -307,7 +428,24 @@ const UnitList = () => {
|
||||
if (completedSessionRef.current.has(selectedLessonId)) return;
|
||||
if (isProgressCompleted(lesson.uuid)) return;
|
||||
completedSessionRef.current.add(selectedLessonId);
|
||||
upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
|
||||
(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 ─────────────────────────────────
|
||||
@@ -323,12 +461,53 @@ const UnitList = () => {
|
||||
};
|
||||
}, [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 } = location.state ?? {};
|
||||
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);
|
||||
@@ -391,78 +570,114 @@ const UnitList = () => {
|
||||
{ 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) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
// Fire in_progress only if not already started or completed
|
||||
if (!isProgressRead(lessonStub.uuid)) {
|
||||
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress');
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
setSelectedQuizId(quizStub.quiz_id);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetAssessment();
|
||||
setSelectedUnitId(unit.unit_id);
|
||||
setSidebarOpen(false);
|
||||
await getUnitQuiz(courseId, unit.unit_id);
|
||||
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment]);
|
||||
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) {
|
||||
if (selectedAssessment) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = async () => {
|
||||
setSelectedAssessment(true);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedAssessment(true);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
setSidebarOpen(false);
|
||||
if (allRequiredQuizzesPassed) {
|
||||
await getCourseAssessment(courseId);
|
||||
}
|
||||
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) {
|
||||
if (selectedCompletion) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = () => {
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
setSelectedCompletion(true);
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
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) =>
|
||||
@@ -509,6 +724,52 @@ const UnitList = () => {
|
||||
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>
|
||||
|
||||
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
||||
{taskCtx?.has_task && (
|
||||
<div className={`fixed top-[124px] 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">
|
||||
@@ -568,6 +829,7 @@ const UnitList = () => {
|
||||
onCompletionClick={handleCompletionClick}
|
||||
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
|
||||
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
|
||||
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
|
||||
loading={courseLoading}
|
||||
/>
|
||||
</SheetContent>
|
||||
@@ -599,7 +861,7 @@ const UnitList = () => {
|
||||
|
||||
{/* ── Desktop sidebar ── */}
|
||||
{desktopSidebarOpen && (
|
||||
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-80 bg-muted border-r">
|
||||
<div className={`hidden lg:block fixed ${taskCtx?.has_task ? "top-[148px]" : "top-[124px]"} bottom-0 left-0 w-80 bg-muted border-r`}>
|
||||
<SidebarContent
|
||||
units={units}
|
||||
selectedLessonId={selectedLessonId}
|
||||
@@ -621,7 +883,7 @@ const UnitList = () => {
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`mt-32 ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className={`${taskCtx?.has_task ? "mt-[8.5rem]" : "mt-32"} ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full">
|
||||
{selectedCompletion ? (
|
||||
<CourseCompleteBlock course={course} />
|
||||
@@ -638,7 +900,7 @@ const UnitList = () => {
|
||||
loading={assessmentLoading}
|
||||
label="Assessment"
|
||||
onStart={() => startCourseAssessment(courseId, assessment.assessment_id)}
|
||||
onDraft={(answers) => saveDraft(courseId, assessment.assessment_id, answers)}
|
||||
onDraft={handleAssessmentDraft}
|
||||
onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)}
|
||||
onSubmit={async (answers, sessionId) => {
|
||||
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId);
|
||||
@@ -646,19 +908,30 @@ const UnitList = () => {
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getCourseAssessment(courseId)}
|
||||
onActiveChange={setQuizActive}
|
||||
/>
|
||||
)
|
||||
) : selectedQuizId ? (
|
||||
<QuizBlock
|
||||
quiz={quiz}
|
||||
loading={quizLoading}
|
||||
onSubmit={async (answers) => {
|
||||
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
|
||||
await getCourse(courseId);
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
|
||||
/>
|
||||
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}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<LessonBlock
|
||||
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
|
||||
|
||||
Reference in New Issue
Block a user