mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
perform test #1
test to courses Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -39,6 +39,8 @@ export function CoursesProvider({ children }) {
|
||||
const [quiz, setQuiz] = useState(null);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [assessment, setAssessment] = useState(null);
|
||||
const [completions, setCompletions] = useState(null); // { summary, completions[] }
|
||||
const [sessions, setSessions] = useState(null); // { summary, sessions[] }
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -847,6 +849,43 @@ export function CoursesProvider({ children }) {
|
||||
[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
|
||||
// =========================================================================
|
||||
@@ -1032,6 +1071,12 @@ export function CoursesProvider({ children }) {
|
||||
fetchLessonPage,
|
||||
saveLessonPage,
|
||||
|
||||
// ── completions & sessions ─────────────────────────────────────────────
|
||||
completions, sessions,
|
||||
fetchQuizCompletions,
|
||||
fetchAssessmentCompletions,
|
||||
fetchAssessmentSessions,
|
||||
|
||||
// ── assessment ─────────────────────────────────────────────────────────
|
||||
fetchAssessment,
|
||||
createAssessment,
|
||||
|
||||
@@ -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 {
|
||||
const { data } = await api.post(
|
||||
`/client/courses/${courseId}/assessment/${assessmentId}/submit`,
|
||||
{ answers }
|
||||
{ answers, ...(sessionId ? { session_id: sessionId } : {}) }
|
||||
);
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
@@ -214,6 +237,9 @@ export function ClientCoursesProvider({ children }) {
|
||||
getLesson,
|
||||
getUnitQuiz,
|
||||
getCourseAssessment,
|
||||
startCourseAssessment,
|
||||
saveDraft,
|
||||
refreshAssessmentSession,
|
||||
submitUnitQuiz,
|
||||
submitCourseAssessment,
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import api from "@/utils/api.util";
|
||||
|
||||
const ClientNotificationContext = createContext(null);
|
||||
|
||||
const POLL_INTERVAL = 60_000;
|
||||
const POLL_INTERVAL_NORMAL = 60_000;
|
||||
const POLL_INTERVAL_FAST = 10_000;
|
||||
|
||||
export function useClientNotifications() {
|
||||
const ctx = useContext(ClientNotificationContext);
|
||||
@@ -15,7 +16,8 @@ export function ClientNotificationProvider({ children }) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unseenCount, setUnseenCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const intervalRef = useRef(null);
|
||||
const intervalRef = useRef(null);
|
||||
const pollSpeedRef = useRef(POLL_INTERVAL_NORMAL);
|
||||
|
||||
const fetchUnseen = useCallback(async () => {
|
||||
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(() => {
|
||||
fetchUnseen();
|
||||
intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL);
|
||||
intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL_NORMAL);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [fetchUnseen]);
|
||||
|
||||
@@ -76,6 +96,8 @@ export function ClientNotificationProvider({ children }) {
|
||||
fetchNotifications,
|
||||
markSeen,
|
||||
markAllSeen,
|
||||
accelerate,
|
||||
decelerate,
|
||||
}}>
|
||||
{children}
|
||||
</ClientNotificationContext.Provider>
|
||||
|
||||
@@ -178,12 +178,19 @@ export function QuestionCard({ question, index, onChange, onRemove, error }) {
|
||||
{/* Options */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Options
|
||||
<span className="ml-1 normal-case text-muted-foreground/60">
|
||||
— click circle to mark correct
|
||||
</span>
|
||||
</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Options
|
||||
<span className="ml-1 normal-case text-muted-foreground/60">
|
||||
— click circle to mark correct
|
||||
</span>
|
||||
</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" && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { useAuth } from "@/contexts/AuthContext";
|
||||
@@ -12,6 +12,26 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -200,6 +220,15 @@ export default function CourseAssessment() {
|
||||
const [timeLimit, setTimeLimit] = useState("");
|
||||
const [isRequired, setIsRequired] = useState(false);
|
||||
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 navItemRefs = useRef([]);
|
||||
@@ -217,18 +246,17 @@ export default function CourseAssessment() {
|
||||
// ── Seed ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!assessment) return;
|
||||
setTitle(assessment.title ?? "");
|
||||
setPassingScore(assessment.passing_score ?? 70);
|
||||
setTimeLimit(assessment.time_limit_minutes ?? "");
|
||||
setIsRequired(assessment.is_required === true || assessment.is_required === 1);
|
||||
setMaxQuestions(assessment.max_questions ?? "");
|
||||
setQuestions(
|
||||
(assessment.questions ?? []).map((q) => ({
|
||||
...q,
|
||||
_tempId: q.question_id,
|
||||
options: q.options ?? [],
|
||||
}))
|
||||
);
|
||||
const t = assessment.title ?? "";
|
||||
const ps = assessment.passing_score ?? 70;
|
||||
const tl = assessment.time_limit_minutes ?? "";
|
||||
const ir = assessment.is_required === true || assessment.is_required === 1;
|
||||
const mq = assessment.max_questions ?? "";
|
||||
const ma = assessment.max_attempts ?? 3;
|
||||
const ch = assessment.cooldown_hours ?? 24;
|
||||
const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
||||
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
|
||||
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]);
|
||||
|
||||
// ── Measure sticky header → --assessment-h ────────────────────────────────
|
||||
@@ -335,46 +363,82 @@ export default function CourseAssessment() {
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
const errs = validate(questions);
|
||||
if (Object.keys(errs).length) {
|
||||
setErrors(errs);
|
||||
// Jump to first error
|
||||
const firstErr = parseInt(Object.keys(errs)[0], 10);
|
||||
jumpTo(firstErr);
|
||||
jumpTo(parseInt(Object.keys(errs)[0], 10));
|
||||
return;
|
||||
}
|
||||
|
||||
let assessmentId = assessment?.assessment_id;
|
||||
const assessmentId = assessment?.assessment_id;
|
||||
const meta = {
|
||||
title: title || "Course Assessment",
|
||||
passing_score: passingScore,
|
||||
time_limit_minutes: timeLimit ? parseInt(timeLimit) : null,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
max_attempts: parseInt(maxAttempts) || 3,
|
||||
cooldown_hours: parseInt(cooldownHours) || 24,
|
||||
updatedBy: user?.user_id,
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
|
||||
// New assessment — no students can be in progress yet, save directly.
|
||||
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);
|
||||
assessmentId = res?.data?.data?.data?.assessment_id;
|
||||
if (!assessmentId) return;
|
||||
id = res?.data?.data?.data?.assessment_id;
|
||||
if (!id) return;
|
||||
} else {
|
||||
await updateAssessment(courseId, assessmentId, meta);
|
||||
await updateAssessment(courseId, id, meta);
|
||||
}
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
|
||||
if (q.question_id) {
|
||||
await updateAssessmentQuestion(courseId, assessmentId, q.question_id, q);
|
||||
await updateAssessmentQuestion(courseId, id, q.question_id, q);
|
||||
} 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 ─────────────────────────────────────────────────────────────────
|
||||
@@ -402,8 +466,8 @@ export default function CourseAssessment() {
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
<Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}>
|
||||
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Assessment
|
||||
</Button>
|
||||
</div>
|
||||
@@ -491,6 +555,28 @@ export default function CourseAssessment() {
|
||||
placeholder={`All (${questions.length})`}
|
||||
/>
|
||||
</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 className="flex items-center gap-3">
|
||||
@@ -569,6 +655,58 @@ export default function CourseAssessment() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -56,21 +56,17 @@ const schema = z.object({
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CertBadgeIcon = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
<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">
|
||||
<defs>
|
||||
<linearGradient id="ec-cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#8B9FEE" />
|
||||
<stop offset="1" stopColor="#4F6FD4" />
|
||||
<linearGradient id="prism-ec" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#8EA2F6"/>
|
||||
<stop offset="1" stopColor="#5061E6"/>
|
||||
</linearGradient>
|
||||
</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>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { PageMeta } from "@/contexts/MetadataContext";
|
||||
@@ -8,7 +12,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,21 +27,32 @@ function InfoRow({ label, children }) {
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
{title && (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
{title && (<><h2 className="text-sm font-semibold">{title}</h2><Separator /></>)}
|
||||
{children}
|
||||
</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 = {
|
||||
multiple_choice: "Multiple Choice",
|
||||
multi_select: "Multi Select",
|
||||
true_false: "True / False",
|
||||
multi_select: "Multi Select",
|
||||
true_false: "True / False",
|
||||
};
|
||||
|
||||
function QuestionView({ question, index }) {
|
||||
@@ -54,15 +68,20 @@ function QuestionView({ question, index }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{TYPE_LABELS[question.type] ?? question.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">{TYPE_LABELS[question.type] ?? question.type}</Badge>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</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">
|
||||
{(question.options ?? []).map((opt, oi) => (
|
||||
<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() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -92,16 +310,38 @@ function LoadingSkeleton() {
|
||||
|
||||
// ─── 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() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchAssessment, assessment, loading } = useCourses();
|
||||
|
||||
const {
|
||||
fetchAssessment, assessment,
|
||||
fetchAssessmentCompletions, fetchAssessmentSessions,
|
||||
completions, sessions,
|
||||
loading,
|
||||
} = useCourses();
|
||||
|
||||
const [activeTab, setActiveTab] = useState("questions");
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssessment(courseId);
|
||||
}, [courseId]);
|
||||
|
||||
const questions = assessment?.questions ?? [];
|
||||
// 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 totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||
|
||||
return (
|
||||
@@ -129,15 +369,30 @@ export default function ViewAssessment() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Modify Assessment
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Create Assessment
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : activeTab === "questions" ? (
|
||||
<>
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
|
||||
@@ -176,30 +426,29 @@ export default function ViewAssessment() {
|
||||
</InfoRow>
|
||||
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
|
||||
<InfoRow label="Max Shown per Attempt">
|
||||
{assessment.max_questions
|
||||
? `${assessment.max_questions} (random)`
|
||||
: `All (${questions.length})`}
|
||||
{assessment.max_questions ? `${assessment.max_questions} (random)` : `All (${questions.length})`}
|
||||
</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>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Questions ── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
|
||||
Questions
|
||||
</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">Questions</p>
|
||||
{questions.length === 0 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
questions.map((q, i) => (
|
||||
<QuestionView key={q.question_id ?? i} question={q} index={i} />
|
||||
))
|
||||
questions.map((q, i) => <QuestionView key={q.question_id ?? i} question={q} index={i} />)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : activeTab === "completions" ? (
|
||||
<CompletionsTab completions={completions} loading={loading} />
|
||||
) : (
|
||||
<SessionsTab sessions={sessions} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,25 @@ function validate(questions) {
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function JumpToInput({ max, onJump }) {
|
||||
@@ -193,10 +212,11 @@ export default function UnitQuiz() {
|
||||
const [isRequired, setIsRequired] = useState(false);
|
||||
const [maxQuestions, setMaxQuestions] = useState("");
|
||||
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
const navContainerRef = useRef(null);
|
||||
const headerRef = useRef(null);
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
const navContainerRef = useRef(null);
|
||||
const headerRef = useRef(null);
|
||||
const initialSnapshot = useRef(null);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
@@ -217,17 +237,13 @@ export default function UnitQuiz() {
|
||||
// ── Seed ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!quiz) return;
|
||||
setTitle(quiz.title ?? "");
|
||||
setPassingScore(quiz.passing_score ?? 70);
|
||||
setIsRequired(quiz.is_required === true || quiz.is_required === 1);
|
||||
setMaxQuestions(quiz.max_questions ?? "");
|
||||
setQuestions(
|
||||
(quiz.questions ?? []).map((q) => ({
|
||||
...q,
|
||||
_tempId: q.question_id,
|
||||
options: q.options ?? [],
|
||||
}))
|
||||
);
|
||||
const t = quiz.title ?? "";
|
||||
const ps = quiz.passing_score ?? 70;
|
||||
const ir = quiz.is_required === true || quiz.is_required === 1;
|
||||
const mq = quiz.max_questions ?? "";
|
||||
const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
||||
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setQuestions(qs);
|
||||
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, questions: qs });
|
||||
}, [quiz]);
|
||||
|
||||
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
|
||||
@@ -334,6 +350,11 @@ export default function UnitQuiz() {
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
const errs = validate(questions);
|
||||
@@ -370,6 +391,7 @@ export default function UnitQuiz() {
|
||||
}
|
||||
}
|
||||
|
||||
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, questions });
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
@@ -398,7 +420,7 @@ export default function UnitQuiz() {
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</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" />}
|
||||
Save Quiz
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { PageMeta } from "@/contexts/MetadataContext";
|
||||
@@ -23,21 +27,31 @@ function InfoRow({ label, children }) {
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
{title && (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
{title && (<><h2 className="text-sm font-semibold">{title}</h2><Separator /></>)}
|
||||
{children}
|
||||
</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 = {
|
||||
multiple_choice: "Multiple Choice",
|
||||
multi_select: "Multi Select",
|
||||
true_false: "True / False",
|
||||
multi_select: "Multi Select",
|
||||
true_false: "True / False",
|
||||
};
|
||||
|
||||
function QuestionView({ question, index }) {
|
||||
@@ -53,15 +67,20 @@ function QuestionView({ question, index }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{TYPE_LABELS[question.type] ?? question.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">{TYPE_LABELS[question.type] ?? question.type}</Badge>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</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">
|
||||
{(question.options ?? []).map((opt, oi) => (
|
||||
<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() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -91,21 +227,38 @@ function LoadingSkeleton() {
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ key: "questions", label: "Questions", icon: HelpCircle },
|
||||
{ key: "completions", label: "Completions", icon: Users },
|
||||
];
|
||||
|
||||
export default function ViewUnitQuiz() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchQuiz, quiz, loading } = useCourses();
|
||||
|
||||
const {
|
||||
fetchQuiz, quiz,
|
||||
fetchQuizCompletions, completions,
|
||||
loading,
|
||||
} = useCourses();
|
||||
|
||||
const [activeTab, setActiveTab] = useState("questions");
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuiz(courseId, unitId);
|
||||
}, [courseId, unitId]);
|
||||
|
||||
const questions = quiz?.questions ?? [];
|
||||
useEffect(() => {
|
||||
if (!quiz?.quiz_id) return;
|
||||
if (activeTab === "completions") fetchQuizCompletions(courseId, unitId, quiz.quiz_id);
|
||||
}, [activeTab, quiz?.quiz_id]);
|
||||
|
||||
const questions = quiz?.questions ?? [];
|
||||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||
|
||||
return (
|
||||
<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 ── */}
|
||||
<div
|
||||
@@ -137,6 +290,25 @@ export default function ViewUnitQuiz() {
|
||||
Modify Quiz
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Create Quiz
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : activeTab === "questions" ? (
|
||||
<>
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Title">{quiz.title || "Unit Quiz"}</InfoRow>
|
||||
@@ -171,31 +338,26 @@ export default function ViewUnitQuiz() {
|
||||
</InfoRow>
|
||||
<InfoRow label="Passing Score">{quiz.passing_score ?? 70}%</InfoRow>
|
||||
<InfoRow label="Max Shown per Attempt">
|
||||
{quiz.max_questions
|
||||
? `${quiz.max_questions} (random)`
|
||||
: `All (${questions.length})`}
|
||||
{quiz.max_questions ? `${quiz.max_questions} (random)` : `All (${questions.length})`}
|
||||
</InfoRow>
|
||||
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
|
||||
<InfoRow label="Total Points">{totalPoints}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Questions ── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
|
||||
Questions
|
||||
</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">Questions</p>
|
||||
{questions.length === 0 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
questions.map((q, i) => (
|
||||
<QuestionView key={q.question_id ?? i} question={q} index={i} />
|
||||
))
|
||||
questions.map((q, i) => <QuestionView key={q.question_id ?? i} question={q} index={i} />)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CompletionsTab completions={completions} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ function RequirementCard({ req }) {
|
||||
{req.link_url && (
|
||||
<MetaRow icon={Globe} label="URL">
|
||||
<a
|
||||
href={req.link_url}
|
||||
href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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,
|
||||
});
|
||||
|
||||
if (created) navigate(`/admin/tasks/${taskListId}/tasks/${created.task_id}`);
|
||||
if (created) navigate(`/admin/taskList/${taskListId}/tasks/${created.task_id}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -71,7 +71,7 @@ function RequirementCard({ req }) {
|
||||
{req.link_url && (
|
||||
<MetaRow icon={Globe} label="URL">
|
||||
<a
|
||||
href={req.link_url}
|
||||
href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity"
|
||||
|
||||
@@ -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} · 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
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ChevronLeft, ChevronRight,
|
||||
Circle, CheckCircle2,
|
||||
Square, CheckSquare2,
|
||||
Clock, AlertTriangle, Info,
|
||||
} from "lucide-react";
|
||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||
|
||||
function QuizSkeleton() {
|
||||
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:
|
||||
* 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
|
||||
* onSubmit — (answers) => Promise<result|null>
|
||||
* label — noun used in copy ("Quiz" or "Assessment"), default "Quiz"
|
||||
* onStart — async () => { session_id, expires_at, remaining_seconds, draft_answers } — only for timed assessments
|
||||
* 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 total = questions.length;
|
||||
|
||||
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
|
||||
// ── Core stage state ──────────────────────────────────────────────────────
|
||||
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState({});
|
||||
const [answers, setAnswers] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
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(() => {
|
||||
setStage("intro");
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
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]);
|
||||
|
||||
// ── 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) {
|
||||
return (
|
||||
<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 = () => {
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
setStage("intro");
|
||||
onRetake?.(); // refetch so attempts_remaining/cooldown_until reflect the submission that just happened
|
||||
};
|
||||
// ── Derived values ────────────────────────────────────────────────────────
|
||||
const isAssessment = label === "Assessment";
|
||||
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
|
||||
const activeSession = isAssessment ? (quiz?.active_session ?? null) : null; // pre-existing in_progress from server
|
||||
|
||||
// ── Intro screen ─────────────────────────────────────────────────────────
|
||||
if (stage === "intro") {
|
||||
const attempts = quiz.attempt_count ?? 0;
|
||||
const attemptsRemaining = quiz.attempts_remaining ?? null; // null = backend hasn't sent this field yet
|
||||
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 attempts = quiz.attempt_count ?? 0;
|
||||
const attemptsRemaining = quiz.attempts_remaining ?? null;
|
||||
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
|
||||
const canAttempt = quiz.can_attempt ?? true;
|
||||
|
||||
return (
|
||||
<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"}
|
||||
</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>
|
||||
|
||||
{quiz.has_passed && (
|
||||
@@ -130,20 +335,39 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
</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="space-y-0.5">
|
||||
<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>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">
|
||||
{attemptsRemaining !== null ? attemptsRemaining : attempts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
{(attemptsRemaining !== null || attempts > 0) && (
|
||||
<>
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">
|
||||
{attemptsRemaining !== null ? attemptsRemaining : attempts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<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>
|
||||
|
||||
<Button size="lg" className="w-full sm:w-auto" onClick={() => setStage("taking")} disabled={!canAttempt}>
|
||||
{attempts > 0 ? `Retake ${label}` : `Start ${label}`}
|
||||
<Button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,47 +421,57 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
}
|
||||
|
||||
// ── Question stepper ─────────────────────────────────────────────────────
|
||||
const question = questions[currentIndex];
|
||||
const isFirst = currentIndex === 0;
|
||||
const isLast = currentIndex === total - 1;
|
||||
const isMulti = question.type === "multi_select";
|
||||
const selected = answers[question.question_id];
|
||||
const progress = Math.round(((currentIndex + 1) / total) * 100);
|
||||
const question = questions[currentIndex];
|
||||
const isFirst = currentIndex === 0;
|
||||
const isLast = currentIndex === total - 1;
|
||||
const isMulti = question.type === "multi_select";
|
||||
const selected = answers[question.question_id];
|
||||
const progress = Math.round(((currentIndex + 1) / total) * 100);
|
||||
|
||||
const handleOptionClick = (optionId) => {
|
||||
setAnswers((prev) => {
|
||||
if (!isMulti) return { ...prev, [question.question_id]: optionId };
|
||||
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 multiLimit = isMulti ? (question.correct_count ?? null) : null;
|
||||
const selectedCount = isMulti ? (selected ?? []).length : 0;
|
||||
const limitReached = multiLimit !== null && selectedCount >= multiLimit;
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLast) {
|
||||
setSubmitting(true);
|
||||
const res = await onSubmit?.(answers);
|
||||
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));
|
||||
};
|
||||
// Timer color: red < 60s, amber < 5min, default otherwise
|
||||
const timerColor = remainingSeconds !== null
|
||||
? remainingSeconds <= 60
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: remainingSeconds <= 300
|
||||
? "text-amber-500 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{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">
|
||||
<span>Question {currentIndex + 1} of {total}</span>
|
||||
<span>{progress}%</span>
|
||||
@@ -236,16 +481,32 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
</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">
|
||||
<p className="font-bold text-base leading-relaxed sm:text-lg">
|
||||
{currentIndex + 1}. {question.question}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-base leading-relaxed sm:text-lg">
|
||||
{currentIndex + 1}. {question.question}
|
||||
</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">
|
||||
{(question.options ?? []).map((option, i) => {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
const isSelected = isMulti
|
||||
? (selected ?? []).includes(option.option_id)
|
||||
: selected === option.option_id;
|
||||
const isDisabled = (isMulti && limitReached && !isSelected) || (timeExpired && submitting);
|
||||
const Icon = isMulti
|
||||
? (isSelected ? CheckSquare2 : Square)
|
||||
: (isSelected ? CheckCircle2 : Circle);
|
||||
@@ -255,8 +516,10 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
key={option.option_id}
|
||||
type="button"
|
||||
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"}`} />
|
||||
<span className="font-medium text-muted-foreground">{letter}.</span>
|
||||
@@ -273,7 +536,7 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
Previous
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={submitting}>
|
||||
{submitting ? "Submitting..." : isLast ? "Submit" : "Next"}
|
||||
{submitting ? "Submitting…" : isLast ? "Submit" : "Next"}
|
||||
{!isLast && !submitting && <ChevronRight className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -281,4 +544,4 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizBlock;
|
||||
export default QuizBlock;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
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 { Progress } from "@/components/ui/progress";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
@@ -10,17 +10,28 @@ import { SendHorizonal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
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 navigate = useNavigate();
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
const [summaries, setSummaries] = useState({});
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
const [summaries, setSummaries] = useState({});
|
||||
const [locked, setLocked] = useState({});
|
||||
const [lockedInfo, setLockedInfo] = useState({});
|
||||
const [fetching, setFetching] = useState({});
|
||||
const prevCompletedRef = useRef({});
|
||||
|
||||
// Toast notification when a course requirement is auto turned-in
|
||||
useEffect(() => {
|
||||
courses.forEach((course) => {
|
||||
const prev = prevCompletedRef.current[course.id];
|
||||
@@ -34,6 +45,7 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
useEffect(() => {
|
||||
courses.forEach(async (course) => {
|
||||
if (!course.reference_id) return;
|
||||
setFetching((prev) => ({ ...prev, [course.reference_id]: true }));
|
||||
try {
|
||||
const res = await api.get(`/client/courses/uuid/${course.reference_id}`);
|
||||
const d = res.data?.data;
|
||||
@@ -44,17 +56,26 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
const summary = progRes.data?.data;
|
||||
if (summary) setSummaries((prev) => ({ ...prev, [course.reference_id]: summary }));
|
||||
} catch (err) {
|
||||
console.error('[ReadCourse] fetch failed:', err?.response?.status, err?.message);
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
setFetching((prev) => ({ ...prev, [course.reference_id]: false }));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reading percentage (lessons read / total) — independent of quiz / assessment completion
|
||||
const getReadingPercent = (course) => {
|
||||
const summary = summaries[course.reference_id];
|
||||
return summary ? summary.percent : (course.progress ?? 0);
|
||||
};
|
||||
|
||||
const hasLocked = Object.values(locked).some(Boolean);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
@@ -64,60 +85,113 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
<Badge variant="secondary" className="ml-auto">{courses.length}</Badge>
|
||||
</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">
|
||||
<div className="flex gap-4 p-4">
|
||||
{courses.map((course) => {
|
||||
const info = details[course.reference_id];
|
||||
const percent = getReadingPercent(course);
|
||||
// task requirement completed (auto turned-in) — quizzes + assessment also done
|
||||
const done = !!course.completed;
|
||||
// all lessons read but task not yet auto-turned-in (quiz/assessment still pending)
|
||||
const allRead = !done && percent >= 100;
|
||||
const info = details[course.reference_id];
|
||||
const courseInfo = lockedInfo[course.reference_id];
|
||||
const percent = getReadingPercent(course);
|
||||
const done = !!course.completed;
|
||||
const allRead = !done && percent >= 100;
|
||||
const isLocked = locked[course.reference_id];
|
||||
const isFetching = fetching[course.reference_id];
|
||||
|
||||
// ── Locked card ───────────────────────────────────────────────────
|
||||
if (isLocked) {
|
||||
return (
|
||||
<div
|
||||
key={course.id}
|
||||
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">
|
||||
<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">
|
||||
<TierBadge tier={courseInfo?.subscription} locked />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
|
||||
{course.title}
|
||||
</h1>
|
||||
<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={() => setSelected(course)}
|
||||
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"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{info ? (
|
||||
<>
|
||||
{info.subscription && (
|
||||
<Badge variant="secondary">
|
||||
<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}
|
||||
{/* 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>
|
||||
|
||||
<h1 className="text-lg font-medium leading-snug line-clamp-3 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">
|
||||
{info?.description ?? ''}
|
||||
</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">
|
||||
{course.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
{info?.description ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-auto pt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<div className="flex flex-col gap-2 mt-auto pt-1 border-t">
|
||||
<div className="flex items-center justify-between text-sm pt-1">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{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
|
||||
? <><CheckCheck className="size-4 text-amber-500" /> Lessons Done</>
|
||||
: <><RefreshCcw className="size-4 text-muted-foreground" /> In Progress</>
|
||||
? <><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" /> In Progress</>
|
||||
}
|
||||
</span>
|
||||
<span className="font-medium">{percent}%</span>
|
||||
<span className="font-semibold text-xs">{percent}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
@@ -146,9 +220,7 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
description={done ? "Course Summary" : "Course Info"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
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">
|
||||
{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">
|
||||
<CheckCheck className="size-4 shrink-0" />
|
||||
Automatically Turned-in
|
||||
<CheckCheck className="size-4 shrink-0" /> Automatically Turned-in
|
||||
</div>
|
||||
) : 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">
|
||||
<CheckCheck className="size-4 shrink-0" />
|
||||
Lessons complete — finish quizzes & assessment to turn in
|
||||
<CheckCheck className="size-4 shrink-0" /> Lessons complete — finish quizzes & assessment to turn in
|
||||
</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">
|
||||
<RefreshCcw className="size-4 shrink-0" />
|
||||
Currently in progress
|
||||
<RefreshCcw className="size-4 shrink-0" /> Currently in progress
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 divide-x rounded-lg border text-center text-sm">
|
||||
<div className="flex flex-col gap-1 py-4">
|
||||
<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 className="text-muted-foreground text-xs">Subscription</span>
|
||||
</div>
|
||||
@@ -194,9 +263,7 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
|
||||
About this course
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">About this course</p>
|
||||
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,63 +2,182 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 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(() => {
|
||||
lessons.forEach(async (lesson) => {
|
||||
if (!lesson.reference_id) return;
|
||||
setFetching((prev) => ({ ...prev, [lesson.reference_id]: true }));
|
||||
try {
|
||||
const res = await api.get(`/client/courses/lesson/uuid/${lesson.reference_id}`);
|
||||
const d = res.data?.data;
|
||||
if (d) setDetails((prev) => ({ ...prev, [lesson.reference_id]: d }));
|
||||
} 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 (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
<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>
|
||||
<Badge variant="secondary" className="ml-auto">{lessons.length}</Badge>
|
||||
</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">
|
||||
<div className="flex gap-4 p-4">
|
||||
{lessons.map((lesson) => {
|
||||
const info = details[lesson.reference_id];
|
||||
const progress = lesson.completed ? 100 : 0;
|
||||
const info = details[lesson.reference_id];
|
||||
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 (
|
||||
<div
|
||||
key={lesson.id}
|
||||
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={() => navigate(
|
||||
onClick={() => !isFetching && navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ 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">
|
||||
<Tag className="size-3" /> Lesson
|
||||
</Badge>
|
||||
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{info?.description ?? ''}
|
||||
</p>
|
||||
{/* Card type row */}
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-auto pt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
{/* 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}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
{info?.description ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-auto pt-1 border-t">
|
||||
<div className="flex items-center justify-between text-sm pt-1">
|
||||
{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
|
||||
</span>
|
||||
) : (
|
||||
@@ -66,7 +185,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
value={lesson.completed ? 100 : 0}
|
||||
className={`h-1.5 ${lesson.completed ? "[&>div]:bg-green-500" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
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 { Progress } from "@/components/ui/progress";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
@@ -8,20 +8,41 @@ import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 navigate = useNavigate();
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
const [locked, setLocked] = useState({});
|
||||
const [lockedInfo, setLockedInfo] = useState({});
|
||||
const [unavailable, setUnavailable] = useState({});
|
||||
const [fetching, setFetching] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
units.forEach(async (unit) => {
|
||||
if (!unit.reference_id) return;
|
||||
setFetching((prev) => ({ ...prev, [unit.reference_id]: true }));
|
||||
try {
|
||||
const res = await api.get(`/client/courses/unit/uuid/${unit.reference_id}`);
|
||||
const d = res.data?.data;
|
||||
if (d) setDetails((prev) => ({ ...prev, [unit.reference_id]: d }));
|
||||
} 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 (
|
||||
<>
|
||||
<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>
|
||||
<Badge variant="secondary" className="ml-auto">{units.length}</Badge>
|
||||
</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">
|
||||
<div className="flex gap-4 p-4">
|
||||
{units.map((unit) => {
|
||||
const info = details[unit.reference_id];
|
||||
const progress = getProgress(unit);
|
||||
const info = details[unit.reference_id];
|
||||
const courseInfo = lockedInfo[unit.reference_id];
|
||||
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 (
|
||||
<div
|
||||
key={unit.id}
|
||||
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">
|
||||
<Layers className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground font-medium">Unit</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">
|
||||
{unit.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 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={() => setSelected(unit)}
|
||||
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"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge variant="secondary" className="w-fit truncate max-w-full">
|
||||
<Tag className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{info ? (info.course?.title ?? 'No course') : 'Loading…'}
|
||||
</span>
|
||||
</Badge>
|
||||
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors">
|
||||
{unit.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{info?.description ?? ''}
|
||||
</p>
|
||||
{/* 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 className="flex flex-col gap-3 mt-auto pt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<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 ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-auto pt-1 border-t">
|
||||
<div className="flex items-center justify-between text-sm pt-1">
|
||||
{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
|
||||
</span>
|
||||
) : 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
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground font-medium">Not Started</span>
|
||||
)}
|
||||
{progress > 0 && <span className="text-xs font-semibold">{progress}%</span>}
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
@@ -114,7 +224,11 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { unit: selected } },
|
||||
)}
|
||||
disabled={getProgress(selected ?? {}) >= 100}
|
||||
disabled={
|
||||
getProgress(selected ?? {}) >= 100 ||
|
||||
locked[selected?.reference_id] ||
|
||||
!details[selected?.reference_id]
|
||||
}
|
||||
>
|
||||
<SendHorizonal /> Proceed
|
||||
</Button>
|
||||
@@ -130,22 +244,17 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
||||
<div className="flex flex-col gap-4">
|
||||
{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">
|
||||
<CheckCheck className="size-4 shrink-0" />
|
||||
Automatically Turned-in
|
||||
<CheckCheck className="size-4 shrink-0" /> Automatically Turned-in
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info?.course?.title && (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Course: </span>
|
||||
<span className="font-medium">{info.course.title}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
||||
About this unit
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">About this unit</p>
|
||||
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,13 @@ import { useEffect, useState } from "react";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
const fetchLinkMeta = async (url) => {
|
||||
try {
|
||||
@@ -113,7 +120,7 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
<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" />
|
||||
Open Link
|
||||
</a>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon, BadgeCheck,
|
||||
SendHorizonal, CheckCheck,
|
||||
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
||||
SendHorizonal, CheckCheck, CheckCircle2, Clock,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,8 +13,12 @@ import { useScrollTrigger } from "../hooks/ScrollTrigger";
|
||||
import {
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -31,17 +35,17 @@ function formatDuration(seconds = 0) {
|
||||
// ─── Certificate badge icon ────────────────────────────────────────────────────
|
||||
|
||||
const CertBadgeIcon = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
<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">
|
||||
<defs>
|
||||
<linearGradient id="cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#8B9FEE" />
|
||||
<stop offset="1" stopColor="#4F6FD4" />
|
||||
<linearGradient id="prism-cd" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#8EA2F6"/>
|
||||
<stop offset="1" stopColor="#5061E6"/>
|
||||
</linearGradient>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -79,7 +83,7 @@ const useVisibleNodes = (refs, count) => {
|
||||
|
||||
// ─── Unit Accordion Block ─────────────────────────────────────────────────────
|
||||
|
||||
const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle }) => {
|
||||
const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
@@ -93,7 +97,7 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle }
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={i === 0 ? `unit-${unit.unit_id}` : undefined}
|
||||
defaultValue={`unit-${unit.unit_id}`}
|
||||
onValueChange={() => setTimeout(onToggle, 250)}
|
||||
>
|
||||
<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 } })}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{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) ─────────────────────────────────────────────
|
||||
|
||||
const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle }) => {
|
||||
const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle, isCompleted, pendingCert, certificate }) => {
|
||||
const wrapRef = useRef(null);
|
||||
const cardRefs = useRef([]);
|
||||
// +1 for the certificate card at the end
|
||||
@@ -246,33 +352,19 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle
|
||||
cardRefs={cardRefs}
|
||||
courseId={courseId}
|
||||
onToggle={measure}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Certificate badge — final node, always present */}
|
||||
<motion.div
|
||||
ref={(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"
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, delay: units.length * 0.05, 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">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>
|
||||
<CertCard
|
||||
nodeRef={(el) => (cardRefs.current[units.length] = el)}
|
||||
delay={units.length * 0.05}
|
||||
courseTitle={courseTitle}
|
||||
courseLevel={courseLevel}
|
||||
pendingCert={pendingCert}
|
||||
certificate={certificate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -288,6 +380,7 @@ const CourseDetails = () => {
|
||||
|
||||
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
|
||||
const { myTier, getMyTier } = useClientTiers();
|
||||
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
|
||||
|
||||
const hasCompleted = !!course?.is_completed;
|
||||
|
||||
@@ -298,7 +391,8 @@ const CourseDetails = () => {
|
||||
useEffect(() => {
|
||||
getMyTier();
|
||||
getCourse(courseId);
|
||||
return () => resetCourse();
|
||||
fetchCourseProgress(courseId);
|
||||
return () => { resetCourse(); resetProgress(); };
|
||||
}, [courseId]);
|
||||
|
||||
if (courseBlocked) {
|
||||
@@ -431,7 +525,15 @@ const CourseDetails = () => {
|
||||
{course?.units?.length > 0 && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -214,16 +214,21 @@ const CoursesList = () => {
|
||||
if (!myTier) getMyTier();
|
||||
}, []);
|
||||
|
||||
// ── Filter ────────────────────────────────────────────────────────────────
|
||||
// ── Filter + sort ─────────────────────────────────────────────────────────
|
||||
|
||||
const filtered = courses.filter((c) => {
|
||||
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(c.description ?? "").toLowerCase().includes(search.toLowerCase());
|
||||
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
|
||||
const matchSub = subFilter === "All" || c.subscription === subFilter.toLowerCase();
|
||||
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
|
||||
return matchSearch && matchLevel && matchSub && matchCategory;
|
||||
});
|
||||
const filtered = useMemo(() =>
|
||||
courses
|
||||
.filter((c) => {
|
||||
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(c.description ?? "").toLowerCase().includes(search.toLowerCase());
|
||||
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
|
||||
const matchSub = subFilter === "All" || c.subscription === subFilter.toLowerCase();
|
||||
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
|
||||
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 paginated = filtered.slice(
|
||||
|
||||
@@ -9,17 +9,17 @@ import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const CertBadgeIcon = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
<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">
|
||||
<defs>
|
||||
<linearGradient id="cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#8B9FEE" />
|
||||
<stop offset="1" stopColor="#4F6FD4" />
|
||||
<linearGradient id="prism-mc" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#8EA2F6"/>
|
||||
<stop offset="1" stopColor="#5061E6"/>
|
||||
</linearGradient>
|
||||
</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>
|
||||
);
|
||||
|
||||
|
||||
@@ -68,17 +68,17 @@ const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
|
||||
// ─── Certificate landscape card (profile preview) ────────────────────────────
|
||||
|
||||
const CertBadgeIcon = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
<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">
|
||||
<defs>
|
||||
<linearGradient id="cert-grad-p" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#8B9FEE" />
|
||||
<stop offset="1" stopColor="#4F6FD4" />
|
||||
<linearGradient id="prism-p" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#8EA2F6"/>
|
||||
<stop offset="1" stopColor="#5061E6"/>
|
||||
</linearGradient>
|
||||
</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>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle } from "lucide-react";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import {
|
||||
@@ -232,10 +232,10 @@ const UnitList = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
course, courseLoading, getCourse,
|
||||
course, courseLoading, courseBlocked, getCourse,
|
||||
lesson, lessonLoading, getLesson, resetLesson,
|
||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz,
|
||||
assessment, assessmentLoading, getCourseAssessment, resetAssessment, submitCourseAssessment,
|
||||
assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment,
|
||||
} = useClientCourses();
|
||||
|
||||
const {
|
||||
@@ -483,6 +483,29 @@ const UnitList = () => {
|
||||
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 (
|
||||
<>
|
||||
<PageMeta title={pageTitle} />
|
||||
@@ -614,8 +637,11 @@ const UnitList = () => {
|
||||
quiz={assessment ? { ...assessment, quiz_id: assessment.assessment_id } : null}
|
||||
loading={assessmentLoading}
|
||||
label="Assessment"
|
||||
onSubmit={async (answers) => {
|
||||
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers);
|
||||
onStart={() => startCourseAssessment(courseId, assessment.assessment_id)}
|
||||
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);
|
||||
return result;
|
||||
}}
|
||||
|
||||
@@ -12,6 +12,7 @@ import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||
import {
|
||||
House, TableOfContents, CheckCheck, Circle,
|
||||
BookOpen, Layers, FileText, ArrowLeft, ChevronDown, ChevronRight,
|
||||
Lock, Zap, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -160,16 +161,20 @@ const SidebarContent = ({
|
||||
const CourseView = ({ req }) => {
|
||||
const [info, setInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!req.reference_id) { setLoading(false); return; }
|
||||
api.get(`/client/courses/uuid/${req.reference_id}`)
|
||||
.then((r) => setInfo(r.data?.data ?? null))
|
||||
.catch(() => {})
|
||||
.catch((err) => {
|
||||
if (err?.response?.status === 403) setLocked(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [req.reference_id]);
|
||||
|
||||
if (loading) return <ContentSkeleton />;
|
||||
if (locked) return <LockedContent />;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -190,6 +195,7 @@ const CourseView = ({ req }) => {
|
||||
const LessonView = ({ req }) => {
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!req.reference_id) { setLoading(false); return; }
|
||||
@@ -198,11 +204,14 @@ const LessonView = ({ req }) => {
|
||||
const d = r.data?.data;
|
||||
if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch((err) => {
|
||||
if (err?.response?.status === 403) setLocked(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [req.reference_id]);
|
||||
|
||||
if (loading) return <ContentSkeleton />;
|
||||
if (locked) return <LockedContent />;
|
||||
return <LessonBlock lesson={lesson} loading={false} />;
|
||||
};
|
||||
|
||||
@@ -216,6 +225,32 @@ const ContentSkeleton = () => (
|
||||
</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 ────────────────────────────────────────────────────────────────
|
||||
const ViewRequirement = () => {
|
||||
const { groupId, taskListId, taskId } = useParams();
|
||||
@@ -231,10 +266,12 @@ const ViewRequirement = () => {
|
||||
const [selection, setSelection] = useState(null);
|
||||
const [unitLessonsMap, setUnitLessonsMap] = useState({});
|
||||
const [unitLoadingMap, setUnitLoadingMap] = useState({});
|
||||
const [lockedReqs, setLockedReqs] = useState(new Set());
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopOpen, setDesktopOpen] = useState(true);
|
||||
const [scrollPct, setScrollPct] = useState(0);
|
||||
const initialised = useRef(false);
|
||||
const initialised = useRef(false);
|
||||
const lastAutoMarkRef = useRef(null);
|
||||
|
||||
// ── Fetch task + progress ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -260,7 +297,11 @@ const ViewRequirement = () => {
|
||||
const lessons = 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 })));
|
||||
});
|
||||
}, [requirements.length]);
|
||||
@@ -393,6 +434,17 @@ const ViewRequirement = () => {
|
||||
const isLastContent = selectedReq?.type !== 'read_unit' || !nextLesson;
|
||||
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 = {
|
||||
requirements,
|
||||
selection,
|
||||
@@ -502,54 +554,49 @@ const ViewRequirement = () => {
|
||||
)}
|
||||
|
||||
{selectedReq.type === 'read_unit' && (
|
||||
selectedLesson
|
||||
? <LessonBlock lesson={selectedLesson} loading={false} />
|
||||
: <ContentSkeleton />
|
||||
lockedReqs.has(selectedReq.requirement_id)
|
||||
? <LockedContent />
|
||||
: selectedLesson
|
||||
? <LessonBlock lesson={selectedLesson} loading={false} />
|
||||
: <ContentSkeleton />
|
||||
)}
|
||||
|
||||
{selectedReq.type === 'read_lesson' && (
|
||||
<LessonView req={selectedReq} />
|
||||
)}
|
||||
|
||||
{/* Mark done footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
{selectedDone ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You have completed this requirement.
|
||||
{/* Turn-in footer — hidden for locked requirements */}
|
||||
{!lockedReqs.has(selectedReq.requirement_id) && (
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
{selectedDone ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You have completed this requirement.
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleMarkDone}
|
||||
disabled={progressLoading}
|
||||
variant="outline"
|
||||
>
|
||||
<CheckCheck className="size-4" />
|
||||
Mark as not done
|
||||
</Button>
|
||||
</>
|
||||
) : nextLesson ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Continue reading all lessons to complete this requirement.
|
||||
</p>
|
||||
) : !canMarkDone ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scroll to the end to complete this requirement.
|
||||
</p>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<RefreshCw className="size-3.5 animate-spin" /> Turning in…
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleMarkDone}
|
||||
disabled={progressLoading}
|
||||
variant="outline"
|
||||
>
|
||||
<CheckCheck className="size-4" />
|
||||
Mark as not done
|
||||
</Button>
|
||||
</>
|
||||
) : nextLesson ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Continue reading all lessons to complete this requirement.
|
||||
</p>
|
||||
) : !canMarkDone ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scroll to the end of this lesson to mark as done.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Mark this requirement as done when finished.
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleMarkDone}
|
||||
disabled={progressLoading}
|
||||
>
|
||||
<CheckCheck className="size-4" />
|
||||
Mark as done
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import ProtectedRoute from '../../../routes/ProtectedRoute'
|
||||
import Client from '../pages/Dashboard'
|
||||
import ClientLayout from '../layout/ClientLayout'
|
||||
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 CourseDetails from '../pages/CourseDetails'
|
||||
import UnitList from '../pages/UnitList'
|
||||
@@ -20,18 +20,27 @@ import CourseCheckout from '../pages/CourseCheckout'
|
||||
import MyCertificates from '../pages/MyCertificates'
|
||||
import MyAchievements from '../pages/MyAchievements'
|
||||
import AccountSettings from '../pages/AccountSettings'
|
||||
import IntroPage from '@/modules/auth/pages/Intro'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
const ClientWrapper = () => (
|
||||
<Fragment>
|
||||
{/* Wrapper components belong here. */}
|
||||
<ScrollToTop />
|
||||
<ClientLayout />
|
||||
</Fragment>
|
||||
);
|
||||
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>
|
||||
{/* Wrapper components belong here. */}
|
||||
<ScrollToTop />
|
||||
<ClientLayout />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClientRoutes = {
|
||||
element: <ProtectedRoute allowedRoles={['user']} />,
|
||||
children: [
|
||||
// Intro — full-screen, outside ClientLayout, no nav
|
||||
{ path: 'intro', element: <IntroPage /> },
|
||||
{
|
||||
element: <ClientWrapper />,
|
||||
children: [
|
||||
|
||||
@@ -10,9 +10,9 @@ export default function PublicRoute() {
|
||||
if (user) {
|
||||
switch (user.acc_type) {
|
||||
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 />
|
||||
default: return <Navigate to="/dashboard" replace />
|
||||
default: return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user