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>