ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
+648
View File
@@ -0,0 +1,648 @@
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 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
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 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 { Skeleton } from "@/components/ui/skeleton";
// ─── 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,
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">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">{lesson.title}</span>
</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>
)}
</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" />
}
{courseAssessment.title || "Course 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, getCourse,
lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz,
assessment, assessmentLoading, getCourseAssessment, resetAssessment, submitCourseAssessment,
} = useClientCourses();
const {
fetchCourseProgress,
upsertLessonProgress,
isCompleted: isProgressCompleted,
isRead: isProgressRead,
resetProgress,
} = useCourseReadingProgress();
// Tracks which lessons have been marked completed this session to avoid duplicate calls
const completedSessionRef = useRef(new Set());
// ── 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);
// ── 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);
// ── 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);
upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
}, [scrollProgress]);
// ── Fetch course + progress on mount ─────────────────────────────────
useEffect(() => {
getCourse(courseId);
fetchCourseProgress(courseId);
return () => {
resetLesson();
resetQuiz();
resetAssessment();
resetProgress();
completedSessionRef.current.clear();
};
}, [courseId]);
// ── 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 ?? {};
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") },
];
// ── Lesson click ───────────────────────────────────────────────────────
const handleLessonClick = useCallback(async ({ unit, lesson: lessonStub }) => {
if (lessonStub.lesson_id === selectedLessonId) {
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');
}
}, [selectedLessonId, courseId, getLesson, resetQuiz, resetAssessment, isProgressRead, upsertLessonProgress]);
// ── Quiz click ─────────────────────────────────────────────────────────
const handleQuizClick = useCallback(async ({ unit, quiz: quizStub }) => {
if (quizStub.quiz_id === selectedQuizId) {
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]);
// ── Course assessment click ─────────────────────────────────────────────
const handleAssessmentClick = useCallback(async () => {
if (selectedAssessment) {
setSidebarOpen(false);
return;
}
setSelectedAssessment(true);
setSelectedLessonId(null);
setSelectedQuizId(null);
resetCompletion();
resetLesson();
resetQuiz();
setSidebarOpen(false);
if (allRequiredQuizzesPassed) {
await getCourseAssessment(courseId);
}
}, [selectedAssessment, courseId, getCourseAssessment, resetLesson, resetQuiz, allRequiredQuizzesPassed]);
// ── Course complete click ───────────────────────────────────────────────
const handleCompletionClick = useCallback(() => {
if (selectedCompletion) {
setSidebarOpen(false);
return;
}
setSelectedLessonId(null);
setSelectedQuizId(null);
setSelectedAssessment(false);
resetLesson();
resetQuiz();
resetAssessment();
setSelectedCompletion(true);
setSidebarOpen(false);
}, [selectedCompletion, resetLesson, resetQuiz, resetAssessment]);
// ── 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 handleNextContentClick = () => {
if (!nextContent) return;
if (nextContent.type === "lesson") handleLessonClick(nextContent);
else if (nextContent.type === "quiz") handleQuizClick(nextContent);
else handleAssessmentClick();
};
return (
<>
<PageMeta title={pageTitle} />
{/* ── 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>
)}
{/* ── 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)}
loading={courseLoading}
/>
</SheetContent>
</Sheet>
</div>
{/* Desktop TOC toggle */}
<ButtonGroup id="call-to-action" className="flex-shrink-0">
<Button
variant="outline"
className="hidden lg:flex"
onClick={() => setDesktopSidebarOpen((prev) => !prev)}
>
<TableOfContents />
</Button>
</ButtonGroup>
</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 ── */}
{desktopSidebarOpen && (
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-80 bg-muted border-r">
<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)}
loading={courseLoading}
/>
</div>
)}
{/* ── Main content ── */}
<div className={`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} />
) : selectedAssessment ? (
!allRequiredQuizzesPassed ? (
<AssessmentGate
requiredQuizzes={requiredQuizzes}
units={units}
onQuizClick={handleQuizClick}
/>
) : (
<QuizBlock
quiz={assessment ? { ...assessment, quiz_id: assessment.assessment_id } : null}
loading={assessmentLoading}
label="Assessment"
onSubmit={async (answers) => {
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers);
await getCourse(courseId);
return result;
}}
onRetake={() => getCourseAssessment(courseId)}
/>
)
) : 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)}
/>
) : (
<LessonBlock
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
loading={lessonLoading}
/>
)}
</div>
</div>
</>
);
};
export default UnitList;