perform test #1

test to courses

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-24 12:49:20 +08:00
parent fbef7cb6e6
commit 93c3c688ca
26 changed files with 2141 additions and 452 deletions
+45
View File
@@ -39,6 +39,8 @@ export function CoursesProvider({ children }) {
const [quiz, setQuiz] = useState(null); const [quiz, setQuiz] = useState(null);
const [questions, setQuestions] = useState([]); const [questions, setQuestions] = useState([]);
const [assessment, setAssessment] = useState(null); const [assessment, setAssessment] = useState(null);
const [completions, setCompletions] = useState(null); // { summary, completions[] }
const [sessions, setSessions] = useState(null); // { summary, sessions[] }
const [attributes, setAttributes] = useState([]); const [attributes, setAttributes] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT); const [pagination, setPagination] = useState(PAGINATION_INIT);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -847,6 +849,43 @@ export function CoursesProvider({ children }) {
[request], [request],
); );
// =========================================================================
// COMPLETIONS & SESSIONS
// =========================================================================
const fetchQuizCompletions = useCallback(
(courseId, unitId, quizId) =>
request(async () => {
setCompletions(null);
const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/completions`);
setCompletions(data?.data ?? null);
return data;
}),
[request],
);
const fetchAssessmentCompletions = useCallback(
(courseId, assessmentId) =>
request(async () => {
setCompletions(null);
const { data } = await api.get(`${BASE}/${courseId}/assessment/${assessmentId}/completions`);
setCompletions(data?.data ?? null);
return data;
}),
[request],
);
const fetchAssessmentSessions = useCallback(
(courseId, assessmentId) =>
request(async () => {
setSessions(null);
const { data } = await api.get(`${BASE}/${courseId}/assessment/${assessmentId}/sessions`);
setSessions(data?.data ?? null);
return data;
}),
[request],
);
// ========================================================================= // =========================================================================
// COURSE PRODUCT & CATEGORIES // COURSE PRODUCT & CATEGORIES
// ========================================================================= // =========================================================================
@@ -1032,6 +1071,12 @@ export function CoursesProvider({ children }) {
fetchLessonPage, fetchLessonPage,
saveLessonPage, saveLessonPage,
// ── completions & sessions ─────────────────────────────────────────────
completions, sessions,
fetchQuizCompletions,
fetchAssessmentCompletions,
fetchAssessmentSessions,
// ── assessment ───────────────────────────────────────────────────────── // ── assessment ─────────────────────────────────────────────────────────
fetchAssessment, fetchAssessment,
createAssessment, createAssessment,
+28 -2
View File
@@ -130,11 +130,34 @@ export function ClientCoursesProvider({ children }) {
} }
}, []); }, []);
const submitCourseAssessment = useCallback(async (courseId, assessmentId, answers) => { const startCourseAssessment = useCallback(async (courseId, assessmentId) => {
try {
const { data } = await api.post(`/client/courses/${courseId}/assessment/${assessmentId}/start`);
return data.data ?? null;
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not start assessment.");
return null;
}
}, []);
const saveDraft = useCallback(async (courseId, assessmentId, answers) => {
try {
await api.patch(`/client/courses/${courseId}/assessment/${assessmentId}/draft`, { answers });
} catch { /* silent — draft saves are best-effort */ }
}, []);
const refreshAssessmentSession = useCallback(async (courseId, assessmentId) => {
try {
const { data } = await api.get(`/client/courses/${courseId}/assessment/${assessmentId}/session`);
return data.data ?? null;
} catch { return null; }
}, []);
const submitCourseAssessment = useCallback(async (courseId, assessmentId, answers, sessionId) => {
try { try {
const { data } = await api.post( const { data } = await api.post(
`/client/courses/${courseId}/assessment/${assessmentId}/submit`, `/client/courses/${courseId}/assessment/${assessmentId}/submit`,
{ answers } { answers, ...(sessionId ? { session_id: sessionId } : {}) }
); );
return data.data ?? null; return data.data ?? null;
} catch (err) { } catch (err) {
@@ -214,6 +237,9 @@ export function ClientCoursesProvider({ children }) {
getLesson, getLesson,
getUnitQuiz, getUnitQuiz,
getCourseAssessment, getCourseAssessment,
startCourseAssessment,
saveDraft,
refreshAssessmentSession,
submitUnitQuiz, submitUnitQuiz,
submitCourseAssessment, submitCourseAssessment,
+24 -2
View File
@@ -3,7 +3,8 @@ import api from "@/utils/api.util";
const ClientNotificationContext = createContext(null); const ClientNotificationContext = createContext(null);
const POLL_INTERVAL = 60_000; const POLL_INTERVAL_NORMAL = 60_000;
const POLL_INTERVAL_FAST = 10_000;
export function useClientNotifications() { export function useClientNotifications() {
const ctx = useContext(ClientNotificationContext); const ctx = useContext(ClientNotificationContext);
@@ -16,6 +17,7 @@ export function ClientNotificationProvider({ children }) {
const [unseenCount, setUnseenCount] = useState(0); const [unseenCount, setUnseenCount] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const intervalRef = useRef(null); const intervalRef = useRef(null);
const pollSpeedRef = useRef(POLL_INTERVAL_NORMAL);
const fetchUnseen = useCallback(async () => { const fetchUnseen = useCallback(async () => {
try { try {
@@ -62,9 +64,27 @@ export function ClientNotificationProvider({ children }) {
} }
}, []); }, []);
const restartPoll = useCallback((interval) => {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(fetchUnseen, interval);
}, [fetchUnseen]);
// Speed up polling while a student is mid-assessment; restore when done
const accelerate = useCallback(() => {
if (pollSpeedRef.current === POLL_INTERVAL_FAST) return;
pollSpeedRef.current = POLL_INTERVAL_FAST;
restartPoll(POLL_INTERVAL_FAST);
}, [restartPoll]);
const decelerate = useCallback(() => {
if (pollSpeedRef.current === POLL_INTERVAL_NORMAL) return;
pollSpeedRef.current = POLL_INTERVAL_NORMAL;
restartPoll(POLL_INTERVAL_NORMAL);
}, [restartPoll]);
useEffect(() => { useEffect(() => {
fetchUnseen(); fetchUnseen();
intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL); intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL_NORMAL);
return () => clearInterval(intervalRef.current); return () => clearInterval(intervalRef.current);
}, [fetchUnseen]); }, [fetchUnseen]);
@@ -76,6 +96,8 @@ export function ClientNotificationProvider({ children }) {
fetchNotifications, fetchNotifications,
markSeen, markSeen,
markAllSeen, markAllSeen,
accelerate,
decelerate,
}}> }}>
{children} {children}
</ClientNotificationContext.Provider> </ClientNotificationContext.Provider>
@@ -178,12 +178,19 @@ export function QuestionCard({ question, index, onChange, onRemove, error }) {
{/* Options */} {/* Options */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs text-muted-foreground uppercase tracking-wide"> <Label className="text-xs text-muted-foreground uppercase tracking-wide">
Options Options
<span className="ml-1 normal-case text-muted-foreground/60"> <span className="ml-1 normal-case text-muted-foreground/60">
— click circle to mark correct — click circle to mark correct
</span> </span>
</Label> </Label>
{question.type === "multi_select" && correctCount > 0 && (
<p className="text-xs text-blue-600 dark:text-blue-400">
Students must select {correctCount} answer{correctCount !== 1 ? "s" : ""}
</p>
)}
</div>
{question.type !== "true_false" && ( {question.type !== "true_false" && (
<Button <Button
type="button" type="button"
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown } from "lucide-react"; import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
@@ -12,6 +12,26 @@ import { Spinner } from "@/components/ui/spinner";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor"; import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor";
import api from "@/utils/api.util";
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) {
return JSON.stringify({
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours,
questions: questions.map((q) => ({
question_id: q.question_id ?? null,
question: q.question,
type: q.type,
points: q.points ?? 1,
options: (q.options ?? []).map((o) => ({
option_id: o.option_id ?? null,
text: o.text,
is_correct: o.is_correct,
})),
})),
});
}
// ── Validation ──────────────────────────────────────────────────────────────── // ── Validation ────────────────────────────────────────────────────────────────
@@ -200,6 +220,15 @@ export default function CourseAssessment() {
const [timeLimit, setTimeLimit] = useState(""); const [timeLimit, setTimeLimit] = useState("");
const [isRequired, setIsRequired] = useState(false); const [isRequired, setIsRequired] = useState(false);
const [maxQuestions, setMaxQuestions] = useState(""); const [maxQuestions, setMaxQuestions] = useState("");
const [maxAttempts, setMaxAttempts] = useState(3);
const [cooldownHours, setCooldownHours] = useState(24);
// ── Update confirmation dialog ─────────────────────────────────────────────
const [confirmOpen, setConfirmOpen] = useState(false);
const [inProgressCount, setInProgressCount] = useState(0);
const [confirmLoading, setConfirmLoading] = useState(false);
const pendingSaveRef = useRef(null); // stores the meta+questions payload until confirmed
const initialSnapshot = useRef(null);
const questionRefs = useRef([]); const questionRefs = useRef([]);
const navItemRefs = useRef([]); const navItemRefs = useRef([]);
@@ -217,18 +246,17 @@ export default function CourseAssessment() {
// ── Seed ────────────────────────────────────────────────────────────────── // ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!assessment) return; if (!assessment) return;
setTitle(assessment.title ?? ""); const t = assessment.title ?? "";
setPassingScore(assessment.passing_score ?? 70); const ps = assessment.passing_score ?? 70;
setTimeLimit(assessment.time_limit_minutes ?? ""); const tl = assessment.time_limit_minutes ?? "";
setIsRequired(assessment.is_required === true || assessment.is_required === 1); const ir = assessment.is_required === true || assessment.is_required === 1;
setMaxQuestions(assessment.max_questions ?? ""); const mq = assessment.max_questions ?? "";
setQuestions( const ma = assessment.max_attempts ?? 3;
(assessment.questions ?? []).map((q) => ({ const ch = assessment.cooldown_hours ?? 24;
...q, const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
_tempId: q.question_id, setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
options: q.options ?? [], setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setQuestions(qs);
})) initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, questions: qs });
);
}, [assessment]); }, [assessment]);
// ── Measure sticky header → --assessment-h ──────────────────────────────── // ── Measure sticky header → --assessment-h ────────────────────────────────
@@ -335,46 +363,82 @@ export default function CourseAssessment() {
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0); const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
// ── Dirty tracking ─────────────────────────────────────────────────────────
const isDirty = initialSnapshot.current === null
? questions.length > 0
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) !== initialSnapshot.current;
// ── Save ─────────────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => { const handleSave = async () => {
const errs = validate(questions); const errs = validate(questions);
if (Object.keys(errs).length) { if (Object.keys(errs).length) {
setErrors(errs); setErrors(errs);
// Jump to first error jumpTo(parseInt(Object.keys(errs)[0], 10));
const firstErr = parseInt(Object.keys(errs)[0], 10);
jumpTo(firstErr);
return; return;
} }
let assessmentId = assessment?.assessment_id; const assessmentId = assessment?.assessment_id;
const meta = { const meta = {
title: title || "Course Assessment", title: title || "Course Assessment",
passing_score: passingScore, passing_score: passingScore,
time_limit_minutes: timeLimit ? parseInt(timeLimit) : null, time_limit_minutes: timeLimit ? parseInt(timeLimit) : null,
is_required: isRequired, is_required: isRequired,
max_questions: maxQuestions ? parseInt(maxQuestions) : null, max_questions: maxQuestions ? parseInt(maxQuestions) : null,
max_attempts: parseInt(maxAttempts) || 3,
cooldown_hours: parseInt(cooldownHours) || 24,
updatedBy: user?.user_id, updatedBy: user?.user_id,
createdBy: user?.user_id, createdBy: user?.user_id,
}; };
// New assessment — no students can be in progress yet, save directly.
if (!assessmentId) { if (!assessmentId) {
await executeSave(null, meta);
return;
}
// Existing assessment — fetch in_progress count then show mandatory confirmation.
setConfirmLoading(true);
try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment/${assessmentId}/sessions`);
const count = (data?.data?.sessions ?? []).filter((s) => s.status === 'in_progress').length;
setInProgressCount(count);
} catch {
setInProgressCount(0);
} finally {
setConfirmLoading(false);
}
pendingSaveRef.current = { assessmentId, meta };
setConfirmOpen(true);
};
const executeSave = async (assessmentId, meta) => {
let id = assessmentId;
if (!id) {
const res = await createAssessment(courseId, meta); const res = await createAssessment(courseId, meta);
assessmentId = res?.data?.data?.data?.assessment_id; id = res?.data?.data?.data?.assessment_id;
if (!assessmentId) return; if (!id) return;
} else { } else {
await updateAssessment(courseId, assessmentId, meta); await updateAssessment(courseId, id, meta);
} }
for (let i = 0; i < questions.length; i++) { for (let i = 0; i < questions.length; i++) {
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id }; const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
if (q.question_id) { if (q.question_id) {
await updateAssessmentQuestion(courseId, assessmentId, q.question_id, q); await updateAssessmentQuestion(courseId, id, q.question_id, q);
} else { } else {
await createAssessmentQuestion(courseId, assessmentId, q); await createAssessmentQuestion(courseId, id, q);
} }
} }
// navigate(-1); initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions });
};
const handleConfirmSave = async () => {
const { assessmentId, meta } = pendingSaveRef.current ?? {};
if (!assessmentId) return;
await executeSave(assessmentId, meta);
pendingSaveRef.current = null;
setConfirmOpen(false);
}; };
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
@@ -402,8 +466,8 @@ export default function CourseAssessment() {
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""} {questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p> </p>
</div> </div>
<Button onClick={handleSave} disabled={loading}> <Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />} {(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Assessment Save Assessment
</Button> </Button>
</div> </div>
@@ -491,6 +555,28 @@ export default function CourseAssessment() {
placeholder={`All (${questions.length})`} placeholder={`All (${questions.length})`}
/> />
</div> </div>
<div className="space-y-1.5">
<Label>
Max Failed Attempts{" "}
<span className="text-muted-foreground font-normal text-xs">before cooldown</span>
</Label>
<Input
type="number" min={1}
value={maxAttempts}
onChange={(e) => setMaxAttempts(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>
Cooldown{" "}
<span className="text-muted-foreground font-normal text-xs">(hours after max fails)</span>
</Label>
<Input
type="number" min={1}
value={cooldownHours}
onChange={(e) => setCooldownHours(e.target.value)}
/>
</div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -569,6 +655,58 @@ export default function CourseAssessment() {
)} )}
</div> </div>
</div> </div>
{/* ── Update confirmation dialog ── */}
{confirmOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-background rounded-xl border shadow-xl w-full max-w-md p-6 space-y-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-5 w-5 text-amber-500" />
</div>
<div>
<h2 className="text-base font-semibold">Save Assessment Changes?</h2>
<p className="text-sm text-muted-foreground mt-1">
Changes will take effect immediately for all students.
</p>
</div>
</div>
{inProgressCount > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-400 font-medium">
{inProgressCount} student{inProgressCount !== 1 ? "s are" : " is"} currently taking this assessment.
Saving now will affect their ongoing session.
</p>
</div>
)}
<ul className="text-sm text-muted-foreground space-y-1 pl-1">
<li>• New passing score applies to future submissions only.</li>
<li>• Changing the time limit does not affect already-started sessions.</li>
<li>• Adding or removing questions affects any student not yet on that question.</li>
</ul>
<div className="flex gap-3 justify-end pt-1">
<Button
variant="outline"
onClick={() => { setConfirmOpen(false); pendingSaveRef.current = null; }}
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleConfirmSave}
disabled={loading}
>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : null}
Save Anyway
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+8 -12
View File
@@ -56,21 +56,17 @@ const schema = z.object({
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => ( const CertBadgeIcon = ({ className }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<rect width="120" height="120" rx="26" fill="url(#ec-cert-grad)" />
<rect width="120" height="120" rx="26" fill="url(#cd-cert-grad)" />
{/* short top bar */}
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
{/* longer middle bar */}
<rect x="22" y="56" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
{/* gold bottom bar */}
<rect x="19" y="80" width="84" height="14" rx="8" fill="#D4A017" />
<defs> <defs>
<linearGradient id="ec-cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse"> <linearGradient id="prism-ec" x1="0" y1="0" x2="1" y2="1">
<stop stopColor="#8B9FEE" /> <stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#4F6FD4" /> <stop offset="1" stopColor="#5061E6"/>
</linearGradient> </linearGradient>
</defs> </defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-ec)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg> </svg>
); );
@@ -1,6 +1,10 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, ClipboardList, NotebookPen, CheckCircle2, Circle } from "lucide-react"; import {
ArrowLeft, ClipboardList, NotebookPen,
CheckCircle2, Circle, Users, Activity,
ChevronDown, ChevronUp,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
@@ -8,7 +12,6 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -24,17 +27,28 @@ function InfoRow({ label, children }) {
function SectionCard({ title, children }) { function SectionCard({ title, children }) {
return ( return (
<div className="rounded-lg border bg-card p-5 space-y-4"> <div className="rounded-lg border bg-card p-5 space-y-4">
{title && ( {title && (<><h2 className="text-sm font-semibold">{title}</h2><Separator /></>)}
<>
<h2 className="text-sm font-semibold">{title}</h2>
<Separator />
</>
)}
{children} {children}
</div> </div>
); );
} }
function StatCard({ label, value, color = "default" }) {
const colors = {
default: "bg-card border",
green: "bg-green-500/5 border-green-500/20",
red: "bg-red-500/5 border-red-500/20",
blue: "bg-blue-500/5 border-blue-500/20",
amber: "bg-amber-500/5 border-amber-500/20",
};
return (
<div className={`rounded-lg border p-4 text-center ${colors[color]}`}>
<p className="text-2xl font-bold">{value ?? 0}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
);
}
const TYPE_LABELS = { const TYPE_LABELS = {
multiple_choice: "Multiple Choice", multiple_choice: "Multiple Choice",
multi_select: "Multi Select", multi_select: "Multi Select",
@@ -54,15 +68,20 @@ function QuestionView({ question, index }) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">{TYPE_LABELS[question.type] ?? question.type}</Badge>
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""} {question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
</span> </span>
</div> </div>
</div> </div>
{question.type === "multi_select" && (() => {
const cnt = (question.options ?? []).filter((o) => o.is_correct).length;
return cnt > 0 ? (
<p className="text-xs font-medium text-blue-600 dark:text-blue-400 pl-8">
Students select {cnt} answer{cnt !== 1 ? "s" : ""}
</p>
) : null;
})()}
<ul className="space-y-1.5 pl-8"> <ul className="space-y-1.5 pl-8">
{(question.options ?? []).map((opt, oi) => ( {(question.options ?? []).map((opt, oi) => (
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm"> <li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
@@ -81,6 +100,205 @@ function QuestionView({ question, index }) {
); );
} }
// ─── Completions tab ──────────────────────────────────────────────────────────
function CompletionRow({ row }) {
const [open, setOpen] = useState(false);
return (
<>
<tr
className="border-b transition-colors hover:bg-muted/40 cursor-pointer"
onClick={() => setOpen((v) => !v)}
>
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium">{row.full_name}</p>
<p className="text-xs text-muted-foreground">{row.email}</p>
</div>
</td>
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
<td className="px-4 py-3 text-center">
<span className="text-sm font-semibold">{row.best_score}%</span>
</td>
<td className="px-4 py-3 text-center">
{row.passed ? (
<Badge className="bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20">Passed</Badge>
) : (
<Badge variant="outline" className="text-red-600 dark:text-red-400 border-red-500/20">Not yet</Badge>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
</td>
<td className="px-4 py-3 text-center">
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
</td>
</tr>
{open && (
<tr className="bg-muted/30">
<td colSpan={6} className="px-6 py-3">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Attempt</th>
<th className="text-center py-1 font-medium">Score</th>
<th className="text-center py-1 font-medium">Points</th>
<th className="text-center py-1 font-medium">Result</th>
<th className="text-left py-1 font-medium">Date</th>
</tr>
</thead>
<tbody>
{(row.attempts ?? []).map((a) => (
<tr key={a.attempt_id} className="border-b border-border/50 last:border-0">
<td className="py-1.5 text-muted-foreground">#{a.attempt_number}</td>
<td className="py-1.5 text-center font-semibold">{a.score}%</td>
<td className="py-1.5 text-center text-muted-foreground">{a.earned_points}/{a.total_points}</td>
<td className="py-1.5 text-center">
{a.passed
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
: <span className="text-red-500 font-medium">Fail</span>}
</td>
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
);
}
function CompletionsTab({ completions, loading }) {
if (loading) return <div className="space-y-3">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)}</div>;
if (!completions) return (
<div className="rounded-lg border border-dashed bg-card p-12 text-center">
<p className="text-sm text-muted-foreground">No data yet.</p>
</div>
);
const { summary, completions: rows } = completions;
return (
<div className="space-y-5">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<StatCard label="Total Takers" value={summary.total_takers} />
<StatCard label="Passed" value={summary.passed_count} color="green" />
<StatCard label="Failed" value={summary.failed_count} color="red" />
<StatCard label="Pass Rate" value={`${summary.pass_rate}%`} color="blue" />
<StatCard label="Avg Score" value={`${summary.avg_score}%`} />
</div>
{rows.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 text-center">
<p className="text-sm text-muted-foreground">No one has attempted this assessment yet.</p>
</div>
) : (
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Student</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Attempts</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Best Score</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Last Attempt</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row) => <CompletionRow key={row.user_id} row={row} />)}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Sessions tab ─────────────────────────────────────────────────────────────
const SESSION_BADGE = {
completed: "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20",
expired: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
in_progress: "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20",
};
function fmtDuration(secs) {
if (secs == null) return "—";
if (secs < 60) return `${secs}s`;
const m = Math.floor(secs / 60);
const s = secs % 60;
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
function SessionsTab({ sessions, loading }) {
if (loading) return <div className="space-y-3">{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full rounded-lg" />)}</div>;
if (!sessions) return (
<div className="rounded-lg border border-dashed bg-card p-12 text-center">
<p className="text-sm text-muted-foreground">No data yet.</p>
</div>
);
const { summary, sessions: rows } = sessions;
return (
<div className="space-y-5">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Sessions" value={summary.total} />
<StatCard label="Completed" value={summary.completed} color="green" />
<StatCard label="Expired" value={summary.expired} color="red" />
<StatCard label="In Progress" value={summary.in_progress} color="amber" />
</div>
{rows.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 text-center">
<p className="text-sm text-muted-foreground">No sessions recorded yet.</p>
</div>
) : (
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Student</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Started</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Expires At</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Time Spent</th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<p className="text-sm font-medium">{s.full_name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</td>
<td className="px-4 py-3 text-center">
<Badge className={SESSION_BADGE[s.status] ?? ""}>
{s.status === 'in_progress' ? 'In Progress' : s.status.charAt(0).toUpperCase() + s.status.slice(1)}
</Badge>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{new Date(s.started_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{s.expires_at ? new Date(s.expires_at).toLocaleString() : "—"}
</td>
<td className="px-4 py-3 text-center text-sm">{fmtDuration(s.time_spent_seconds)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -92,15 +310,37 @@ function LoadingSkeleton() {
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
const TABS = [
{ key: "questions", label: "Questions", icon: ClipboardList },
{ key: "completions", label: "Completions", icon: Users },
{ key: "sessions", label: "Sessions", icon: Activity },
];
export default function ViewAssessment() { export default function ViewAssessment() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId } = useParams(); const { courseId } = useParams();
const { fetchAssessment, assessment, loading } = useCourses();
const {
fetchAssessment, assessment,
fetchAssessmentCompletions, fetchAssessmentSessions,
completions, sessions,
loading,
} = useCourses();
const [activeTab, setActiveTab] = useState("questions");
useEffect(() => { useEffect(() => {
fetchAssessment(courseId); fetchAssessment(courseId);
}, [courseId]); }, [courseId]);
// Lazy-load completions/sessions the first time each tab is opened
const loadedRef = { completions: false, sessions: false };
useEffect(() => {
if (!assessment?.assessment_id) return;
if (activeTab === "completions") fetchAssessmentCompletions(courseId, assessment.assessment_id);
if (activeTab === "sessions") fetchAssessmentSessions(courseId, assessment.assessment_id);
}, [activeTab, assessment?.assessment_id]);
const questions = assessment?.questions ?? []; const questions = assessment?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0); const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
@@ -129,15 +369,30 @@ export default function ViewAssessment() {
</p> </p>
)} )}
</div> </div>
<Button <Button variant="outline" size="sm" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
>
<NotebookPen className="h-4 w-4 mr-2" /> <NotebookPen className="h-4 w-4 mr-2" />
Modify Assessment Modify Assessment
</Button> </Button>
</div> </div>
{/* Tabs */}
{assessment && (
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors
${activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
@@ -150,18 +405,13 @@ export default function ViewAssessment() {
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center"> <div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground/40" /> <ClipboardList className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p> <p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
<Button <Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
>
<NotebookPen className="h-4 w-4 mr-2" /> <NotebookPen className="h-4 w-4 mr-2" />
Create Assessment Create Assessment
</Button> </Button>
</div> </div>
) : ( ) : activeTab === "questions" ? (
<> <>
{/* ── Settings ── */}
<SectionCard title="Settings"> <SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow> <InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
@@ -176,30 +426,29 @@ export default function ViewAssessment() {
</InfoRow> </InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow> <InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Max Shown per Attempt"> <InfoRow label="Max Shown per Attempt">
{assessment.max_questions {assessment.max_questions ? `${assessment.max_questions} (random)` : `All (${questions.length})`}
? `${assessment.max_questions} (random)`
: `All (${questions.length})`}
</InfoRow> </InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow> <InfoRow label="Total Points">{totalPoints}</InfoRow>
<InfoRow label="Max Failed Attempts">{assessment.max_attempts ?? 3}</InfoRow>
<InfoRow label="Cooldown After Fails">{assessment.cooldown_hours ?? 24}h</InfoRow>
</div> </div>
</SectionCard> </SectionCard>
{/* ── Questions ── */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1"> <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">Questions</p>
Questions
</p>
{questions.length === 0 ? ( {questions.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center"> <div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">No questions added yet.</p> <p className="text-sm text-muted-foreground">No questions added yet.</p>
</div> </div>
) : ( ) : (
questions.map((q, i) => ( questions.map((q, i) => <QuestionView key={q.question_id ?? i} question={q} index={i} />)
<QuestionView key={q.question_id ?? i} question={q} index={i} />
))
)} )}
</div> </div>
</> </>
) : activeTab === "completions" ? (
<CompletionsTab completions={completions} loading={loading} />
) : (
<SessionsTab sessions={sessions} loading={loading} />
)} )}
</div> </div>
</div> </div>
@@ -28,6 +28,25 @@ function validate(questions) {
return errors; return errors;
} }
// ── Dirty-check snapshot (stable fields only, strips internal _tempId) ────────
function snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) {
return JSON.stringify({
title, passingScore, isRequired, maxQuestions,
questions: questions.map((q) => ({
question_id: q.question_id ?? null,
question: q.question,
type: q.type,
points: q.points ?? 1,
options: (q.options ?? []).map((o) => ({
option_id: o.option_id ?? null,
text: o.text,
is_correct: o.is_correct,
})),
})),
});
}
// ── Jump to input ───────────────────────────────────────────────────────────── // ── Jump to input ─────────────────────────────────────────────────────────────
function JumpToInput({ max, onJump }) { function JumpToInput({ max, onJump }) {
@@ -197,6 +216,7 @@ export default function UnitQuiz() {
const navItemRefs = useRef([]); const navItemRefs = useRef([]);
const navContainerRef = useRef(null); const navContainerRef = useRef(null);
const headerRef = useRef(null); const headerRef = useRef(null);
const initialSnapshot = useRef(null);
const breadcrumbItems = [ const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -217,17 +237,13 @@ export default function UnitQuiz() {
// ── Seed ────────────────────────────────────────────────────────────────── // ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!quiz) return; if (!quiz) return;
setTitle(quiz.title ?? ""); const t = quiz.title ?? "";
setPassingScore(quiz.passing_score ?? 70); const ps = quiz.passing_score ?? 70;
setIsRequired(quiz.is_required === true || quiz.is_required === 1); const ir = quiz.is_required === true || quiz.is_required === 1;
setMaxQuestions(quiz.max_questions ?? ""); const mq = quiz.max_questions ?? "";
setQuestions( const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
(quiz.questions ?? []).map((q) => ({ setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setQuestions(qs);
...q, initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, questions: qs });
_tempId: q.question_id,
options: q.options ?? [],
}))
);
}, [quiz]); }, [quiz]);
// ── Measure sticky header → --quiz-h ────────────────────────────────────── // ── Measure sticky header → --quiz-h ──────────────────────────────────────
@@ -334,6 +350,11 @@ export default function UnitQuiz() {
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0); const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
// ── Dirty tracking ─────────────────────────────────────────────────────────
const isDirty = initialSnapshot.current === null
? questions.length > 0 // new quiz — enable once they've added a question
: snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) !== initialSnapshot.current;
// ── Save ─────────────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => { const handleSave = async () => {
const errs = validate(questions); const errs = validate(questions);
@@ -370,6 +391,7 @@ export default function UnitQuiz() {
} }
} }
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, questions });
navigate(-1); navigate(-1);
}; };
@@ -398,7 +420,7 @@ export default function UnitQuiz() {
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""} {questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p> </p>
</div> </div>
<Button onClick={handleSave} disabled={loading}> <Button onClick={handleSave} disabled={loading || !isDirty}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />} {loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Quiz Save Quiz
</Button> </Button>
@@ -1,6 +1,10 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, HelpCircle, NotebookPen, CheckCircle2, Circle } from "lucide-react"; import {
ArrowLeft, HelpCircle, NotebookPen,
CheckCircle2, Circle, Users,
ChevronDown, ChevronUp,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
@@ -23,17 +27,27 @@ function InfoRow({ label, children }) {
function SectionCard({ title, children }) { function SectionCard({ title, children }) {
return ( return (
<div className="rounded-lg border bg-card p-5 space-y-4"> <div className="rounded-lg border bg-card p-5 space-y-4">
{title && ( {title && (<><h2 className="text-sm font-semibold">{title}</h2><Separator /></>)}
<>
<h2 className="text-sm font-semibold">{title}</h2>
<Separator />
</>
)}
{children} {children}
</div> </div>
); );
} }
function StatCard({ label, value, color = "default" }) {
const colors = {
default: "bg-card border",
green: "bg-green-500/5 border-green-500/20",
red: "bg-red-500/5 border-red-500/20",
blue: "bg-blue-500/5 border-blue-500/20",
};
return (
<div className={`rounded-lg border p-4 text-center ${colors[color]}`}>
<p className="text-2xl font-bold">{value ?? 0}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
);
}
const TYPE_LABELS = { const TYPE_LABELS = {
multiple_choice: "Multiple Choice", multiple_choice: "Multiple Choice",
multi_select: "Multi Select", multi_select: "Multi Select",
@@ -53,15 +67,20 @@ function QuestionView({ question, index }) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">{TYPE_LABELS[question.type] ?? question.type}</Badge>
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""} {question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
</span> </span>
</div> </div>
</div> </div>
{question.type === "multi_select" && (() => {
const cnt = (question.options ?? []).filter((o) => o.is_correct).length;
return cnt > 0 ? (
<p className="text-xs font-medium text-blue-600 dark:text-blue-400 pl-8">
Students select {cnt} answer{cnt !== 1 ? "s" : ""}
</p>
) : null;
})()}
<ul className="space-y-1.5 pl-8"> <ul className="space-y-1.5 pl-8">
{(question.options ?? []).map((opt, oi) => ( {(question.options ?? []).map((opt, oi) => (
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm"> <li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
@@ -80,6 +99,123 @@ function QuestionView({ question, index }) {
); );
} }
// ─── Completions tab ──────────────────────────────────────────────────────────
function CompletionRow({ row }) {
const [open, setOpen] = useState(false);
return (
<>
<tr
className="border-b transition-colors hover:bg-muted/40 cursor-pointer"
onClick={() => setOpen((v) => !v)}
>
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium">{row.full_name}</p>
<p className="text-xs text-muted-foreground">{row.email}</p>
</div>
</td>
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
<td className="px-4 py-3 text-center">
<span className="text-sm font-semibold">{row.best_score}%</span>
</td>
<td className="px-4 py-3 text-center">
{row.passed ? (
<Badge className="bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20">Passed</Badge>
) : (
<Badge variant="outline" className="text-red-600 dark:text-red-400 border-red-500/20">Not yet</Badge>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
</td>
<td className="px-4 py-3 text-center">
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
</td>
</tr>
{open && (
<tr className="bg-muted/30">
<td colSpan={6} className="px-6 py-3">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Attempt</th>
<th className="text-center py-1 font-medium">Score</th>
<th className="text-center py-1 font-medium">Points</th>
<th className="text-center py-1 font-medium">Result</th>
<th className="text-left py-1 font-medium">Date</th>
</tr>
</thead>
<tbody>
{(row.attempts ?? []).map((a) => (
<tr key={a.attempt_id} className="border-b border-border/50 last:border-0">
<td className="py-1.5 text-muted-foreground">#{a.attempt_number}</td>
<td className="py-1.5 text-center font-semibold">{a.score}%</td>
<td className="py-1.5 text-center text-muted-foreground">{a.earned_points}/{a.total_points}</td>
<td className="py-1.5 text-center">
{a.passed
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
: <span className="text-red-500 font-medium">Fail</span>}
</td>
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
);
}
function CompletionsTab({ completions, loading }) {
if (loading) return <div className="space-y-3">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)}</div>;
if (!completions) return (
<div className="rounded-lg border border-dashed bg-card p-12 text-center">
<p className="text-sm text-muted-foreground">No data yet.</p>
</div>
);
const { summary, completions: rows } = completions;
return (
<div className="space-y-5">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Takers" value={summary.total_takers} />
<StatCard label="Passed" value={summary.passed_count} color="green" />
<StatCard label="Failed" value={summary.failed_count} color="red" />
<StatCard label="Pass Rate" value={`${summary.pass_rate}%`} color="blue" />
</div>
{rows.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 text-center">
<p className="text-sm text-muted-foreground">No one has attempted this quiz yet.</p>
</div>
) : (
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Student</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Attempts</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Best Score</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Last Attempt</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row) => <CompletionRow key={row.user_id} row={row} />)}
</tbody>
</table>
</div>
)}
</div>
);
}
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -91,21 +227,38 @@ function LoadingSkeleton() {
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
const TABS = [
{ key: "questions", label: "Questions", icon: HelpCircle },
{ key: "completions", label: "Completions", icon: Users },
];
export default function ViewUnitQuiz() { export default function ViewUnitQuiz() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId, unitId } = useParams(); const { courseId, unitId } = useParams();
const { fetchQuiz, quiz, loading } = useCourses();
const {
fetchQuiz, quiz,
fetchQuizCompletions, completions,
loading,
} = useCourses();
const [activeTab, setActiveTab] = useState("questions");
useEffect(() => { useEffect(() => {
fetchQuiz(courseId, unitId); fetchQuiz(courseId, unitId);
}, [courseId, unitId]); }, [courseId, unitId]);
useEffect(() => {
if (!quiz?.quiz_id) return;
if (activeTab === "completions") fetchQuizCompletions(courseId, unitId, quiz.quiz_id);
}, [activeTab, quiz?.quiz_id]);
const questions = quiz?.questions ?? []; const questions = quiz?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0); const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return ( return (
<div className="flex flex-col min-h-screen bg-muted/60"> <div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={quiz ? `${quiz.title ?? 'Quiz'} – View - STARR` : undefined} /> <PageMeta title={quiz ? `${quiz.title ?? "Quiz"} – View - STARR` : undefined} />
{/* ── Header ── */} {/* ── Header ── */}
<div <div
@@ -137,6 +290,25 @@ export default function ViewUnitQuiz() {
Modify Quiz Modify Quiz
</Button> </Button>
</div> </div>
{/* Tabs */}
{quiz && (
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors
${activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
@@ -149,18 +321,13 @@ export default function ViewUnitQuiz() {
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center"> <div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<HelpCircle className="h-8 w-8 text-muted-foreground/40" /> <HelpCircle className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p> <p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p>
<Button <Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}>
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
>
<NotebookPen className="h-4 w-4 mr-2" /> <NotebookPen className="h-4 w-4 mr-2" />
Create Quiz Create Quiz
</Button> </Button>
</div> </div>
) : ( ) : activeTab === "questions" ? (
<> <>
{/* ── Settings ── */}
<SectionCard title="Settings"> <SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{quiz.title || "Unit Quiz"}</InfoRow> <InfoRow label="Title">{quiz.title || "Unit Quiz"}</InfoRow>
@@ -171,31 +338,26 @@ export default function ViewUnitQuiz() {
</InfoRow> </InfoRow>
<InfoRow label="Passing Score">{quiz.passing_score ?? 70}%</InfoRow> <InfoRow label="Passing Score">{quiz.passing_score ?? 70}%</InfoRow>
<InfoRow label="Max Shown per Attempt"> <InfoRow label="Max Shown per Attempt">
{quiz.max_questions {quiz.max_questions ? `${quiz.max_questions} (random)` : `All (${questions.length})`}
? `${quiz.max_questions} (random)`
: `All (${questions.length})`}
</InfoRow> </InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow> <InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow> <InfoRow label="Total Points">{totalPoints}</InfoRow>
</div> </div>
</SectionCard> </SectionCard>
{/* ── Questions ── */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1"> <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">Questions</p>
Questions
</p>
{questions.length === 0 ? ( {questions.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center"> <div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">No questions added yet.</p> <p className="text-sm text-muted-foreground">No questions added yet.</p>
</div> </div>
) : ( ) : (
questions.map((q, i) => ( questions.map((q, i) => <QuestionView key={q.question_id ?? i} question={q} index={i} />)
<QuestionView key={q.question_id ?? i} question={q} index={i} />
))
)} )}
</div> </div>
</> </>
) : (
<CompletionsTab completions={completions} loading={loading} />
)} )}
</div> </div>
</div> </div>
@@ -76,7 +76,7 @@ function RequirementCard({ req }) {
{req.link_url && ( {req.link_url && (
<MetaRow icon={Globe} label="URL"> <MetaRow icon={Globe} label="URL">
<a <a
href={req.link_url} href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity" className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity"
@@ -55,7 +55,7 @@ export default function CreateTask() {
requirements: form.requirements, requirements: form.requirements,
}); });
if (created) navigate(`/admin/tasks/${taskListId}/tasks/${created.task_id}`); if (created) navigate(`/admin/taskList/${taskListId}/tasks/${created.task_id}/view`);
}; };
return ( return (
@@ -71,7 +71,7 @@ function RequirementCard({ req }) {
{req.link_url && ( {req.link_url && (
<MetaRow icon={Globe} label="URL"> <MetaRow icon={Globe} label="URL">
<a <a
href={req.link_url} href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity" className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity"
+268
View File
@@ -0,0 +1,268 @@
/***********************************************************************************************************************************************************************
* File Name: Intro.jsx
* Type of Program: Frontend Page
* Description: "Tell us about yourself" onboarding page shown once to every new user
* (system-registered or Google OAuth) before they reach the dashboard.
* Skips rendering and redirects if user.needs_intro is already false.
* On submit: PUT /client/profile → clears needs_intro → navigate /dashboard.
* Author: lash0000
* Date Created: Jun. 23, 2026
***********************************************************************************************************************************************************************/
import { useState } from 'react'
import { Navigate, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { Toaster } from 'sonner'
import api from '@/utils/api.util'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getInitials(given = '', last = '') {
const g = given.trim()[0]?.toUpperCase() ?? ''
const l = last.trim()[0]?.toUpperCase() ?? ''
return g + l || '?'
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function IntroPage() {
const navigate = useNavigate()
const { user, setUser } = useAuth()
// Already completed intro — send straight to dashboard
if (!user?.needs_intro) return <Navigate to="/dashboard" replace />
const pi = user?.personal_info ?? {}
// ── Form state seeded from existing personal_info (Google already has name) ──
const [givenName, setGivenName] = useState(pi.name?.given_name ?? '')
const [middleName, setMiddleName] = useState(pi.name?.middle_name ?? '')
const [lastName, setLastName] = useState(pi.name?.last_name ?? '')
const [extensionName, setExtensionName] = useState(pi.name?.extension_name ?? '')
const [dateOfBirth, setDateOfBirth] = useState(pi.date_of_birth ?? '')
const [occupation, setOccupation] = useState(pi.occupation ?? '')
const [phone, setPhone] = useState(
pi.phone_number?.[0]?.full_number ?? ''
)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState({})
// ── Derived ────────────────────────────────────────────────────────────────
const avatarUrl = pi.avatar?.url ?? ''
const initials = getInitials(givenName, lastName)
const displayEmail = user?.email ?? ''
// ── Validation ─────────────────────────────────────────────────────────────
const validate = () => {
const e = {}
if (!givenName.trim()) e.givenName = 'First name is required.'
if (!lastName.trim()) e.lastName = 'Last name is required.'
if (!occupation.trim()) e.occupation = 'Occupation is required.'
if (dateOfBirth) {
const age = (Date.now() - new Date(dateOfBirth)) / (1000 * 60 * 60 * 24 * 365.25)
if (isNaN(age) || age < 13 || age > 120) e.dateOfBirth = 'Please enter a valid date of birth.'
}
if (phone.trim() && !/^\+?[0-9\s\-() ]{7,20}$/.test(phone.trim())) {
e.phone = 'Invalid phone number.'
}
return e
}
// ── Submit ─────────────────────────────────────────────────────────────────
const handleSubmit = async (e) => {
e.preventDefault()
const errs = validate()
if (Object.keys(errs).length) {
setErrors(errs)
return
}
setErrors({})
setLoading(true)
const fullName = [givenName, middleName, lastName, extensionName].filter(Boolean).join(' ')
const personal_info = {
name: {
given_name: givenName,
middle_name: middleName,
last_name: lastName,
extension_name: extensionName,
full_name: fullName,
},
...(dateOfBirth ? { date_of_birth: dateOfBirth } : {}),
occupation,
phone_number: phone.trim()
? (() => {
const digits = phone.replace(/\D/g, '')
const number = digits.startsWith('63') ? digits.slice(2) : digits.replace(/^0/, '')
return [{ number, country_code: '+63', full_number: `+63${number}`, phone_type: 'mobile' }]
})()
: (pi.phone_number ?? []),
// Preserve existing avatar and addresses
...(pi.avatar ? { avatar: pi.avatar } : {}),
...(pi.addresses ? { addresses: pi.addresses } : {}),
}
try {
const { data } = await api.put('/client/profile', { personal_info })
// Merge the full returned user object — needs_intro will now be false
setUser(prev => ({ ...prev, ...data.data }))
navigate('/dashboard')
} catch (err) {
toast.error(err?.response?.data?.message ?? 'Could not save your info. Please try again.')
} finally {
setLoading(false)
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
<Toaster position="bottom-right" richColors />
<div className="min-h-svh bg-muted flex items-center justify-center p-6">
<div className="w-full max-w-lg">
{/* Logo */}
<div className="flex justify-center mb-8">
<Link to="/">
<img src="/philpro-white.png" alt="Philproperties" className="w-44 dark:hidden" />
<img src="/philpro-dark.png" alt="Philproperties" className="w-44 hidden dark:block" />
</Link>
</div>
{/* Card */}
<div className="bg-card rounded-2xl border shadow-sm p-8 space-y-6">
{/* Header */}
<div className="flex flex-col items-center gap-3 text-center">
<Avatar className="h-16 w-16">
<AvatarImage src={avatarUrl} />
<AvatarFallback className="text-lg font-semibold">{initials}</AvatarFallback>
</Avatar>
<div>
<h1 className="text-xl font-bold tracking-tight">Tell us about yourself</h1>
<p className="text-sm text-muted-foreground mt-1">
{displayEmail} &nbsp;·&nbsp; This info appears on your profile.
</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">First name <span className="text-destructive">*</span></Label>
<Input
value={givenName}
onChange={e => setGivenName(e.target.value)}
placeholder="Juan"
disabled={loading}
/>
{errors.givenName && <p className="text-xs text-destructive">{errors.givenName}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Last name <span className="text-destructive">*</span></Label>
<Input
value={lastName}
onChange={e => setLastName(e.target.value)}
placeholder="Dela Cruz"
disabled={loading}
/>
{errors.lastName && <p className="text-xs text-destructive">{errors.lastName}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">
Middle name <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
value={middleName}
onChange={e => setMiddleName(e.target.value)}
placeholder="Santos"
disabled={loading}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">
Extension <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
value={extensionName}
onChange={e => setExtensionName(e.target.value)}
placeholder="Jr., Sr., III"
disabled={loading}
/>
</div>
</div>
{/* Date of birth */}
<div className="space-y-1.5">
<Label className="text-xs">
Date of birth <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
type="date"
value={dateOfBirth}
onChange={e => setDateOfBirth(e.target.value)}
max={new Date().toISOString().split('T')[0]}
disabled={loading}
/>
{errors.dateOfBirth && <p className="text-xs text-destructive">{errors.dateOfBirth}</p>}
</div>
{/* Occupation */}
<div className="space-y-1.5">
<Label className="text-xs">Occupation <span className="text-destructive">*</span></Label>
<Input
value={occupation}
onChange={e => setOccupation(e.target.value)}
placeholder="e.g. Real Estate Broker"
disabled={loading}
/>
{errors.occupation && <p className="text-xs text-destructive">{errors.occupation}</p>}
</div>
{/* Phone */}
<div className="space-y-1.5">
<Label className="text-xs">
Phone number <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="+63 912 345 6789"
disabled={loading}
/>
{errors.phone && <p className="text-xs text-destructive">{errors.phone}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading
? <><Loader2 className="size-4 animate-spin" /> Saving...</>
: "Let's get started →"
}
</Button>
</form>
</div>
</div>
</div>
</>
)
}
@@ -1,12 +1,14 @@
// components/QuizBlock.jsx // components/QuizBlock.jsx
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
ChevronLeft, ChevronRight, ChevronLeft, ChevronRight,
Circle, CheckCircle2, Circle, CheckCircle2,
Square, CheckSquare2, Square, CheckSquare2,
Clock, AlertTriangle, Info,
} from "lucide-react"; } from "lucide-react";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
function QuizSkeleton() { function QuizSkeleton() {
return ( return (
@@ -24,30 +26,225 @@ function QuizSkeleton() {
); );
} }
function formatTime(secs) {
if (secs === null || secs === undefined) return null;
const m = Math.floor(secs / 60).toString().padStart(2, "0");
const s = (secs % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
/** /**
* Props: * Props:
* quiz — { quiz_id, title, is_required, passing_score, max_questions, attempt_count, has_passed, best_attempt, questions: [...] } * quiz — assessment data including active_session, time_limit_minutes, etc.
* loading — true while fetch is in-flight * loading — true while fetch is in-flight
* onSubmit — (answers) => Promise<result|null> * onStart — async () => { session_id, expires_at, remaining_seconds, draft_answers } — only for timed assessments
* label — noun used in copy ("Quiz" or "Assessment"), default "Quiz" * onDraft — (answers) => void — called every 25s to UPSERT draft + last_heartbeat_at
* onRefreshSession — async () => { expires_at, remaining_seconds } — called when assessment_updated fires
* onSubmit — async (answers, sessionId?) => result|null
* onRetake — () => void — refetch so attempt stats refresh
* label — "Quiz" or "Assessment"
*/ */
const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }) => { const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, label = "Quiz" }) => {
const questions = quiz?.questions ?? []; const questions = quiz?.questions ?? [];
const total = questions.length; const total = questions.length;
// ── Core stage state ──────────────────────────────────────────────────────
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result' const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState({}); const [answers, setAnswers] = useState({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [starting, setStarting] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
// ── Timer state ───────────────────────────────────────────────────────────
const [remainingSeconds, setRemainingSeconds] = useState(null); // null = no active countdown
const [timeExpired, setTimeExpired] = useState(false);
// ── Notifications (assessment_updated alert) ──────────────────────────────
const { notifications, fetchNotifications, accelerate, decelerate } = useClientNotifications();
const [assessmentUpdatedAlert, setAssessmentUpdatedAlert] = useState(false);
const seenNotifRef = useRef(new Set()); // tracks notification_ids already shown
// ── Session refs (stable across renders, no stale-closure issues) ─────────
const sessionRef = useRef({ sessionId: null, expiresAt: null });
const answersRef = useRef({});
const timerFiredRef = useRef(false);
// Keep answersRef in sync with state so the timer's auto-submit closure reads fresh data
useEffect(() => { answersRef.current = answers; }, [answers]);
// Reset all state when the quiz/assessment changes
useEffect(() => { useEffect(() => {
setStage("intro"); setStage("intro");
setCurrentIndex(0); setCurrentIndex(0);
setAnswers({});
setResult(null); setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
// Pre-load any saved draft so the student can see their progress on the intro screen
const draft = quiz?.active_session?.draft_answers ?? {};
setAnswers(Object.keys(draft).length > 0 ? draft : {});
answersRef.current = draft;
}, [quiz?.quiz_id]); }, [quiz?.quiz_id]);
// ── Speed up notification polling while mid-assessment ───────────────────
useEffect(() => {
if (stage !== "taking") { decelerate(); return; }
accelerate();
fetchNotifications(); // immediate refresh when stage starts
return () => decelerate();
}, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Watch for assessment_updated notifications ────────────────────────────
useEffect(() => {
if (stage !== "taking") return;
notifications.forEach((n) => {
if (n.type === "assessment" && n.title === "Assessment Updated" && !seenNotifRef.current.has(n.notification_id)) {
seenNotifRef.current.add(n.notification_id);
setAssessmentUpdatedAlert(true);
// Refresh the session timer — admin may have changed time_limit_minutes
if (onRefreshSession) {
onRefreshSession().then(updated => {
if (updated?.remaining_seconds != null) {
setRemainingSeconds(updated.remaining_seconds);
sessionRef.current.expiresAt = updated.expires_at
? new Date(updated.expires_at)
: null;
}
});
}
}
});
}, [notifications, stage]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Draft auto-save: immediate on enter, then every 25s ──────────────────
// The immediate call sets last_heartbeat_at right away so the crash-resume
// freeze logic always has a reference point even if the tab closes within seconds.
useEffect(() => {
if (stage !== "taking" || !onDraft) return;
onDraft(answersRef.current); // immediate — seeds last_heartbeat_at now
const id = setInterval(() => onDraft(answersRef.current), 25_000);
return () => clearInterval(id);
}, [stage, onDraft]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Timer tick (only active while stage === 'taking' and expiresAt is set) ─
useEffect(() => {
if (stage !== "taking" || !sessionRef.current.expiresAt) return;
timerFiredRef.current = false;
const tick = () => {
const secs = Math.max(0, Math.ceil((sessionRef.current.expiresAt.getTime() - Date.now()) / 1000));
setRemainingSeconds(secs);
if (secs <= 0 && !timerFiredRef.current) {
timerFiredRef.current = true;
setTimeExpired(true);
clearInterval(id);
}
};
tick(); // immediate first tick so UI shows correct time right away
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [stage]); // intentionally only stage — expiresAt is a ref
// ── Auto-submit when time runs out ────────────────────────────────────────
useEffect(() => {
if (!timeExpired || submitting) return;
const autoSubmit = async () => {
setSubmitting(true);
const res = await onSubmit?.(answersRef.current, sessionRef.current.sessionId);
setSubmitting(false);
if (res) {
setResult(res);
setStage("result");
}
};
autoSubmit();
}, [timeExpired]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Handlers ─────────────────────────────────────────────────────────────
const handleStart = useCallback(async (resuming = false) => {
const isAssessment = label === "Assessment";
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
// Always create/resume a session for assessments (timed or not) so admin
// can see in-progress records and draft answers are preserved on crash.
if (isAssessment && onStart) {
setStarting(true);
const session = await onStart();
setStarting(false);
if (!session) return; // onStart already toasted the error
sessionRef.current = {
sessionId: session.session_id,
expiresAt: session.expires_at ? new Date(session.expires_at) : null,
};
if (hasTimeLimit && session.remaining_seconds != null) {
setRemainingSeconds(session.remaining_seconds);
}
// Restore draft answers saved before the crash/close
if (session.draft_answers && Object.keys(session.draft_answers).length > 0) {
setAnswers(session.draft_answers);
answersRef.current = session.draft_answers;
}
}
setStage("taking");
}, [label, quiz?.time_limit_minutes, onStart]);
const handleRetake = () => {
setCurrentIndex(0);
setAnswers({});
setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
setStage("intro");
onRetake?.();
};
const handleOptionClick = (optionId) => {
const question = questions[currentIndex];
const isMulti = question.type === "multi_select";
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
setAnswers((prev) => {
if (!isMulti) return { ...prev, [question.question_id]: optionId };
const current = prev[question.question_id] ?? [];
if (!current.includes(optionId) && multiLimit !== null && current.length >= multiLimit) return prev;
const next = current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId];
return { ...prev, [question.question_id]: next };
});
};
const handleNext = async () => {
if (isLast) {
setSubmitting(true);
const res = await onSubmit?.(answers, sessionRef.current.sessionId);
setSubmitting(false);
if (res) {
setResult(res);
setStage("result");
}
return;
}
setCurrentIndex((i) => Math.min(i + 1, total - 1));
};
const handlePrev = () => {
if (isFirst) { setStage("intro"); return; }
setCurrentIndex((i) => Math.max(i - 1, 0));
};
// ── Empty / loading guards ────────────────────────────────────────────────
if (!quiz && !loading) { if (!quiz && !loading) {
return ( return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20"> <div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
@@ -66,20 +263,17 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
); );
} }
const handleRetake = () => { // ── Derived values ────────────────────────────────────────────────────────
setCurrentIndex(0); const isAssessment = label === "Assessment";
setAnswers({}); const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
setResult(null); const activeSession = isAssessment ? (quiz?.active_session ?? null) : null; // pre-existing in_progress from server
setStage("intro");
onRetake?.(); // refetch so attempts_remaining/cooldown_until reflect the submission that just happened
};
// ── Intro screen ───────────────────────────────────────────────────────── // ── Intro screen ─────────────────────────────────────────────────────────
if (stage === "intro") { if (stage === "intro") {
const attempts = quiz.attempt_count ?? 0; const attempts = quiz.attempt_count ?? 0;
const attemptsRemaining = quiz.attempts_remaining ?? null; // null = backend hasn't sent this field yet const attemptsRemaining = quiz.attempts_remaining ?? null;
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null; const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
const canAttempt = quiz.can_attempt ?? true; // default open if field is absent, for back-compat const canAttempt = quiz.can_attempt ?? true;
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
@@ -91,6 +285,17 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
Required to complete this {label === "Assessment" ? "course" : "unit"} Required to complete this {label === "Assessment" ? "course" : "unit"}
</span> </span>
)} )}
{label === "Assessment" && quiz.max_attempts && quiz.cooldown_hours && (
<span className="inline-block rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
{quiz.max_attempts} failed attempt{quiz.max_attempts !== 1 ? "s" : ""} → {quiz.cooldown_hours}h cooldown
</span>
)}
{hasTimeLimit && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Clock className="size-3" />
{quiz.time_limit_minutes} minute time limit
</span>
)}
</div> </div>
{quiz.has_passed && ( {quiz.has_passed && (
@@ -130,11 +335,28 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
</div> </div>
)} )}
{/* Resume banner — shown when the server reports an active in_progress session */}
{activeSession && canAttempt && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-left space-y-1">
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 flex items-center gap-1.5">
<Clock className="size-4" /> Session in progress
</p>
<p className="text-xs text-muted-foreground">
You have an unfinished attempt.{" "}
{activeSession.remaining_seconds != null
? `${formatTime(activeSession.remaining_seconds)} remaining — resume before time runs out.`
: "Resume where you left off."}
</p>
</div>
)}
<div className="flex items-center justify-center gap-6 sm:gap-10"> <div className="flex items-center justify-center gap-6 sm:gap-10">
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">{total}</p> <p className="text-2xl font-bold sm:text-3xl">{total}</p>
<p className="text-xs text-muted-foreground sm:text-sm">Question{total === 1 ? "" : "s"}</p> <p className="text-xs text-muted-foreground sm:text-sm">Question{total === 1 ? "" : "s"}</p>
</div> </div>
{(attemptsRemaining !== null || attempts > 0) && (
<>
<div className="h-10 w-px bg-border" /> <div className="h-10 w-px bg-border" />
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl"> <p className="text-2xl font-bold sm:text-3xl">
@@ -144,6 +366,8 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`} {attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
</p> </p>
</div> </div>
</>
)}
<div className="h-10 w-px bg-border" /> <div className="h-10 w-px bg-border" />
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">{quiz.passing_score}%</p> <p className="text-2xl font-bold sm:text-3xl">{quiz.passing_score}%</p>
@@ -151,8 +375,19 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
</div> </div>
</div> </div>
<Button size="lg" className="w-full sm:w-auto" onClick={() => setStage("taking")} disabled={!canAttempt}> <Button
{attempts > 0 ? `Retake ${label}` : `Start ${label}`} size="lg"
className="w-full sm:w-auto"
onClick={() => handleStart(!!activeSession)}
disabled={!canAttempt || starting}
>
{starting
? "Starting…"
: activeSession
? "Resume Assessment"
: attempts > 0
? `Retake ${label}`
: `Start ${label}`}
</Button> </Button>
</div> </div>
</div> </div>
@@ -193,40 +428,50 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
const selected = answers[question.question_id]; const selected = answers[question.question_id];
const progress = Math.round(((currentIndex + 1) / total) * 100); const progress = Math.round(((currentIndex + 1) / total) * 100);
const handleOptionClick = (optionId) => { const multiLimit = isMulti ? (question.correct_count ?? null) : null;
setAnswers((prev) => { const selectedCount = isMulti ? (selected ?? []).length : 0;
if (!isMulti) return { ...prev, [question.question_id]: optionId }; const limitReached = multiLimit !== null && selectedCount >= multiLimit;
const current = prev[question.question_id] ?? [];
const next = current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId];
return { ...prev, [question.question_id]: next };
});
};
const handleNext = async () => { // Timer color: red < 60s, amber < 5min, default otherwise
if (isLast) { const timerColor = remainingSeconds !== null
setSubmitting(true); ? remainingSeconds <= 60
const res = await onSubmit?.(answers); ? "text-red-500 dark:text-red-400"
setSubmitting(false); : remainingSeconds <= 300
if (res) { ? "text-amber-500 dark:text-amber-400"
setResult(res); : "text-muted-foreground"
setStage("result"); : null;
}
return;
}
setCurrentIndex((i) => Math.min(i + 1, total - 1));
};
const handlePrev = () => {
if (isFirst) { setStage("intro"); return; }
setCurrentIndex((i) => Math.max(i - 1, 0));
};
return ( return (
<div className="max-w-2xl mx-auto space-y-5"> <div className="max-w-2xl mx-auto space-y-5">
{/* ── Assessment updated alert banner ── */}
{assessmentUpdatedAlert && (
<div className="flex items-start gap-3 rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-700 dark:text-blue-400">Assessment Updated</p>
<p className="text-xs text-blue-600/80 dark:text-blue-300/70 mt-0.5">
Your administrator has made changes to this assessment. Your current session and saved answers are unaffected — continue as normal.
</p>
</div>
<button
onClick={() => setAssessmentUpdatedAlert(false)}
className="text-blue-400 hover:text-blue-600 shrink-0 text-xs leading-none mt-0.5"
>✕</button>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl">{quiz.title}</h2>} <div className="flex items-center justify-between">
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl truncate">{quiz.title}</h2>}
{remainingSeconds !== null && (
<span className={`flex items-center gap-1 font-mono text-sm font-bold tabular-nums shrink-0 ml-3 ${timerColor}`}>
{timeExpired && submitting
? <><AlertTriangle className="size-3.5" /> Time's up</>
: <><Clock className="size-3.5" />{formatTime(remainingSeconds)}</>
}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground sm:text-sm"> <div className="flex items-center justify-between text-xs text-muted-foreground sm:text-sm">
<span>Question {currentIndex + 1} of {total}</span> <span>Question {currentIndex + 1} of {total}</span>
<span>{progress}%</span> <span>{progress}%</span>
@@ -236,16 +481,32 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
</div> </div>
</div> </div>
{timeExpired && submitting && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400 text-center">
Time's up — submitting your answers…
</div>
)}
<div className="rounded-xl border bg-card p-4 space-y-4 sm:p-6"> <div className="rounded-xl border bg-card p-4 space-y-4 sm:p-6">
<div className="space-y-1">
<p className="font-bold text-base leading-relaxed sm:text-lg"> <p className="font-bold text-base leading-relaxed sm:text-lg">
{currentIndex + 1}. {question.question} {currentIndex + 1}. {question.question}
</p> </p>
{isMulti && multiLimit !== null && (
<p className={`text-xs font-medium ${limitReached ? "text-amber-600 dark:text-amber-400" : "text-blue-600 dark:text-blue-400"}`}>
{limitReached
? `${selectedCount} / ${multiLimit} selected — limit reached`
: `Select ${multiLimit} answer${multiLimit !== 1 ? "s" : ""} · ${selectedCount} / ${multiLimit} selected`}
</p>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
{(question.options ?? []).map((option, i) => { {(question.options ?? []).map((option, i) => {
const letter = String.fromCharCode(65 + i); const letter = String.fromCharCode(65 + i);
const isSelected = isMulti const isSelected = isMulti
? (selected ?? []).includes(option.option_id) ? (selected ?? []).includes(option.option_id)
: selected === option.option_id; : selected === option.option_id;
const isDisabled = (isMulti && limitReached && !isSelected) || (timeExpired && submitting);
const Icon = isMulti const Icon = isMulti
? (isSelected ? CheckSquare2 : Square) ? (isSelected ? CheckSquare2 : Square)
: (isSelected ? CheckCircle2 : Circle); : (isSelected ? CheckCircle2 : Circle);
@@ -255,8 +516,10 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
key={option.option_id} key={option.option_id}
type="button" type="button"
onClick={() => handleOptionClick(option.option_id)} onClick={() => handleOptionClick(option.option_id)}
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base ${isSelected ? "border-primary bg-primary/5" : "border-border hover:bg-muted-foreground/5" disabled={isDisabled}
}`} className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base
${isSelected ? "border-primary bg-primary/5" : "border-border"}
${isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-muted-foreground/5"}`}
> >
<Icon className={`size-4 shrink-0 ${isSelected ? "text-primary" : "text-muted-foreground"}`} /> <Icon className={`size-4 shrink-0 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
<span className="font-medium text-muted-foreground">{letter}.</span> <span className="font-medium text-muted-foreground">{letter}.</span>
@@ -273,7 +536,7 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
Previous Previous
</Button> </Button>
<Button onClick={handleNext} disabled={submitting}> <Button onClick={handleNext} disabled={submitting}>
{submitting ? "Submitting..." : isLast ? "Submit" : "Next"} {submitting ? "Submitting…" : isLast ? "Submit" : "Next"}
{!isLast && !submitting && <ChevronRight className="size-4" />} {!isLast && !submitting && <ChevronRight className="size-4" />}
</Button> </Button>
</div> </div>
@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { BookOpen, Tag, CheckCheck, RefreshCcw } from "lucide-react"; import { BookOpen, Tag, CheckCheck, RefreshCcw, Lock, Zap, Info } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
@@ -10,17 +10,28 @@ import { SendHorizonal } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
const SUB_LABEL = { free: 'Free', premium: 'Premium', exclusive: 'Exclusive' };
const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' }; const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' };
function TierBadge({ tier, locked = false }) {
if (tier === 'premium')
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
if (tier === 'exclusive')
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
if (!locked)
return <Badge className="gap-1 bg-green-500 text-white border-0 w-fit shrink-0"><Tag className="size-3" /> Free</Badge>;
return null;
}
const ReadCourse = ({ title = "Read Course", courses = [] }) => { const ReadCourse = ({ title = "Read Course", courses = [] }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [details, setDetails] = useState({}); const [details, setDetails] = useState({});
const [summaries, setSummaries] = useState({}); const [summaries, setSummaries] = useState({});
const [locked, setLocked] = useState({});
const [lockedInfo, setLockedInfo] = useState({});
const [fetching, setFetching] = useState({});
const prevCompletedRef = useRef({}); const prevCompletedRef = useRef({});
// Toast notification when a course requirement is auto turned-in
useEffect(() => { useEffect(() => {
courses.forEach((course) => { courses.forEach((course) => {
const prev = prevCompletedRef.current[course.id]; const prev = prevCompletedRef.current[course.id];
@@ -34,6 +45,7 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
useEffect(() => { useEffect(() => {
courses.forEach(async (course) => { courses.forEach(async (course) => {
if (!course.reference_id) return; if (!course.reference_id) return;
setFetching((prev) => ({ ...prev, [course.reference_id]: true }));
try { try {
const res = await api.get(`/client/courses/uuid/${course.reference_id}`); const res = await api.get(`/client/courses/uuid/${course.reference_id}`);
const d = res.data?.data; const d = res.data?.data;
@@ -44,17 +56,26 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
const summary = progRes.data?.data; const summary = progRes.data?.data;
if (summary) setSummaries((prev) => ({ ...prev, [course.reference_id]: summary })); if (summary) setSummaries((prev) => ({ ...prev, [course.reference_id]: summary }));
} catch (err) { } catch (err) {
if (err?.response?.status === 403) {
setLocked((prev) => ({ ...prev, [course.reference_id]: true }));
const courseData = err.response?.data?.course;
if (courseData) setLockedInfo((prev) => ({ ...prev, [course.reference_id]: courseData }));
} else {
console.error('[ReadCourse] fetch failed:', err?.response?.status, err?.message); console.error('[ReadCourse] fetch failed:', err?.response?.status, err?.message);
} }
} finally {
setFetching((prev) => ({ ...prev, [course.reference_id]: false }));
}
}); });
}, []); }, []);
// Reading percentage (lessons read / total) — independent of quiz / assessment completion
const getReadingPercent = (course) => { const getReadingPercent = (course) => {
const summary = summaries[course.reference_id]; const summary = summaries[course.reference_id];
return summary ? summary.percent : (course.progress ?? 0); return summary ? summary.percent : (course.progress ?? 0);
}; };
const hasLocked = Object.values(locked).some(Boolean);
return ( return (
<div className="border rounded-lg bg-card overflow-hidden"> <div className="border rounded-lg bg-card overflow-hidden">
{/* Header */} {/* Header */}
@@ -64,60 +85,113 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
<Badge variant="secondary" className="ml-auto">{courses.length}</Badge> <Badge variant="secondary" className="ml-auto">{courses.length}</Badge>
</div> </div>
{/* Horizontal scroll */} {/* Subscription advisory */}
{hasLocked && (
<div className="flex items-start gap-3 px-5 py-3.5 border-b bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300">
<Info className="size-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-snug">Subscription Required</p>
<p className="text-xs mt-0.5 text-amber-700 dark:text-amber-400 leading-relaxed">
To complete this activity, subscribe to one of our available tier plans.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
)}
<ScrollArea className="w-full bg-muted overflow-hidden"> <ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4"> <div className="flex gap-4 p-4">
{courses.map((course) => { {courses.map((course) => {
const info = details[course.reference_id]; const info = details[course.reference_id];
const courseInfo = lockedInfo[course.reference_id];
const percent = getReadingPercent(course); const percent = getReadingPercent(course);
// task requirement completed (auto turned-in) — quizzes + assessment also done
const done = !!course.completed; const done = !!course.completed;
// all lessons read but task not yet auto-turned-in (quiz/assessment still pending)
const allRead = !done && percent >= 100; const allRead = !done && percent >= 100;
const isLocked = locked[course.reference_id];
const isFetching = fetching[course.reference_id];
// ── Locked card ───────────────────────────────────────────────────
if (isLocked) {
return ( return (
<div <div
key={course.id} key={course.id}
onClick={() => setSelected(course)} onClick={() => navigate('/plans')}
className="bg-card rounded-2xl border dark:hover:border-blue-500 p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0" className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0 opacity-80"
> >
<div className="flex gap-2 items-center flex-wrap"> <div className="flex items-center gap-1.5">
{info ? ( <BookOpen className="size-3.5 text-muted-foreground shrink-0" />
<> <span className="text-xs text-muted-foreground font-medium">Course</span>
{info.subscription && ( <div className="ml-auto">
<Badge variant="secondary"> <TierBadge tier={courseInfo?.subscription} locked />
<Tag className="size-3" /> {SUB_LABEL[info.subscription] ?? info.subscription}
</Badge>
)}
{info.level && (
<Badge variant="secondary">
<Tag className="size-3" /> {LVL_LABEL[info.level] ?? info.level}
</Badge>
)}
</>
) : course.reference_id ? (
<Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
) : null}
</div> </div>
</div>
<h1 className="text-lg font-medium leading-snug line-clamp-3 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors"> <div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
{course.title} {course.title}
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2"> <p className="text-sm text-muted-foreground mt-1 line-clamp-2">
This course requires a higher subscription plan.
</p>
</div>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
</div>
);
}
// ── Normal card ───────────────────────────────────────────────────
return (
<div
key={course.id}
onClick={() => !isFetching && setSelected(course)}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
isFetching
? 'opacity-60 cursor-wait'
: 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
}`}
>
{/* Card type row */}
<div className="flex items-center gap-1.5">
<BookOpen className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Course</span>
<div className="ml-auto flex items-center gap-1.5">
{info?.subscription && <TierBadge tier={info.subscription} />}
{info?.level && (
<Badge variant="outline" className="text-xs capitalize">
{LVL_LABEL[info.level] ?? info.level}
</Badge>
)}
{!info && course.reference_id && (
<Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
)}
</div>
</div>
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
{course.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
{info?.description ?? ''} {info?.description ?? ''}
</p> </p>
</div>
<div className="flex flex-col gap-3 mt-auto pt-1"> <div className="flex flex-col gap-2 mt-auto pt-1 border-t">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm pt-1">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5 text-muted-foreground">
{done {done
? <><CheckCheck className="size-4 text-green-500" /> Completed</> ? <><CheckCheck className="size-4 text-green-500" /><span className="text-green-600 dark:text-green-400 font-medium">Completed</span></>
: allRead : allRead
? <><CheckCheck className="size-4 text-amber-500" /> Lessons Done</> ? <><CheckCheck className="size-4 text-amber-500" /><span className="text-amber-600 dark:text-amber-400 font-medium">Lessons Done</span></>
: <><RefreshCcw className="size-4 text-muted-foreground" /> In Progress</> : <><RefreshCcw className="size-4" /> In Progress</>
} }
</span> </span>
<span className="font-medium">{percent}%</span> <span className="font-semibold text-xs">{percent}%</span>
</div> </div>
<Progress <Progress
value={percent} value={percent}
@@ -146,9 +220,7 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
description={done ? "Course Summary" : "Course Info"} description={done ? "Course Summary" : "Course Info"}
footer={ footer={
<> <>
<Button variant="outline" onClick={() => setSelected(null)}> <Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
Cancel
</Button>
<Button <Button
onClick={() => { onClick={() => {
if (info?.course_id) navigate(`/course/${info.course_id}/unit`, { state: allRead ? { seekFirstIncomplete: true } : undefined }); if (info?.course_id) navigate(`/course/${info.course_id}/unit`, { state: allRead ? { seekFirstIncomplete: true } : undefined });
@@ -163,25 +235,22 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{done ? ( {done ? (
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium"> <div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
<CheckCheck className="size-4 shrink-0" /> <CheckCheck className="size-4 shrink-0" /> Automatically Turned-in
Automatically Turned-in
</div> </div>
) : allRead ? ( ) : allRead ? (
<div className="flex items-center gap-2 bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-lg px-4 py-3 text-sm font-medium"> <div className="flex items-center gap-2 bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-lg px-4 py-3 text-sm font-medium">
<CheckCheck className="size-4 shrink-0" /> <CheckCheck className="size-4 shrink-0" /> Lessons complete — finish quizzes &amp; assessment to turn in
Lessons complete — finish quizzes &amp; assessment to turn in
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg px-4 py-3 text-sm font-medium"> <div className="flex items-center gap-2 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg px-4 py-3 text-sm font-medium">
<RefreshCcw className="size-4 shrink-0" /> <RefreshCcw className="size-4 shrink-0" /> Currently in progress
Currently in progress
</div> </div>
)} )}
<div className="grid grid-cols-2 divide-x rounded-lg border text-center text-sm"> <div className="grid grid-cols-2 divide-x rounded-lg border text-center text-sm">
<div className="flex flex-col gap-1 py-4"> <div className="flex flex-col gap-1 py-4">
<span className="text-xl font-bold"> <span className="text-xl font-bold">
{info?.subscription ? (SUB_LABEL[info.subscription] ?? info.subscription) : '—'} {info?.subscription ? (info.subscription.charAt(0).toUpperCase() + info.subscription.slice(1)) : '—'}
</span> </span>
<span className="text-muted-foreground text-xs">Subscription</span> <span className="text-muted-foreground text-xs">Subscription</span>
</div> </div>
@@ -194,9 +263,7 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium"> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">About this course</p>
About this course
</p>
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p> <p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
</div> </div>
</div> </div>
@@ -2,63 +2,182 @@ import { useNavigate } from "react-router-dom";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { FileVideo, Tag, CheckCheck } from "lucide-react"; import { FileText, CheckCheck, Lock, Zap, Info } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
function TierBadge({ tier }) {
if (tier === 'premium')
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
if (tier === 'exclusive')
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
return null;
}
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => { const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [details, setDetails] = useState({}); const [details, setDetails] = useState({});
const [locked, setLocked] = useState({});
const [lockedInfo, setLockedInfo] = useState({});
const [unavailable, setUnavailable] = useState({});
const [fetching, setFetching] = useState({});
useEffect(() => { useEffect(() => {
lessons.forEach(async (lesson) => { lessons.forEach(async (lesson) => {
if (!lesson.reference_id) return; if (!lesson.reference_id) return;
setFetching((prev) => ({ ...prev, [lesson.reference_id]: true }));
try { try {
const res = await api.get(`/client/courses/lesson/uuid/${lesson.reference_id}`); const res = await api.get(`/client/courses/lesson/uuid/${lesson.reference_id}`);
const d = res.data?.data; const d = res.data?.data;
if (d) setDetails((prev) => ({ ...prev, [lesson.reference_id]: d })); if (d) setDetails((prev) => ({ ...prev, [lesson.reference_id]: d }));
} catch (err) { } catch (err) {
console.error('[ReadLesson] fetch failed:', err?.response?.status, err?.message); if (err?.response?.status === 403) {
setLocked((prev) => ({ ...prev, [lesson.reference_id]: true }));
const course = err.response?.data?.course;
if (course) setLockedInfo((prev) => ({ ...prev, [lesson.reference_id]: course }));
} else {
setUnavailable((prev) => ({ ...prev, [lesson.reference_id]: true }));
}
} finally {
setFetching((prev) => ({ ...prev, [lesson.reference_id]: false }));
} }
}); });
}, []); }, []);
const hasLocked = Object.values(locked).some(Boolean);
return ( return (
<div className="border rounded-lg bg-card overflow-hidden"> <div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2"> <div className="px-6 py-4 border-b flex items-center gap-2">
<FileVideo className="size-4 text-muted-foreground" /> <FileText className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2> <h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">{lessons.length}</Badge> <Badge variant="secondary" className="ml-auto">{lessons.length}</Badge>
</div> </div>
{hasLocked && (
<div className="flex items-start gap-3 px-5 py-3.5 border-b bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300">
<Info className="size-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-snug">Subscription Required</p>
<p className="text-xs mt-0.5 text-amber-700 dark:text-amber-400 leading-relaxed">
To complete this activity, subscribe to one of our available tier plans.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
)}
<ScrollArea className="w-full bg-muted overflow-hidden"> <ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4"> <div className="flex gap-4 p-4">
{lessons.map((lesson) => { {lessons.map((lesson) => {
const info = details[lesson.reference_id]; const info = details[lesson.reference_id];
const progress = lesson.completed ? 100 : 0; const courseInfo = lockedInfo[lesson.reference_id];
const isLocked = locked[lesson.reference_id];
const isUnavailable = unavailable[lesson.reference_id];
const isFetching = fetching[lesson.reference_id];
// ── Locked card ───────────────────────────────────────
if (isLocked) {
return ( return (
<div <div
key={lesson.id} key={lesson.id}
onClick={() => navigate( onClick={() => navigate('/plans')}
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0 opacity-80"
>
<div className="flex items-center gap-1.5">
<FileText className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Lesson</span>
<div className="ml-auto">
<TierBadge tier={courseInfo?.subscription} />
</div>
</div>
{courseInfo?.title && (
<p className="text-xs text-muted-foreground font-medium truncate -mt-1">
from <span className="text-foreground/70">{courseInfo.title}</span>
</p>
)}
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
{lesson.title}
</h1>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
Subscribe to <span className="font-medium">{courseInfo?.title ?? 'this course'}</span> to unlock this lesson.
</p>
</div>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
</div>
);
}
// ── Unavailable card ──────────────────────────────────
if (isUnavailable) {
return (
<div
key={lesson.id}
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 w-96 shrink-0 opacity-50 cursor-not-allowed"
>
<div className="flex items-center gap-1.5">
<FileText className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Lesson</span>
<Badge variant="secondary" className="ml-auto gap-1 text-muted-foreground text-xs">
<Lock className="size-3" /> Unavailable
</Badge>
</div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
{lesson.title}
</h1>
<p className="text-sm text-muted-foreground">This lesson is no longer available.</p>
</div>
);
}
// ── Normal card ───────────────────────────────────────
return (
<div
key={lesson.id}
onClick={() => !isFetching && navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`, `/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { lesson } }, { state: { lesson } },
)} )}
className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0" className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
isFetching
? 'opacity-60 cursor-wait'
: 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
}`}
> >
<Badge variant="secondary" className="w-fit"> {/* Card type row */}
<Tag className="size-3" /> Lesson <div className="flex items-center gap-1.5">
</Badge> <FileText className="size-3.5 text-muted-foreground shrink-0" />
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors"> <span className="text-xs text-muted-foreground font-medium">Lesson</span>
</div>
{/* Course breadcrumb */}
{info?.unit?.course?.title && (
<p className="text-xs text-muted-foreground truncate -mt-1.5">
from <span className="text-foreground/70 font-medium">{info.unit.course.title}</span>
</p>
)}
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
{lesson.title} {lesson.title}
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2"> <p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
{info?.description ?? ''} {info?.description ?? ''}
</p> </p>
</div>
<div className="flex flex-col gap-3 mt-auto pt-1"> <div className="flex flex-col gap-2 mt-auto pt-1 border-t">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm pt-1">
{lesson.completed ? ( {lesson.completed ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium"> <span className="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-medium">
<CheckCheck className="size-4" /> Completed <CheckCheck className="size-4" /> Completed
</span> </span>
) : ( ) : (
@@ -66,7 +185,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
)} )}
</div> </div>
<Progress <Progress
value={progress} value={lesson.completed ? 100 : 0}
className={`h-1.5 ${lesson.completed ? "[&>div]:bg-green-500" : ""}`} className={`h-1.5 ${lesson.completed ? "[&>div]:bg-green-500" : ""}`}
/> />
</div> </div>
+133 -24
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Layers, Tag, CheckCheck, RefreshCw, SendHorizonal } from "lucide-react"; import { Layers, CheckCheck, RefreshCw, SendHorizonal, Lock, Zap, Info } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
@@ -8,20 +8,41 @@ import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
function TierBadge({ tier }) {
if (tier === 'premium')
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
if (tier === 'exclusive')
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
return null;
}
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => { const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [details, setDetails] = useState({}); const [details, setDetails] = useState({});
const [locked, setLocked] = useState({});
const [lockedInfo, setLockedInfo] = useState({});
const [unavailable, setUnavailable] = useState({});
const [fetching, setFetching] = useState({});
useEffect(() => { useEffect(() => {
units.forEach(async (unit) => { units.forEach(async (unit) => {
if (!unit.reference_id) return; if (!unit.reference_id) return;
setFetching((prev) => ({ ...prev, [unit.reference_id]: true }));
try { try {
const res = await api.get(`/client/courses/unit/uuid/${unit.reference_id}`); const res = await api.get(`/client/courses/unit/uuid/${unit.reference_id}`);
const d = res.data?.data; const d = res.data?.data;
if (d) setDetails((prev) => ({ ...prev, [unit.reference_id]: d })); if (d) setDetails((prev) => ({ ...prev, [unit.reference_id]: d }));
} catch (err) { } catch (err) {
console.error('[ReadUnit] fetch failed:', err?.response?.status, err?.message); if (err?.response?.status === 403) {
setLocked((prev) => ({ ...prev, [unit.reference_id]: true }));
const course = err.response?.data?.course;
if (course) setLockedInfo((prev) => ({ ...prev, [unit.reference_id]: course }));
} else {
setUnavailable((prev) => ({ ...prev, [unit.reference_id]: true }));
}
} finally {
setFetching((prev) => ({ ...prev, [unit.reference_id]: false }));
} }
}); });
}, []); }, []);
@@ -41,6 +62,8 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
); );
} }
const hasLocked = Object.values(locked).some(Boolean);
return ( return (
<> <>
<div className="border rounded-lg bg-card overflow-hidden"> <div className="border rounded-lg bg-card overflow-hidden">
@@ -49,44 +72,131 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<h2 className="font-semibold text-sm">{title}</h2> <h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">{units.length}</Badge> <Badge variant="secondary" className="ml-auto">{units.length}</Badge>
</div> </div>
{hasLocked && (
<div className="flex items-start gap-3 px-5 py-3.5 border-b bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300">
<Info className="size-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-snug">Subscription Required</p>
<p className="text-xs mt-0.5 text-amber-700 dark:text-amber-400 leading-relaxed">
To complete this activity, subscribe to one of our available tier plans.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
)}
<ScrollArea className="w-full bg-muted overflow-hidden"> <ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4"> <div className="flex gap-4 p-4">
{units.map((unit) => { {units.map((unit) => {
const info = details[unit.reference_id]; const info = details[unit.reference_id];
const courseInfo = lockedInfo[unit.reference_id];
const progress = getProgress(unit); const progress = getProgress(unit);
const isLocked = locked[unit.reference_id];
const isUnavailable = unavailable[unit.reference_id];
const isFetching = fetching[unit.reference_id];
// ── Locked card ───────────────────────────────────
if (isLocked) {
return ( return (
<div <div
key={unit.id} key={unit.id}
onClick={() => setSelected(unit)} onClick={() => navigate('/plans')}
className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0" className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0 opacity-80"
> >
<Badge variant="secondary" className="w-fit truncate max-w-full"> <div className="flex items-center gap-1.5">
<Tag className="size-3 shrink-0" /> <Layers className="size-3.5 text-muted-foreground shrink-0" />
<span className="truncate"> <span className="text-xs text-muted-foreground font-medium">Unit</span>
{info ? (info.course?.title ?? 'No course') : 'Loading…'} <div className="ml-auto">
</span> <TierBadge tier={courseInfo?.subscription} />
</Badge> </div>
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors"> </div>
{courseInfo?.title && (
<p className="text-xs text-muted-foreground font-medium truncate -mt-1">
from <span className="text-foreground/70">{courseInfo.title}</span>
</p>
)}
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
{unit.title} {unit.title}
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2"> <p className="text-sm text-muted-foreground mt-1 line-clamp-2">
Subscribe to <span className="font-medium">{courseInfo?.title ?? 'this course'}</span> to unlock this unit.
</p>
</div>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
</div>
);
}
// ── Unavailable card ──────────────────────────────
if (isUnavailable) {
return (
<div
key={unit.id}
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 w-96 shrink-0 opacity-50 cursor-not-allowed"
>
<div className="flex items-center gap-1.5">
<Layers className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Unit</span>
<Badge variant="secondary" className="ml-auto gap-1 text-muted-foreground text-xs">
<Lock className="size-3" /> Unavailable
</Badge>
</div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
{unit.title}
</h1>
<p className="text-sm text-muted-foreground">This unit is no longer available.</p>
</div>
);
}
// ── Normal card ───────────────────────────────────
return (
<div
key={unit.id}
onClick={() => !isFetching && setSelected(unit)}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
isFetching
? 'opacity-60 cursor-wait'
: 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
}`}
>
{/* Card type row */}
<div className="flex items-center gap-1.5">
<Layers className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Unit</span>
</div>
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
{unit.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
{info?.description ?? ''} {info?.description ?? ''}
</p> </p>
</div>
<div className="flex flex-col gap-3 mt-auto pt-1"> <div className="flex flex-col gap-2 mt-auto pt-1 border-t">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm pt-1">
{progress >= 100 ? ( {progress >= 100 ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium"> <span className="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-medium">
<CheckCheck className="size-4" /> Completed <CheckCheck className="size-4" /> Completed
</span> </span>
) : progress > 0 ? ( ) : progress > 0 ? (
<span className="flex items-center gap-1 font-medium"> <span className="flex items-center gap-1.5 text-muted-foreground font-medium">
<RefreshCw className="size-4" /> In Progress <RefreshCw className="size-4" /> In Progress
</span> </span>
) : ( ) : (
<span className="text-muted-foreground font-medium">Not Started</span> <span className="text-muted-foreground font-medium">Not Started</span>
)} )}
{progress > 0 && <span className="text-xs font-semibold">{progress}%</span>}
</div> </div>
<Progress <Progress
value={progress} value={progress}
@@ -114,7 +224,11 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`, `/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit: selected } }, { state: { unit: selected } },
)} )}
disabled={getProgress(selected ?? {}) >= 100} disabled={
getProgress(selected ?? {}) >= 100 ||
locked[selected?.reference_id] ||
!details[selected?.reference_id]
}
> >
<SendHorizonal /> Proceed <SendHorizonal /> Proceed
</Button> </Button>
@@ -130,22 +244,17 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{done && ( {done && (
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-950/40 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg px-4 py-3"> <div className="flex items-center gap-2 bg-green-50 dark:bg-green-950/40 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg px-4 py-3">
<CheckCheck className="size-4 shrink-0" /> <CheckCheck className="size-4 shrink-0" /> Automatically Turned-in
Automatically Turned-in
</div> </div>
)} )}
{info?.course?.title && ( {info?.course?.title && (
<p className="text-sm"> <p className="text-sm">
<span className="text-muted-foreground">Course: </span> <span className="text-muted-foreground">Course: </span>
<span className="font-medium">{info.course.title}</span> <span className="font-medium">{info.course.title}</span>
</p> </p>
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-wide text-muted-foreground font-medium"> <p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">About this unit</p>
About this unit
</p>
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p> <p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
</div> </div>
</div> </div>
@@ -13,6 +13,13 @@ import { useEffect, useState } from "react";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { SendHorizonal } from "lucide-react"; import { SendHorizonal } from "lucide-react";
// ── URL normalizer — ensures protocol is present so href is never treated as relative ──
const normalizeUrl = (url) => {
if (!url) return '#';
if (/^https?:\/\//i.test(url)) return url;
return `https://${url}`;
};
// ── Meta fetcher ────────────────────────────────────────────────────────────── // ── Meta fetcher ──────────────────────────────────────────────────────────────
const fetchLinkMeta = async (url) => { const fetchLinkMeta = async (url) => {
try { try {
@@ -113,7 +120,7 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground break-all">{link.url}</p> <p className="text-xs text-muted-foreground break-all">{link.url}</p>
<Button asChild variant="outline" className="w-full"> <Button asChild variant="outline" className="w-full">
<a href={link.url} target="_blank" rel="noopener noreferrer"> <a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
Open Link Open Link
</a> </a>
+141 -39
View File
@@ -1,8 +1,8 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { import {
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon, BadgeCheck, House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
SendHorizonal, CheckCheck, SendHorizonal, CheckCheck, CheckCircle2, Clock,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,8 +13,12 @@ import { useScrollTrigger } from "../hooks/ScrollTrigger";
import { import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger, Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import {
Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import { useClientCourses } from "@/contexts/ClientCoursesContext"; import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -31,17 +35,17 @@ function formatDuration(seconds = 0) {
// ─── Certificate badge icon ──────────────────────────────────────────────────── // ─── Certificate badge icon ────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => ( const CertBadgeIcon = ({ className }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<rect width="120" height="120" rx="26" fill="url(#cert-grad)" />
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
<rect x="22" y="54" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
<rect x="17" y="76" width="86" height="14" rx="7" fill="#D4A017" />
<defs> <defs>
<linearGradient id="cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse"> <linearGradient id="prism-cd" x1="0" y1="0" x2="1" y2="1">
<stop stopColor="#8B9FEE" /> <stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#4F6FD4" /> <stop offset="1" stopColor="#5061E6"/>
</linearGradient> </linearGradient>
</defs> </defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-cd)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg> </svg>
); );
@@ -79,7 +83,7 @@ const useVisibleNodes = (refs, count) => {
// ─── Unit Accordion Block ───────────────────────────────────────────────────── // ─── Unit Accordion Block ─────────────────────────────────────────────────────
const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle }) => { const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@@ -93,7 +97,7 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle }
<Accordion <Accordion
type="single" type="single"
collapsible collapsible
defaultValue={i === 0 ? `unit-${unit.unit_id}` : undefined} defaultValue={`unit-${unit.unit_id}`}
onValueChange={() => setTimeout(onToggle, 250)} onValueChange={() => setTimeout(onToggle, 250)}
> >
<AccordionItem value={`unit-${unit.unit_id}`} className="border-none"> <AccordionItem value={`unit-${unit.unit_id}`} className="border-none">
@@ -130,7 +134,10 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle }
onClick={() => navigate(`/course/${courseId}/unit`, { state: { lessonId: lesson.lesson_id, unitId: unit.unit_id } })} onClick={() => navigate(`/course/${courseId}/unit`, { state: { lessonId: lesson.lesson_id, unitId: unit.unit_id } })}
> >
<div className="flex items-center gap-3 select-none"> <div className="flex items-center gap-3 select-none">
<div className="w-4 h-4 rounded-full border border-muted-foreground/40 flex items-center justify-center flex-shrink-0" /> {isCompleted(lesson.uuid)
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <div className="w-4 h-4 rounded-full border border-muted-foreground/40 flex items-center justify-center flex-shrink-0" />
}
<span className="text-md text-card-foreground">{lesson.title}</span> <span className="text-md text-card-foreground">{lesson.title}</span>
</div> </div>
{lesson.duration_seconds > 0 && ( {lesson.duration_seconds > 0 && (
@@ -146,9 +153,108 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle }
); );
}; };
// ─── Certificate Card ─────────────────────────────────────────────────────────
function fmtDate(iso) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, nodeRef }) => {
const isIssued = !!certificate;
const isPending = !isIssued && !!pendingCert;
let issuedLabel = "Upon completion";
if (isIssued) issuedLabel = fmtDate(certificate.issued_at);
if (isPending) issuedLabel = fmtDate(pendingCert.issue_at);
return (
<Dialog>
<motion.div
ref={nodeRef}
className="w-[320px] rounded-2xl border bg-card p-6 flex flex-col items-center gap-4 shadow-sm"
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay, ease: "easeOut" }}
>
<CertBadgeIcon className="w-24" />
<p className="text-lg font-bold text-center leading-snug capitalize">{`${courseLevel} Level`}</p>
<div className="w-full rounded-lg border px-3 py-2.5">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Course</p>
<p className="text-sm font-medium mt-1">{courseTitle}</p>
</div>
<div className="w-full flex items-end justify-between">
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Issued</p>
<p className="text-sm text-foreground mt-0.5">{issuedLabel}</p>
</div>
<DialogTrigger asChild>
<Badge className="bg-blue-100 text-blue-700 border border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700 gap-1 cursor-pointer">
<Clock className="size-3" /> Issued
</Badge>
</DialogTrigger>
</div>
</motion.div>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Clock className="size-4 text-blue-500" />
{isIssued ? "Certificate Issued" : isPending ? "Certificate Pending" : "Certificate"}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 pt-1 text-sm text-muted-foreground">
{isIssued ? (
<>
<p>
Your certificate for this course was officially issued on{" "}
<span className="font-medium text-foreground">{fmtDate(certificate.issued_at)}</span>.
</p>
<div className="rounded-lg border bg-muted px-4 py-3">
<p className="text-sm font-semibold text-foreground">{courseTitle}</p>
</div>
<p>
You can view and download it from your <span className="font-medium text-foreground">Certificates</span> page.
</p>
</>
) : isPending ? (
<>
<p>
You passed the course assessment on{" "}
<span className="font-medium text-foreground">{fmtDate(pendingCert.passed_at)}</span>.
Your certificate is being processed and will be officially issued on:
</p>
<div className="rounded-lg border bg-muted px-4 py-3 text-center">
<p className="text-base font-semibold text-foreground">{fmtDate(pendingCert.issue_at)}</p>
</div>
<p>
Once issued, it will appear in your <span className="font-medium text-foreground">Certificates</span> page.
</p>
</>
) : (
<>
<p>
Complete all lessons and pass the course assessment to earn your certificate for:
</p>
<div className="rounded-lg border bg-muted px-4 py-3">
<p className="text-sm font-semibold text-foreground">{courseTitle}</p>
</div>
<p>
Your certificate will be issued within <span className="font-medium text-foreground">45 minutes</span> after passing and will appear in your <span className="font-medium text-foreground">Certificates</span> page.
</p>
</>
)}
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
};
// ─── Course Units (spine + cards) ───────────────────────────────────────────── // ─── Course Units (spine + cards) ─────────────────────────────────────────────
const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle }) => { const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle, isCompleted, pendingCert, certificate }) => {
const wrapRef = useRef(null); const wrapRef = useRef(null);
const cardRefs = useRef([]); const cardRefs = useRef([]);
// +1 for the certificate card at the end // +1 for the certificate card at the end
@@ -246,33 +352,19 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle
cardRefs={cardRefs} cardRefs={cardRefs}
courseId={courseId} courseId={courseId}
onToggle={measure} onToggle={measure}
isCompleted={isCompleted}
/> />
))} ))}
{/* Certificate badge — final node, always present */} {/* Certificate badge — final node, always present */}
<motion.div <CertCard
ref={(el) => (cardRefs.current[units.length] = el)} nodeRef={(el) => (cardRefs.current[units.length] = el)}
className="w-[320px] rounded-2xl border bg-card p-6 flex flex-col items-center gap-4 shadow-sm" delay={units.length * 0.05}
initial={{ opacity: 0, y: 14 }} courseTitle={courseTitle}
animate={{ opacity: 1, y: 0 }} courseLevel={courseLevel}
transition={{ duration: 0.35, delay: units.length * 0.05, ease: "easeOut" }} pendingCert={pendingCert}
> certificate={certificate}
<CertBadgeIcon className="w-24" /> />
<p className="text-lg font-bold text-center leading-snug capitalize">{`${courseLevel} Level`}</p>
<div className="w-full rounded-lg border px-3 py-2.5">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Course</p>
<p className="text-sm font-medium mt-1">{courseTitle}</p>
</div>
<div className="w-full flex items-end justify-between">
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Issued</p>
<p className="text-sm text-foreground mt-0.5">Upon completion</p>
</div>
<Badge className="bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1">
<BadgeCheck className="size-3" /> Verified
</Badge>
</div>
</motion.div>
</div> </div>
</div> </div>
); );
@@ -288,6 +380,7 @@ const CourseDetails = () => {
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses(); const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
const { myTier, getMyTier } = useClientTiers(); const { myTier, getMyTier } = useClientTiers();
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
const hasCompleted = !!course?.is_completed; const hasCompleted = !!course?.is_completed;
@@ -298,7 +391,8 @@ const CourseDetails = () => {
useEffect(() => { useEffect(() => {
getMyTier(); getMyTier();
getCourse(courseId); getCourse(courseId);
return () => resetCourse(); fetchCourseProgress(courseId);
return () => { resetCourse(); resetProgress(); };
}, [courseId]); }, [courseId]);
if (courseBlocked) { if (courseBlocked) {
@@ -431,7 +525,15 @@ const CourseDetails = () => {
{course?.units?.length > 0 && ( {course?.units?.length > 0 && (
<> <>
<div className="font-bold text-2xl">Course content</div> <div className="font-bold text-2xl">Course content</div>
<CourseUnits units={course.units} courseId={courseId} courseTitle={course.title} courseLevel={course.level} /> <CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
/>
</> </>
)} )}
</div> </div>
+8 -3
View File
@@ -214,16 +214,21 @@ const CoursesList = () => {
if (!myTier) getMyTier(); if (!myTier) getMyTier();
}, []); }, []);
// ── Filter ──────────────────────────────────────────────────────────────── // ── Filter + sort ─────────────────────────────────────────────────────────
const filtered = courses.filter((c) => { const filtered = useMemo(() =>
courses
.filter((c) => {
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) || const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
(c.description ?? "").toLowerCase().includes(search.toLowerCase()); (c.description ?? "").toLowerCase().includes(search.toLowerCase());
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase(); const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
const matchSub = subFilter === "All" || c.subscription === subFilter.toLowerCase(); const matchSub = subFilter === "All" || c.subscription === subFilter.toLowerCase();
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter); const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
return matchSearch && matchLevel && matchSub && matchCategory; return matchSearch && matchLevel && matchSub && matchCategory;
}); })
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
[courses, search, levelFilter, subFilter, categoryFilter]
);
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice( const paginated = filtered.slice(
+8 -8
View File
@@ -9,17 +9,17 @@ import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
const CertBadgeIcon = ({ className }) => ( const CertBadgeIcon = ({ className }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<rect width="120" height="120" rx="26" fill="url(#cert-grad)" />
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
<rect x="22" y="54" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
<rect x="17" y="76" width="86" height="14" rx="7" fill="white" fillOpacity="0.80" />
<defs> <defs>
<linearGradient id="cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse"> <linearGradient id="prism-mc" x1="0" y1="0" x2="1" y2="1">
<stop stopColor="#8B9FEE" /> <stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#4F6FD4" /> <stop offset="1" stopColor="#5061E6"/>
</linearGradient> </linearGradient>
</defs> </defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-mc)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg> </svg>
); );
+8 -8
View File
@@ -68,17 +68,17 @@ const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
// ─── Certificate landscape card (profile preview) ──────────────────────────── // ─── Certificate landscape card (profile preview) ────────────────────────────
const CertBadgeIcon = ({ className }) => ( const CertBadgeIcon = ({ className }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<rect width="120" height="120" rx="26" fill="url(#cert-grad-p)" />
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
<rect x="22" y="54" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
<rect x="17" y="76" width="86" height="14" rx="7" fill="white" fillOpacity="0.80" />
<defs> <defs>
<linearGradient id="cert-grad-p" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse"> <linearGradient id="prism-p" x1="0" y1="0" x2="1" y2="1">
<stop stopColor="#8B9FEE" /> <stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#4F6FD4" /> <stop offset="1" stopColor="#5061E6"/>
</linearGradient> </linearGradient>
</defs> </defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-p)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg> </svg>
); );
+31 -5
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation } from "react-router-dom"; import { useParams, useNavigate, useLocation } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle } from "lucide-react"; import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group"; import { ButtonGroup } from "@/components/ui/button-group";
import { import {
@@ -232,10 +232,10 @@ const UnitList = () => {
const location = useLocation(); const location = useLocation();
const { const {
course, courseLoading, getCourse, course, courseLoading, courseBlocked, getCourse,
lesson, lessonLoading, getLesson, resetLesson, lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz,
assessment, assessmentLoading, getCourseAssessment, resetAssessment, submitCourseAssessment, assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment,
} = useClientCourses(); } = useClientCourses();
const { const {
@@ -483,6 +483,29 @@ const UnitList = () => {
else handleAssessmentClick(); 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>
);
}
return ( return (
<> <>
<PageMeta title={pageTitle} /> <PageMeta title={pageTitle} />
@@ -614,8 +637,11 @@ const UnitList = () => {
quiz={assessment ? { ...assessment, quiz_id: assessment.assessment_id } : null} quiz={assessment ? { ...assessment, quiz_id: assessment.assessment_id } : null}
loading={assessmentLoading} loading={assessmentLoading}
label="Assessment" label="Assessment"
onSubmit={async (answers) => { onStart={() => startCourseAssessment(courseId, assessment.assessment_id)}
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers); onDraft={(answers) => saveDraft(courseId, assessment.assessment_id, answers)}
onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)}
onSubmit={async (answers, sessionId) => {
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId);
await getCourse(courseId); await getCourse(courseId);
return result; return result;
}} }}
+64 -17
View File
@@ -12,6 +12,7 @@ import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { import {
House, TableOfContents, CheckCheck, Circle, House, TableOfContents, CheckCheck, Circle,
BookOpen, Layers, FileText, ArrowLeft, ChevronDown, ChevronRight, BookOpen, Layers, FileText, ArrowLeft, ChevronDown, ChevronRight,
Lock, Zap, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -160,16 +161,20 @@ const SidebarContent = ({
const CourseView = ({ req }) => { const CourseView = ({ req }) => {
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [locked, setLocked] = useState(false);
useEffect(() => { useEffect(() => {
if (!req.reference_id) { setLoading(false); return; } if (!req.reference_id) { setLoading(false); return; }
api.get(`/client/courses/uuid/${req.reference_id}`) api.get(`/client/courses/uuid/${req.reference_id}`)
.then((r) => setInfo(r.data?.data ?? null)) .then((r) => setInfo(r.data?.data ?? null))
.catch(() => {}) .catch((err) => {
if (err?.response?.status === 403) setLocked(true);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [req.reference_id]); }, [req.reference_id]);
if (loading) return <ContentSkeleton />; if (loading) return <ContentSkeleton />;
if (locked) return <LockedContent />;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -190,6 +195,7 @@ const CourseView = ({ req }) => {
const LessonView = ({ req }) => { const LessonView = ({ req }) => {
const [lesson, setLesson] = useState(null); const [lesson, setLesson] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [locked, setLocked] = useState(false);
useEffect(() => { useEffect(() => {
if (!req.reference_id) { setLoading(false); return; } if (!req.reference_id) { setLoading(false); return; }
@@ -198,11 +204,14 @@ const LessonView = ({ req }) => {
const d = r.data?.data; const d = r.data?.data;
if (d) setLesson({ ...d, blocks: d.blocks ?? [] }); if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
}) })
.catch(() => {}) .catch((err) => {
if (err?.response?.status === 403) setLocked(true);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [req.reference_id]); }, [req.reference_id]);
if (loading) return <ContentSkeleton />; if (loading) return <ContentSkeleton />;
if (locked) return <LockedContent />;
return <LessonBlock lesson={lesson} loading={false} />; return <LessonBlock lesson={lesson} loading={false} />;
}; };
@@ -216,6 +225,32 @@ const ContentSkeleton = () => (
</div> </div>
); );
// ─── Locked content placeholder ───────────────────────────────────────────────
const LockedContent = () => {
const navigate = useNavigate();
return (
<div className="flex flex-col items-center gap-6 py-20 text-center">
<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-lg font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
To take this activity, 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>
);
};
// ─── Main page ──────────────────────────────────────────────────────────────── // ─── Main page ────────────────────────────────────────────────────────────────
const ViewRequirement = () => { const ViewRequirement = () => {
const { groupId, taskListId, taskId } = useParams(); const { groupId, taskListId, taskId } = useParams();
@@ -231,10 +266,12 @@ const ViewRequirement = () => {
const [selection, setSelection] = useState(null); const [selection, setSelection] = useState(null);
const [unitLessonsMap, setUnitLessonsMap] = useState({}); const [unitLessonsMap, setUnitLessonsMap] = useState({});
const [unitLoadingMap, setUnitLoadingMap] = useState({}); const [unitLoadingMap, setUnitLoadingMap] = useState({});
const [lockedReqs, setLockedReqs] = useState(new Set());
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopOpen, setDesktopOpen] = useState(true); const [desktopOpen, setDesktopOpen] = useState(true);
const [scrollPct, setScrollPct] = useState(0); const [scrollPct, setScrollPct] = useState(0);
const initialised = useRef(false); const initialised = useRef(false);
const lastAutoMarkRef = useRef(null);
// ── Fetch task + progress ───────────────────────────────────────────────── // ── Fetch task + progress ─────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -260,7 +297,11 @@ const ViewRequirement = () => {
const lessons = data?.lessons ?? []; const lessons = data?.lessons ?? [];
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons } })); setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons } }));
}) })
.catch(() => {}) .catch((err) => {
if (err?.response?.status === 403) {
setLockedReqs((prev) => new Set(prev).add(req.requirement_id));
}
})
.finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false }))); .finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false })));
}); });
}, [requirements.length]); }, [requirements.length]);
@@ -393,6 +434,17 @@ const ViewRequirement = () => {
const isLastContent = selectedReq?.type !== 'read_unit' || !nextLesson; const isLastContent = selectedReq?.type !== 'read_unit' || !nextLesson;
const canMarkDone = scrollPct >= 100 && isLastContent; const canMarkDone = scrollPct >= 100 && isLastContent;
// ── Auto turn-in: fires once per requirement when user reaches the end ────────
useEffect(() => {
if (!canMarkDone || selectedDone || progressLoading || !selectedReq) return;
if (lockedReqs.has(selectedReq.requirement_id)) return;
// Deduplicate so scrolling back up and down doesn't re-fire
const key = selectedReq.requirement_id + (selection?.lessonUuid ?? '');
if (lastAutoMarkRef.current === key) return;
lastAutoMarkRef.current = key;
handleMarkDone();
}, [canMarkDone, selectedDone]); // eslint-disable-line react-hooks/exhaustive-deps
const sidebarProps = { const sidebarProps = {
requirements, requirements,
selection, selection,
@@ -502,7 +554,9 @@ const ViewRequirement = () => {
)} )}
{selectedReq.type === 'read_unit' && ( {selectedReq.type === 'read_unit' && (
selectedLesson lockedReqs.has(selectedReq.requirement_id)
? <LockedContent />
: selectedLesson
? <LessonBlock lesson={selectedLesson} loading={false} /> ? <LessonBlock lesson={selectedLesson} loading={false} />
: <ContentSkeleton /> : <ContentSkeleton />
)} )}
@@ -511,7 +565,8 @@ const ViewRequirement = () => {
<LessonView req={selectedReq} /> <LessonView req={selectedReq} />
)} )}
{/* Mark done footer */} {/* Turn-in footer — hidden for locked requirements */}
{!lockedReqs.has(selectedReq.requirement_id) && (
<div className="flex items-center justify-between pt-4 border-t"> <div className="flex items-center justify-between pt-4 border-t">
{selectedDone ? ( {selectedDone ? (
<> <>
@@ -533,23 +588,15 @@ const ViewRequirement = () => {
</p> </p>
) : !canMarkDone ? ( ) : !canMarkDone ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Scroll to the end of this lesson to mark as done. Scroll to the end to complete this requirement.
</p> </p>
) : ( ) : (
<> <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground"> <RefreshCw className="size-3.5 animate-spin" /> Turning in…
Mark this requirement as done when finished.
</span> </span>
<Button
onClick={handleMarkDone}
disabled={progressLoading}
>
<CheckCheck className="size-4" />
Mark as done
</Button>
</>
)} )}
</div> </div>
)}
</div> </div>
)} )}
</div> </div>
+12 -3
View File
@@ -2,7 +2,7 @@ import ProtectedRoute from '../../../routes/ProtectedRoute'
import Client from '../pages/Dashboard' import Client from '../pages/Dashboard'
import ClientLayout from '../layout/ClientLayout' import ClientLayout from '../layout/ClientLayout'
import CoursesList from '../pages/CourseList' import CoursesList from '../pages/CourseList'
import { Outlet } from "react-router-dom" import { Navigate, Outlet } from "react-router-dom"
import ScrollToTop from '@/components/generic/ScrollToTop' import ScrollToTop from '@/components/generic/ScrollToTop'
import CourseDetails from '../pages/CourseDetails' import CourseDetails from '../pages/CourseDetails'
import UnitList from '../pages/UnitList' import UnitList from '../pages/UnitList'
@@ -20,18 +20,27 @@ import CourseCheckout from '../pages/CourseCheckout'
import MyCertificates from '../pages/MyCertificates' import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements' import MyAchievements from '../pages/MyAchievements'
import AccountSettings from '../pages/AccountSettings' import AccountSettings from '../pages/AccountSettings'
import IntroPage from '@/modules/auth/pages/Intro'
import { useAuth } from '@/contexts/AuthContext'
const ClientWrapper = () => ( const ClientWrapper = () => {
const { user } = useAuth()
// New users must complete the intro before accessing any client page
if (user?.needs_intro) return <Navigate to="/intro" replace />
return (
<Fragment> <Fragment>
{/* Wrapper components belong here. */} {/* Wrapper components belong here. */}
<ScrollToTop /> <ScrollToTop />
<ClientLayout /> <ClientLayout />
</Fragment> </Fragment>
); )
}
export const ClientRoutes = { export const ClientRoutes = {
element: <ProtectedRoute allowedRoles={['user']} />, element: <ProtectedRoute allowedRoles={['user']} />,
children: [ children: [
// Intro — full-screen, outside ClientLayout, no nav
{ path: 'intro', element: <IntroPage /> },
{ {
element: <ClientWrapper />, element: <ClientWrapper />,
children: [ children: [
+1 -1
View File
@@ -10,7 +10,7 @@ export default function PublicRoute() {
if (user) { if (user) {
switch (user.acc_type) { switch (user.acc_type) {
case 'admin': return <Navigate to="/admin" replace /> case 'admin': return <Navigate to="/admin" replace />
case 'user': return <Navigate to="/dashboard" replace /> case 'user': return <Navigate to={user.needs_intro ? '/intro' : '/dashboard'} replace />
case 'staff': return <Navigate to="/staff" replace /> case 'staff': return <Navigate to="/staff" replace />
default: return <Navigate to="/dashboard" replace /> default: return <Navigate to="/dashboard" replace />
} }