mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
1039 lines
52 KiB
React
1039 lines
52 KiB
React
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; |