perform test #1

test to courses

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-24 12:49:20 +08:00
parent fbef7cb6e6
commit 93c3c688ca
26 changed files with 2141 additions and 452 deletions
@@ -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 &amp; assessment to turn in
<CheckCheck className="size-4 shrink-0" /> Lessons complete — finish quizzes &amp; 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>
+141 -32
View File
@@ -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>
+141 -39
View File
@@ -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>
+14 -9
View File
@@ -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(
+8 -8
View File
@@ -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>
);
+8 -8
View File
@@ -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>
);
+31 -5
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle } 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;
}}
+92 -45
View File
@@ -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>
+17 -8
View File
@@ -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: [