pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
+363 -90
View File
@@ -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}