416 lines
19 KiB
TypeScript
416 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { fetchWithAuth } from "@/lib/apiClient";
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-react";
|
|
import { useActiveUser } from "@/hooks/useActiveUser";
|
|
import { PremiumGate } from "@/components/PremiumGate";
|
|
|
|
import { useMemo } from "react";
|
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter";
|
|
|
|
interface TestData {
|
|
id: string;
|
|
title: string;
|
|
type: string;
|
|
exam_pattern: string;
|
|
total_time: number;
|
|
difficulty: string;
|
|
scheduled_date?: string;
|
|
end_date?: string;
|
|
}
|
|
|
|
export default function CBTPage() {
|
|
const [exams, setExams] = useState<TestData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [targetExam, setTargetExam] = useState("");
|
|
const [attempts, setAttempts] = useState<any[]>([]);
|
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
|
const [showAllExams, setShowAllExams] = useState(false);
|
|
const [selectedPattern, setSelectedPattern] = useState<string>("");
|
|
const [dateFilter, setDateFilter] = useState("");
|
|
|
|
const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser();
|
|
|
|
useEffect(() => {
|
|
if (userLoading || !userId) return;
|
|
|
|
async function loadUser() {
|
|
try {
|
|
const userRes = await fetchWithAuth(`/api/users/${userId}`);
|
|
if (!userRes.ok) throw new Error("Failed to load user target exam");
|
|
const userData = await userRes.json();
|
|
const pattern = userData.target_exam;
|
|
setTargetExam(pattern === "JEE_NEET" ? "JEE & NEET" : pattern === "NEET" ? "NEET" : pattern === "JEE_Advanced" ? "JEE Advanced" : "JEE");
|
|
|
|
// Only set the initial pattern if it hasn't been changed by the user
|
|
if (!selectedPattern) {
|
|
setSelectedPattern(pattern === "JEE_NEET" ? "JEE_NEET" : pattern.startsWith("JEE") ? "JEE" : "NEET");
|
|
}
|
|
} catch (err) {
|
|
console.error("Error loading user:", err);
|
|
}
|
|
}
|
|
loadUser();
|
|
}, [userId, userLoading]);
|
|
|
|
useEffect(() => {
|
|
if (!userId || !selectedPattern) return;
|
|
|
|
async function loadTests() {
|
|
setLoading(true);
|
|
try {
|
|
let url = `/api/tests?user_id=${userId}&type=Mock`;
|
|
if (selectedPattern) {
|
|
url += `&exam_pattern=${selectedPattern}`;
|
|
}
|
|
const testRes = await fetchWithAuth(url);
|
|
if (testRes.ok) {
|
|
let testsData = await testRes.json();
|
|
if (selectedPattern === "JEE") {
|
|
testsData = testsData.filter((t: any) => t.exam_pattern?.includes("JEE"));
|
|
} else if (selectedPattern === "NEET") {
|
|
testsData = testsData.filter((t: any) => t.exam_pattern === "NEET" || t.exam_pattern === "JEE_NEET");
|
|
}
|
|
setExams(testsData);
|
|
}
|
|
|
|
const attemptsRes = await fetchWithAuth(`/api/users/${userId}/attempts`);
|
|
if (attemptsRes.ok) {
|
|
const attemptsData = await attemptsRes.json();
|
|
setAttempts(attemptsData);
|
|
}
|
|
} catch (err) {
|
|
console.error("Error loading exams:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
loadTests();
|
|
}, [userId, selectedPattern]);
|
|
|
|
const scheduledDates = useMemo(() => {
|
|
return exams
|
|
.filter((e) => !!e.scheduled_date)
|
|
.map((e) => getLocalDateString(e.scheduled_date))
|
|
.filter(Boolean)
|
|
}, [exams]);
|
|
|
|
const filteredExams = exams.filter(exam => {
|
|
const matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase());
|
|
if (!matchesSearch) return false;
|
|
|
|
if (dateFilter) {
|
|
if (!exam.scheduled_date) return false;
|
|
const scheduledDate = getLocalDateString(exam.scheduled_date);
|
|
if (scheduledDate !== dateFilter) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
const formatDateTime = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleString("en-US", {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
}
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
}
|
|
|
|
const formatTime = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleTimeString("en-US", {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
}
|
|
|
|
// Get the highest score for each test if attempted multiple times
|
|
const attemptsByTest = attempts.reduce((acc, curr) => {
|
|
// Isolate analytics to first attempt only - ignore retakes
|
|
if (curr.is_retake) return acc;
|
|
|
|
if (!acc[curr.source_id] || acc[curr.source_id].score < curr.score) {
|
|
acc[curr.source_id] = curr;
|
|
}
|
|
return acc;
|
|
}, {} as Record<string, any>);
|
|
|
|
if (userLoading || loading) {
|
|
return (
|
|
<div className="h-[70vh] flex flex-col items-center justify-center p-8">
|
|
<div className="h-8 w-8 border-4 border-[var(--cyan-ai)] border-t-transparent rounded-full animate-spin mb-4"></div>
|
|
<p className="text-muted-foreground font-medium animate-pulse">Loading exams...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
|
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
|
<CheckSquare className="text-yellow-600 dark:text-[var(--cyan-ai)]" size={28} />
|
|
</span>
|
|
Mock Tests (CBT)
|
|
</h1>
|
|
<p className="text-muted-foreground text-base font-medium max-w-2xl pl-1">
|
|
Real exam environment simulations to conquer {selectedPattern === "NEET" ? "NEET" : (targetExam || "JEE")}.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row items-center gap-3">
|
|
<CalendarFilter
|
|
selectedDate={dateFilter}
|
|
onSelectDate={setDateFilter}
|
|
availableDates={scheduledDates}
|
|
placeholder="Filter by Exam Date"
|
|
/>
|
|
<div className="relative w-full sm:w-64">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search exams..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="space-y-6">
|
|
<h2 className="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
|
<Clock size={20} className="text-[var(--cyan-ai)]" /> Upcoming & Active Exams
|
|
</h2>
|
|
|
|
{/* BUG-08 fix: Filter out already attempted exams from Upcoming & Active list */}
|
|
{(() => {
|
|
const unattemptedExams = filteredExams.filter(exam => !attemptsByTest[exam.id]);
|
|
|
|
if (loading) {
|
|
return <div className="py-12 text-center text-muted-foreground">Loading test catalog...</div>;
|
|
}
|
|
|
|
if (unattemptedExams.length === 0) {
|
|
return (
|
|
<div className="py-8 text-center text-muted-foreground bg-secondary/10 border border-dashed border-border rounded-2xl p-6">
|
|
{filteredExams.length > 0 ? (
|
|
<>
|
|
<p className="font-bold text-foreground mb-1">All available exams in this section have been attempted!</p>
|
|
<p className="text-xs text-muted-foreground">Review your scores, solution explanations, and performance in the Past Attempts section below.</p>
|
|
</>
|
|
) : (
|
|
<p>No active exams found for {selectedPattern === "NEET" ? "NEET" : "JEE"}. Try adjusting your filters!</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{(showAllExams ? unattemptedExams : unattemptedExams.slice(0, 3)).map((exam) => {
|
|
const isLocked = !hasAccessTo("weekly-test-series");
|
|
|
|
// Date logic
|
|
const now = new Date();
|
|
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now;
|
|
const endTime = exam.end_date ? new Date(exam.end_date) : null;
|
|
const isUpcoming = scheduledTime > now;
|
|
const isExpired = endTime ? now > endTime : false;
|
|
|
|
return (
|
|
<Card key={exam.id} className={`hover:border-[var(--cyan-ai-10)] hover:shadow-[0_0_15px_-5px_var(--cyan-ai)] transition-all group ${isLocked || isExpired ? 'opacity-80' : ''}`}>
|
|
<CardContent className="p-6">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="flex gap-2 flex-wrap">
|
|
<Badge variant="outline" className="bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] border-[var(--cyan-ai-20)]">
|
|
{exam.type} Test
|
|
</Badge>
|
|
{exam.exam_pattern && (
|
|
<Badge variant="outline" className="bg-primary/20 text-primary border-primary/30 uppercase tracking-wider text-[10px]">
|
|
{exam.exam_pattern === 'JEE_Main' ? 'JEE Main' : exam.exam_pattern === 'JEE_Advanced' ? 'JEE Advanced' : exam.exam_pattern}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{isUpcoming ? (
|
|
<Badge variant="secondary" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
|
Upcoming
|
|
</Badge>
|
|
) : isLocked ? (
|
|
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1">
|
|
<Lock size={12} /> Premium
|
|
</Badge>
|
|
) : isExpired ? (
|
|
<Badge variant="outline" className="bg-muted text-muted-foreground border-muted-foreground/30">
|
|
Expired
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<h3 className="font-bold text-lg leading-tight mb-2 group-hover:text-[var(--cyan-ai)] transition-colors">
|
|
{exam.title}
|
|
</h3>
|
|
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
|
<div className="font-medium text-foreground">
|
|
{isUpcoming && exam.scheduled_date ? (
|
|
<span className="text-amber-500 font-semibold">
|
|
Opens: {formatDateTime(exam.scheduled_date)}
|
|
</span>
|
|
) : isExpired && exam.end_date ? (
|
|
<span className="text-destructive font-semibold">
|
|
Expired on {formatDateTime(exam.end_date)}
|
|
</span>
|
|
) : exam.end_date ? (
|
|
<span className="text-emerald-500 font-semibold">
|
|
Live Now • Closes {formatDateTime(exam.end_date)}
|
|
</span>
|
|
) : exam.scheduled_date ? (
|
|
<span className="text-emerald-500 font-semibold">
|
|
Available since {formatDateTime(exam.scheduled_date)}
|
|
</span>
|
|
) : (
|
|
<span className="text-emerald-500 font-semibold">Available Now</span>
|
|
)}
|
|
</div>
|
|
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
|
</div>
|
|
|
|
{isUpcoming ? (
|
|
<Button disabled className="w-full gap-2 rounded-xl bg-amber-500/10 text-amber-500 border border-amber-500/30 font-bold">
|
|
<Clock size={16} /> Opens at {exam.scheduled_date ? formatTime(exam.scheduled_date) : "Scheduled Time"}
|
|
</Button>
|
|
) : isLocked ? (
|
|
<Button
|
|
onClick={() => setShowUpgradeModal(true)}
|
|
className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground border border-dashed border-border hover:border-[var(--cyan-ai-10)] hover:text-foreground font-bold"
|
|
>
|
|
<Lock size={16} /> Unlock Test
|
|
</Button>
|
|
) : isExpired ? (
|
|
<Button disabled className="w-full gap-2 rounded-xl bg-secondary/50 text-muted-foreground border border-border font-bold">
|
|
Expired
|
|
</Button>
|
|
) : (
|
|
<Link href={`/cbt/${exam.id}`}>
|
|
<Button className="w-full gap-2 rounded-xl bg-secondary text-foreground hover:bg-[var(--cyan-ai)] hover:text-background font-bold transition-colors">
|
|
<PlayCircle size={16} /> Start Exam
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)})}
|
|
</div>
|
|
|
|
{unattemptedExams.length > 3 && !showAllExams && (
|
|
<div className="flex justify-center mt-6">
|
|
<Button variant="outline" onClick={() => setShowAllExams(true)} className="rounded-xl border-dashed">
|
|
Show More Available Exams ({unattemptedExams.length - 3})
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
<div className="space-y-6 pt-8">
|
|
<h2 className="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
|
<Activity size={20} className="text-muted-foreground" /> Past Attempts
|
|
</h2>
|
|
<div className="space-y-4">
|
|
{attempts.filter(a => a.source_type === "Test" && exams.some(e => e.id === a.source_id)).length === 0 ? (
|
|
<div className="py-8 text-center text-muted-foreground bg-secondary/10 border border-dashed border-border rounded-2xl">
|
|
You haven't attempted any CBT exams yet.
|
|
</div>
|
|
) : attempts.filter(a => a.source_type === "Test" && exams.some(e => e.id === a.source_id)).map((attempt, i) => {
|
|
const exam = exams.find(e => e.id === attempt.source_id);
|
|
// BUG-07 fix: use actual final_score from backend if available, else derive correctly
|
|
// backend stores final_score = (correct*4) - (incorrect*1)
|
|
const correctCount = attempt.answers ? attempt.answers.filter((a: any) => a.is_correct).length : attempt.score;
|
|
const incorrectCount = attempt.answers ? (attempt.answers.filter((a: any) => a.selected_answer !== null).length - correctCount) : 0;
|
|
const scoreCalc = attempt.final_score ?? ((correctCount * 4) - (incorrectCount * 1));
|
|
const maxScore = attempt.total_questions * 4;
|
|
const scorePercent = maxScore > 0 ? (scoreCalc / maxScore) * 100 : 0;
|
|
const scoreColor = scorePercent >= 60 ? 'text-green-500' : scorePercent >= 35 ? 'text-amber-500' : 'text-red-500';
|
|
|
|
return (
|
|
<Card key={i} className="bg-secondary/20">
|
|
<CardContent className="p-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
|
<div className="w-full md:w-auto">
|
|
<h3 className="font-bold">{exam?.title || 'Unknown Test'}</h3>
|
|
<p className="text-sm text-muted-foreground font-medium">{formatDate(attempt.submitted_at)}</p>
|
|
</div>
|
|
<div className="flex items-center gap-4 md:gap-8 w-full md:w-auto justify-between md:justify-end">
|
|
<div className="text-right">
|
|
<div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Score</div>
|
|
<div className="font-bold text-base md:text-lg">
|
|
<span className={scoreColor}>{scoreCalc}</span>
|
|
<span className="text-muted-foreground">/{maxScore}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Accuracy</div>
|
|
<div className="font-bold text-base md:text-lg text-[var(--cyan-ai)]">{Math.round(attempt.accuracy)}%</div>
|
|
</div>
|
|
<Link href={`/dashboard/cbt/history/${attempt.id}`}>
|
|
<Button variant="outline" className="rounded-xl border-border hover:bg-primary hover:text-primary-foreground">
|
|
View Solutions
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)})}
|
|
</div>
|
|
</div>
|
|
|
|
{showUpgradeModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
|
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[450px] p-8 text-center space-y-6">
|
|
<div className="size-16 rounded-full bg-red-500/10 flex items-center justify-center mx-auto text-red-500">
|
|
<Lock size={32} />
|
|
</div>
|
|
<h3 className="text-2xl font-bold">Upgrade to Premium Plan</h3>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
This CBT Mock Test is a premium feature. Free users get access to 1 Daily Practice Sheet and 5 Topic-wise Micro-tests. Upgrade to Pro or AI Coach to unlock unlimited practice and full-length weekly test series!
|
|
</p>
|
|
<div className="flex gap-4 pt-2">
|
|
<Button variant="outline" className="flex-1 rounded-xl" onClick={() => setShowUpgradeModal(false)}>
|
|
Maybe Later
|
|
</Button>
|
|
<Link href="/upgrade" className="flex-1">
|
|
<Button className="w-full rounded-xl bg-primary text-primary-foreground font-bold shadow-[0_0_15px_-3px_var(--color-primary)]">
|
|
View Plans
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|