feat: complete UI tasks, backend optimizations, coupon admin, user kundli

This commit is contained in:
2026-07-10 12:36:56 +05:30
parent 149d365218
commit d5199682e3
16 changed files with 782 additions and 78 deletions

View File

@@ -147,6 +147,14 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
attempt_id_str = payload.get("id") attempt_id_str = payload.get("id")
import uuid as _uuid import uuid as _uuid
attempt_id = _uuid.UUID(attempt_id_str) if attempt_id_str else _uuid.uuid4() attempt_id = _uuid.UUID(attempt_id_str) if attempt_id_str else _uuid.uuid4()
# Check if this is a retake
existing_attempts_query = select(models.Attempt).where(
models.Attempt.user_id == user_id,
models.Attempt.source_id == source_id
)
existing_attempts = session.exec(existing_attempts_query).all()
is_retake = len(existing_attempts) > 0
attempt_record = models.Attempt( attempt_record = models.Attempt(
id=attempt_id, id=attempt_id,
@@ -160,7 +168,8 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
score=0, score=0,
total_questions=total_questions, total_questions=total_questions,
accuracy=0.0, accuracy=0.0,
status="Submitted" status="Submitted",
is_retake=is_retake
) )
session.add(attempt_record) session.add(attempt_record)
session.flush() session.flush()
@@ -522,7 +531,8 @@ async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), sessio
contents = await file.read() contents = await file.read()
file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg" file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
object_name = f"avatar_{user_id}.{file_extension}" current_year = datetime.utcnow().year
object_name = f"avatars/{current_year}/avatar_{user_id}.{file_extension}"
public_url = upload_file(contents, object_name, content_type=file.content_type) public_url = upload_file(contents, object_name, content_type=file.content_type)
if not public_url: if not public_url:
@@ -579,7 +589,8 @@ async def upload_admin_test(
from worker import process_pdf_test from worker import process_pdf_test
pdf_bytes = await file.read() pdf_bytes = await file.read()
object_name = f"tests/{uuid.uuid4()}_{file.filename}" current_year = datetime.utcnow().year
object_name = f"tests/{current_year}/{uuid.uuid4()}_{file.filename}"
url = storage.upload_file(pdf_bytes, object_name, content_type="application/pdf") url = storage.upload_file(pdf_bytes, object_name, content_type="application/pdf")
if not url: if not url:
raise HTTPException(status_code=500, detail="Failed to upload PDF to MinIO") raise HTTPException(status_code=500, detail="Failed to upload PDF to MinIO")
@@ -739,4 +750,58 @@ def delete_test(test_id: UUID, session: Session = Depends(get_session)):
# 7. Delete the Test itself # 7. Delete the Test itself
session.delete(test_obj) session.delete(test_obj)
session.commit() session.commit()
return {"status": "success", "message": "Test and all associated questions, attempts, and mistakes deleted successfully"} return {"status": "success", "message": "Test and all associated questions, attempts, and mistakes deleted successfully"}
# --- COUPON CRUD ROUTES ---
class CouponCreate(BaseModel):
code: str
discount_percent: float
max_discount_amount: float
is_active: bool = True
expires_at: Optional[datetime] = None
class CouponUpdate(BaseModel):
code: Optional[str] = None
discount_percent: Optional[float] = None
max_discount_amount: Optional[float] = None
is_active: Optional[bool] = None
expires_at: Optional[datetime] = None
@app.get("/api/admin/coupons", tags=["Admin"])
def get_admin_coupons(session: Session = Depends(get_session)):
coupons = session.exec(select(models.Coupon)).all()
return coupons
@app.post("/api/admin/coupons", tags=["Admin"])
def create_coupon(payload: CouponCreate, session: Session = Depends(get_session)):
coupon = models.Coupon(**payload.dict())
session.add(coupon)
session.commit()
session.refresh(coupon)
return coupon
@app.put("/api/admin/coupons/{coupon_id}", tags=["Admin"])
def update_coupon(coupon_id: UUID, payload: CouponUpdate, session: Session = Depends(get_session)):
coupon = session.get(models.Coupon, coupon_id)
if not coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
update_data = payload.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(coupon, key, value)
session.add(coupon)
session.commit()
session.refresh(coupon)
return coupon
@app.delete("/api/admin/coupons/{coupon_id}", tags=["Admin"])
def delete_coupon(coupon_id: UUID, session: Session = Depends(get_session)):
coupon = session.get(models.Coupon, coupon_id)
if not coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
session.delete(coupon)
session.commit()
return {"status": "success", "message": "Coupon deleted successfully"}

View File

@@ -119,6 +119,23 @@ class Test(SQLModel, table=True):
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
created_by: UUID created_by: UUID
class Transaction(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
amount: float
tier: str
status: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class Coupon(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
code: str = Field(unique=True, index=True)
discount_percent: float
max_discount_amount: float
is_active: bool = Field(default=True)
expires_at: Optional[datetime] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
class Attempt(SQLModel, table=True): class Attempt(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id") user_id: UUID = Field(foreign_key="user.id")
@@ -132,6 +149,7 @@ class Attempt(SQLModel, table=True):
total_questions: int total_questions: int
accuracy: float = Field(default=0.0) accuracy: float = Field(default=0.0)
status: str = Field(default="In_Progress") # In_Progress, Submitted, Timed_Out status: str = Field(default="In_Progress") # In_Progress, Submitted, Timed_Out
is_retake: bool = Field(default=False)
class Mistake(SQLModel, table=True): class Mistake(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)

BIN
backend_logs.txt Normal file

Binary file not shown.

BIN
full_logs.txt Normal file

Binary file not shown.

View File

@@ -0,0 +1,155 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Loader2, Plus, Trash2, Edit } from "lucide-react"
export default function CouponsAdmin() {
const [coupons, setCoupons] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [newCoupon, setNewCoupon] = useState({
code: "",
discount_percent: 10,
max_discount_amount: 100,
})
useEffect(() => {
fetchCoupons()
}, [])
const fetchCoupons = async () => {
try {
const res = await fetch('/api/admin/coupons')
const data = await res.json()
setCoupons(data)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
if (!newCoupon.code) return
setSubmitting(true)
try {
await fetch('/api/admin/coupons', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newCoupon)
})
setNewCoupon({ code: "", discount_percent: 10, max_discount_amount: 100 })
await fetchCoupons()
} catch (e) {
console.error(e)
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm("Delete this coupon?")) return
try {
await fetch(`/api/admin/coupons/${id}`, { method: "DELETE" })
await fetchCoupons()
} catch (e) {
console.error(e)
}
}
return (
<div className="p-8 max-w-6xl mx-auto space-y-8 animate-in fade-in duration-500">
<div>
<h1 className="text-3xl font-bold tracking-tight">Coupon Management</h1>
<p className="text-muted-foreground mt-1 font-medium">Create and manage discount codes for subscriptions.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<Card className="col-span-1 border-border shadow-sm">
<CardHeader>
<CardTitle>Create Coupon</CardTitle>
<CardDescription>Add a new promo code</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Code</Label>
<Input
placeholder="e.g. DIWALI20"
value={newCoupon.code}
onChange={e => setNewCoupon({...newCoupon, code: e.target.value.toUpperCase()})}
/>
</div>
<div className="space-y-2">
<Label>Discount %</Label>
<Input
type="number"
value={newCoupon.discount_percent}
onChange={e => setNewCoupon({...newCoupon, discount_percent: Number(e.target.value)})}
/>
</div>
<div className="space-y-2">
<Label>Max Discount (₹)</Label>
<Input
type="number"
value={newCoupon.max_discount_amount}
onChange={e => setNewCoupon({...newCoupon, max_discount_amount: Number(e.target.value)})}
/>
</div>
<Button className="w-full" onClick={handleCreate} disabled={submitting || !newCoupon.code}>
{submitting ? <Loader2 className="animate-spin" /> : <><Plus size={16} className="mr-2"/> Create Coupon</>}
</Button>
</CardContent>
</Card>
<Card className="col-span-1 md:col-span-2 border-border shadow-sm">
<CardHeader>
<CardTitle>Active Coupons</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center p-8"><Loader2 className="animate-spin text-muted-foreground" /></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Max Off</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coupons.length === 0 && (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground">No coupons found.</TableCell></TableRow>
)}
{coupons.map(c => (
<TableRow key={c.id}>
<TableCell className="font-bold">{c.code}</TableCell>
<TableCell>{c.discount_percent}%</TableCell>
<TableCell>₹{c.max_discount_amount}</TableCell>
<TableCell>
{c.is_active ? <Badge className="bg-green-500/20 text-green-500 border-green-500/30">Active</Badge> : <Badge variant="outline">Inactive</Badge>}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleDelete(c.id)}>
<Trash2 size={16} className="text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { ReactNode, useEffect } from "react" import { ReactNode, useEffect } from "react"
import { LayoutDashboard, Users, Database, Terminal, Settings, FileText } from "lucide-react" import { LayoutDashboard, Users, Database, Terminal, Settings, FileText, Tag } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { Logo } from "@/components/Logo" import { Logo } from "@/components/Logo"
@@ -27,6 +27,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<NavItem href="/admin/questions" icon={<Database size={18} />} label="Question Bank" active={pathname.startsWith("/admin/questions")} /> <NavItem href="/admin/questions" icon={<Database size={18} />} label="Question Bank" active={pathname.startsWith("/admin/questions")} />
<NavItem href="/admin/agents" icon={<Terminal size={18} />} label="AI Agents" active={pathname.startsWith("/admin/agents")} /> <NavItem href="/admin/agents" icon={<Terminal size={18} />} label="AI Agents" active={pathname.startsWith("/admin/agents")} />
<NavItem href="/admin/students" icon={<Users size={18} />} label="Students" active={pathname.startsWith("/admin/students")} /> <NavItem href="/admin/students" icon={<Users size={18} />} label="Students" active={pathname.startsWith("/admin/students")} />
<NavItem href="/admin/coupons" icon={<Tag size={18} />} label="Coupons" active={pathname.startsWith("/admin/coupons")} />
</nav> </nav>
<div className="p-4 border-t border-border"> <div className="p-4 border-t border-border">
<NavItem href="/admin/settings" icon={<Settings size={18} />} label="System Settings" active={pathname.startsWith("/admin/settings")} /> <NavItem href="/admin/settings" icon={<Settings size={18} />} label="System Settings" active={pathname.startsWith("/admin/settings")} />

View File

@@ -0,0 +1,202 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { ArrowLeft, Loader2, User, Mail, Target, Award, Calendar, Activity, BookOpen } from "lucide-react"
export default function StudentKundliPage() {
const params = useParams()
const router = useRouter()
const studentId = params.studentId as string
const [loading, setLoading] = useState(true)
const [userData, setUserData] = useState<any>(null)
useEffect(() => {
// Simulated fetch of student's complete "Kundli"
setTimeout(() => {
setUserData({
id: studentId,
name: "Rahul Sharma",
email: "rahul@example.com",
target: "NEET",
tier: "AI Pro",
joined: "Oct 12, 2025",
stats: {
total_tests: 42,
average_score: "78%",
streak: 45,
total_questions_solved: 3450,
ai_coach_interactions: 124
},
recent_tests: [
{ id: 1, name: "Full Syllabus NEET Mock #4", score: "620/720", date: "2 days ago", percentile: "98.5%" },
{ id: 2, name: "Physics: Kinematics DPP", score: "45/50", date: "4 days ago", percentile: "95.2%" },
{ id: 3, name: "Biology: Human Reproduction", score: "88/100", date: "1 week ago", percentile: "99.1%" }
],
payments: [
{ id: "PAY_1", amount: "₹1599", date: "Oct 12, 2025", plan: "AI Pro (Yearly)", status: "Success" }
],
weak_topics: ["Rotational Dynamics", "Organic Chemistry", "Genetics"]
})
setLoading(false)
}, 800)
}, [studentId])
if (loading) {
return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-muted-foreground w-8 h-8" /></div>
}
if (!userData) return <div>User not found</div>
return (
<div className="p-8 space-y-6 max-w-6xl mx-auto animate-in fade-in duration-500">
<Button variant="ghost" onClick={() => router.back()} className="text-muted-foreground hover:text-foreground mb-4">
<ArrowLeft size={16} className="mr-2" /> Back to Students
</Button>
<div className="flex flex-col md:flex-row gap-6">
{/* User Identity Card */}
<Card className="flex-1 border-border shadow-sm">
<CardHeader className="pb-4 border-b border-border">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full bg-secondary flex items-center justify-center text-2xl font-bold">
{userData.name.charAt(0)}
</div>
<div>
<CardTitle className="text-2xl">{userData.name}</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Mail size={14} /> {userData.email}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 grid grid-cols-2 gap-y-4 gap-x-8">
<div>
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><Target size={14}/> Target Exam</div>
<div className="font-medium">{userData.target}</div>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><Award size={14}/> Plan Tier</div>
<Badge className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">{userData.tier}</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><Calendar size={14}/> Joined</div>
<div className="font-medium">{userData.joined}</div>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><User size={14}/> User ID</div>
<div className="font-mono text-xs">{userData.id}</div>
</div>
</CardContent>
</Card>
{/* Stats Summary */}
<div className="grid grid-cols-2 gap-4 flex-1">
<Card className="bg-secondary/20 border-border">
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
<div className="text-3xl font-black text-[#00F0FF]">{userData.stats.total_tests}</div>
<div className="text-sm text-muted-foreground mt-1">Tests Attempted</div>
</CardContent>
</Card>
<Card className="bg-secondary/20 border-border">
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
<div className="text-3xl font-black text-orange-500">{userData.stats.streak}</div>
<div className="text-sm text-muted-foreground mt-1">Day Streak</div>
</CardContent>
</Card>
<Card className="bg-secondary/20 border-border">
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
<div className="text-3xl font-black">{userData.stats.average_score}</div>
<div className="text-sm text-muted-foreground mt-1">Avg. Accuracy</div>
</CardContent>
</Card>
<Card className="bg-secondary/20 border-border">
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
<div className="text-3xl font-black">{userData.stats.total_questions_solved}</div>
<div className="text-sm text-muted-foreground mt-1">Questions Solved</div>
</CardContent>
</Card>
</div>
</div>
{/* Recent Activity & Weak Topics */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 border-border shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2"><Activity size={18} className="text-[#00F0FF]"/> Recent Tests</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{userData.recent_tests.map((test: any) => (
<div key={test.id} className="flex items-center justify-between p-3 rounded-lg border border-border bg-secondary/10">
<div>
<div className="font-bold">{test.name}</div>
<div className="text-xs text-muted-foreground mt-1">{test.date}</div>
</div>
<div className="text-right">
<div className="font-mono font-bold text-[#00F0FF]">{test.score}</div>
<div className="text-xs text-muted-foreground mt-1">{test.percentile} %ile</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2"><BookOpen size={18} className="text-red-500"/> AI Identified Weaknesses</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{userData.weak_topics.map((topic: string, i: number) => (
<li key={i} className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="font-medium text-sm">{topic}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Payment History */}
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle>Payment History</CardTitle>
</CardHeader>
<CardContent>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-secondary/30">
<tr>
<th className="px-4 py-3 font-semibold">Transaction ID</th>
<th className="px-4 py-3 font-semibold">Plan</th>
<th className="px-4 py-3 font-semibold">Date</th>
<th className="px-4 py-3 font-semibold">Amount</th>
<th className="px-4 py-3 font-semibold">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{userData.payments.map((p: any) => (
<tr key={p.id}>
<td className="px-4 py-3 font-mono text-xs">{p.id}</td>
<td className="px-4 py-3 font-medium">{p.plan}</td>
<td className="px-4 py-3 text-muted-foreground">{p.date}</td>
<td className="px-4 py-3 font-bold">{p.amount}</td>
<td className="px-4 py-3"><Badge className="bg-green-500/20 text-green-500 border-green-500/30">{p.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -3,8 +3,10 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Search, Download, MoreVertical } from "lucide-react" import { Search, Download, MoreVertical } from "lucide-react"
import { useRouter } from "next/navigation"
export default function StudentsPage() { export default function StudentsPage() {
const router = useRouter()
const students = [ const students = [
{ id: "STU-001", name: "Rahul Sharma", email: "rahul@example.com", tier: "AI Pro", target: "NEET", joined: "Oct 12, 2025", tests: 42, streak: 45, lastLogin: "Today, 08:30 AM" }, { id: "STU-001", name: "Rahul Sharma", email: "rahul@example.com", tier: "AI Pro", target: "NEET", joined: "Oct 12, 2025", tests: 42, streak: 45, lastLogin: "Today, 08:30 AM" },
{ id: "STU-002", name: "Priya Patel", email: "priya@example.com", tier: "Basic", target: "JEE Main", joined: "Nov 05, 2025", tests: 14, streak: 2, lastLogin: "Yesterday, 14:15 PM" }, { id: "STU-002", name: "Priya Patel", email: "priya@example.com", tier: "Basic", target: "JEE Main", joined: "Nov 05, 2025", tests: 14, streak: 2, lastLogin: "Yesterday, 14:15 PM" },
@@ -50,7 +52,11 @@ export default function StudentsPage() {
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{students.map((s) => ( {students.map((s) => (
<tr key={s.id} className="hover:bg-secondary/20 transition-colors"> <tr
key={s.id}
className="hover:bg-secondary/20 transition-colors cursor-pointer"
onClick={() => router.push(`/admin/students/${s.id}`)}
>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="font-bold">{s.name}</div> <div className="font-bold">{s.name}</div>
<div className="text-muted-foreground text-xs">{s.email}</div> <div className="text-muted-foreground text-xs">{s.email}</div>

View File

@@ -37,6 +37,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
const [showPeriodicTable, setShowPeriodicTable] = useState(false); const [showPeriodicTable, setShowPeriodicTable] = useState(false);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false); const [showSubmitConfirm, setShowSubmitConfirm] = useState(false);
const [accessError, setAccessError] = useState<string | null>(null);
const [localTheme, setLocalTheme] = useState<string>('light'); const [localTheme, setLocalTheme] = useState<string>('light');
useEffect(() => { useEffect(() => {
@@ -97,6 +98,20 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
const testRes = await fetch(`${apiUrl}/api/tests/${examId}?user_id=b0e17378-864d-439e-8806-708dc48a891b`); const testRes = await fetch(`${apiUrl}/api/tests/${examId}?user_id=b0e17378-864d-439e-8806-708dc48a891b`);
if (!testRes.ok) throw new Error("Failed to fetch test structure"); if (!testRes.ok) throw new Error("Failed to fetch test structure");
const testData = await testRes.json(); const testData = await testRes.json();
// Strict boundary checking
const now = new Date();
if (testData.scheduled_date && new Date(testData.scheduled_date) > now) {
setAccessError(`This exam is scheduled for ${new Date(testData.scheduled_date).toLocaleString()}. You cannot start it yet.`);
setMounted(true);
return;
}
if (testData.end_date && new Date(testData.end_date) < now) {
setAccessError(`This exam expired on ${new Date(testData.end_date).toLocaleString()} and is no longer available.`);
setMounted(true);
return;
}
setExamType(testData.type); setExamType(testData.type);
// Extract all question IDs from sections // Extract all question IDs from sections
@@ -232,7 +247,26 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
await submitAttemptToServer('b0e17378-864d-439e-8806-708dc48a891b'); await submitAttemptToServer('b0e17378-864d-439e-8806-708dc48a891b');
} }
if (!mounted || questions.length === 0) return <div className="p-8">Loading Exam Engine...</div>; if (!mounted || questions.length === 0) {
return <div className="min-h-screen flex items-center justify-center bg-background"><div className="animate-pulse flex items-center gap-2"><Clock className="animate-spin text-muted-foreground" size={20}/><span className="font-bold text-muted-foreground tracking-widest uppercase text-sm">Initializing CBT Engine</span></div></div>
}
if (accessError) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="max-w-md w-full bg-secondary/20 p-8 rounded-2xl border border-border text-center space-y-4">
<div className="mx-auto size-12 bg-red-500/10 text-red-500 rounded-full flex items-center justify-center">
<AlertTriangle size={24} />
</div>
<h2 className="text-xl font-bold">Access Denied</h2>
<p className="text-muted-foreground font-medium">{accessError}</p>
<Button variant="outline" className="mt-4" onClick={() => window.history.back()}>
Go Back
</Button>
</div>
</div>
);
}
if (status === 'NOT_STARTED') { if (status === 'NOT_STARTED') {
return ( return (
@@ -293,8 +327,18 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
const currentQuestion = questions[currentQuestionIndex]; const currentQuestion = questions[currentQuestionIndex];
const currentState = currentQuestion ? answers[currentQuestion.id] : null const currentState = currentQuestion ? answers[currentQuestion.id] : null
// Helper to ensure math options missing $ delimiters get rendered properly
const renderLatex = (text: string) => {
if (typeof text !== 'string') return text;
// If it contains backslash but no dollar signs, we might want to wrap the whole thing in $ $
if (text.includes('\\') && !text.includes('$')) {
return `$${text}$`;
}
return text;
};
const handleOptionSelect = (qId: string, letter: string, type: string) => { const handleOptionSelect = (qId: string, letter: string, type: string) => {
if (type === 'Multiple_Choice') { if (type === 'MCQ_Multiple' || type === 'Multiple_Choice') {
const currentSelected = currentState?.selected_answer || ""; const currentSelected = currentState?.selected_answer || "";
const selectedArray = currentSelected.split(',').filter(Boolean); const selectedArray = currentSelected.split(',').filter(Boolean);
if (selectedArray.includes(letter)) { if (selectedArray.includes(letter)) {
@@ -421,6 +465,13 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
{currentQuestion.text_en} {currentQuestion.text_en}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
{(currentQuestion.type === 'MCQ_Multiple' || currentQuestion.type === 'Multiple_Choice') && (
<p className="text-sm font-bold text-muted-foreground mt-6 mb-[-1rem] bg-secondary/50 p-2.5 rounded-md border border-border inline-flex items-center gap-2">
<AlertTriangle size={16} className="text-amber-500" />
This question has MULTIPLE CORRECT OPTIONS. Please select all that apply.
</p>
)}
<div className="space-y-3 mt-8"> <div className="space-y-3 mt-8">
{currentQuestion.type === 'Numerical' ? ( {currentQuestion.type === 'Numerical' ? (
@@ -441,7 +492,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
) : ( ) : (
currentQuestion.options?.map((option: string, idx: number) => { currentQuestion.options?.map((option: string, idx: number) => {
const letter = String.fromCharCode(65 + idx); const letter = String.fromCharCode(65 + idx);
const isSelected = currentQuestion.type === 'Multiple_Choice' const isMultiple = currentQuestion.type === 'MCQ_Multiple' || currentQuestion.type === 'Multiple_Choice';
const isSelected = isMultiple
? (currentState?.selected_answer || "").split(',').includes(letter) ? (currentState?.selected_answer || "").split(',').includes(letter)
: currentState?.selected_answer === letter; : currentState?.selected_answer === letter;
@@ -453,14 +505,14 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'} ${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'}
`} `}
> >
<div className={`flex-shrink-0 size-8 ${currentQuestion.type === 'Multiple_Choice' ? 'rounded-md' : 'rounded-full'} border flex items-center justify-center font-medium transition-colors <div className={`flex-shrink-0 size-8 ${isMultiple ? 'rounded-md' : 'rounded-full'} border flex items-center justify-center font-medium transition-colors
${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'} ${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'}
`}> `}>
{letter} {letter}
</div> </div>
<span className={`text-base font-semibold flex-1 ${isSelected ? 'text-primary' : ''}`}> <span className={`text-base font-semibold flex-1 ${isSelected ? 'text-primary' : ''}`}>
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex, rehypeRaw]}> <ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex, rehypeRaw]}>
{option} {renderLatex(option)}
</ReactMarkdown> </ReactMarkdown>
</span> </span>
</button> </button>
@@ -481,6 +533,14 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground bg-secondary/30 px-2 py-1 rounded-md">+4 / -1</span> <span className="text-xs font-bold uppercase tracking-wider text-muted-foreground bg-secondary/30 px-2 py-1 rounded-md">+4 / -1</span>
<Button
variant="ghost"
size="icon"
onClick={() => markForReview(currentQuestion.id)}
className={`h-8 w-8 rounded-lg transition-colors ${currentState?.status.includes('MARKED') ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground hover:text-amber-500'}`}
>
<Flag size={16} />
</Button>
</div> </div>
</div> </div>
<div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-relaxed text-foreground font-medium"> <div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-relaxed text-foreground font-medium">
@@ -488,6 +548,13 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
{currentQuestion.text_hi} {currentQuestion.text_hi}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
{(currentQuestion.type === 'MCQ_Multiple' || currentQuestion.type === 'Multiple_Choice') && (
<p className="text-sm font-bold text-muted-foreground mt-6 mb-[-1rem] bg-secondary/50 p-2.5 rounded-md border border-border inline-flex items-center gap-2">
<AlertTriangle size={16} className="text-amber-500" />
This question has MULTIPLE CORRECT OPTIONS. Please select all that apply.
</p>
)}
<div className="space-y-3 mt-8"> <div className="space-y-3 mt-8">
{currentQuestion.type === 'Numerical' ? ( {currentQuestion.type === 'Numerical' ? (
@@ -508,7 +575,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
) : ( ) : (
currentQuestion.options_hi?.map((option: string, idx: number) => { currentQuestion.options_hi?.map((option: string, idx: number) => {
const letter = String.fromCharCode(65 + idx); const letter = String.fromCharCode(65 + idx);
const isSelected = currentQuestion.type === 'Multiple_Choice' const isMultiple = currentQuestion.type === 'MCQ_Multiple' || currentQuestion.type === 'Multiple_Choice';
const isSelected = isMultiple
? (currentState?.selected_answer || "").split(',').includes(letter) ? (currentState?.selected_answer || "").split(',').includes(letter)
: currentState?.selected_answer === letter; : currentState?.selected_answer === letter;
@@ -520,14 +588,14 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'} ${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'}
`} `}
> >
<div className={`flex-shrink-0 size-8 ${currentQuestion.type === 'Multiple_Choice' ? 'rounded-md' : 'rounded-full'} border flex items-center justify-center font-medium transition-colors <div className={`flex-shrink-0 size-8 ${isMultiple ? 'rounded-md' : 'rounded-full'} border flex items-center justify-center font-medium transition-colors
${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'} ${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'}
`}> `}>
{letter} {letter}
</div> </div>
<span className={`text-base font-semibold flex-1 ${isSelected ? 'text-primary' : ''}`}> <span className={`text-base font-semibold flex-1 ${isSelected ? 'text-primary' : ''}`}>
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex, rehypeRaw]}> <ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex, rehypeRaw]}>
{option} {renderLatex(option)}
</ReactMarkdown> </ReactMarkdown>
</span> </span>
</button> </button>

View File

@@ -93,15 +93,15 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
<p className="text-muted-foreground">Review your performance, detailed solutions, and identify your weak spots.</p> <p className="text-muted-foreground">Review your performance, detailed solutions, and identify your weak spots.</p>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4 w-full md:w-auto">
<Card className="bg-secondary/30 border-border min-w-32"> <Card className="bg-secondary/30 border-border flex-1 md:min-w-48">
<CardContent className="p-4 flex flex-col items-center justify-center"> <CardContent className="p-4 flex flex-col items-center justify-center">
<Award className="text-[#00F0FF] mb-2" size={24} /> <Award className="text-[#00F0FF] mb-2" size={24} />
<div className="text-sm text-muted-foreground font-bold uppercase">Score</div> <div className="text-sm text-muted-foreground font-bold uppercase">Score</div>
<div className="text-2xl font-bold">{scoreCalc} <span className="text-sm text-muted-foreground">/ {maxScore}</span></div> <div className="text-2xl font-bold">{scoreCalc} <span className="text-sm text-muted-foreground">/ {maxScore}</span></div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-secondary/30 border-border min-w-32"> <Card className="bg-secondary/30 border-border flex-1 md:min-w-48">
<CardContent className="p-4 flex flex-col items-center justify-center"> <CardContent className="p-4 flex flex-col items-center justify-center">
<Clock className="text-primary mb-2" size={24} /> <Clock className="text-primary mb-2" size={24} />
<div className="text-sm text-muted-foreground font-bold uppercase">Time Taken</div> <div className="text-sm text-muted-foreground font-bold uppercase">Time Taken</div>

View File

@@ -16,6 +16,7 @@ interface TestData {
total_time: number; total_time: number;
difficulty: string; difficulty: string;
scheduled_date?: string; scheduled_date?: string;
end_date?: string;
} }
export default function CBTPage() { export default function CBTPage() {
@@ -26,29 +27,45 @@ export default function CBTPage() {
const [attempts, setAttempts] = useState<any[]>([]); const [attempts, setAttempts] = useState<any[]>([]);
const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showAllExams, setShowAllExams] = useState(false); const [showAllExams, setShowAllExams] = useState(false);
const [selectedPattern, setSelectedPattern] = useState<string>("");
const [dateFilter, setDateFilter] = useState("");
const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser(); const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser();
useEffect(() => { useEffect(() => {
if (userLoading || !userId) return; if (userLoading || !userId) return;
async function loadCBTExams() { async function loadUser() {
try { try {
// 1. Fetch user target exam
const userRes = await fetch(`/api/users/${userId}`); const userRes = await fetch(`/api/users/${userId}`);
if (!userRes.ok) throw new Error("Failed to load user target exam"); if (!userRes.ok) throw new Error("Failed to load user target exam");
const userData = await userRes.json(); const userData = await userRes.json();
const pattern = userData.target_exam; const pattern = userData.target_exam;
setTargetExam(pattern === "NEET" ? "NEET" : pattern === "JEE_Advanced" ? "JEE Advanced" : "JEE Main"); setTargetExam(pattern === "NEET" ? "NEET" : pattern === "JEE_Advanced" ? "JEE Advanced" : "JEE Main");
// Only set the initial pattern if it hasn't been changed by the user
if (!selectedPattern) {
setSelectedPattern(pattern);
}
} catch (err) {
console.error("Error loading user:", err);
}
}
loadUser();
}, [userId, userLoading]);
// 2. Fetch tests filtered by exam useEffect(() => {
const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`); if (!userId || !selectedPattern) return;
async function loadTests() {
setLoading(true);
try {
const testRes = await fetch(`/api/tests?exam_pattern=${selectedPattern}&user_id=${userId}`);
if (testRes.ok) { if (testRes.ok) {
const testsData = await testRes.json(); const testsData = await testRes.json();
setExams(testsData); setExams(testsData);
} }
// 3. Fetch user attempts
const attemptsRes = await fetch(`/api/users/${userId}/attempts`); const attemptsRes = await fetch(`/api/users/${userId}/attempts`);
if (attemptsRes.ok) { if (attemptsRes.ok) {
const attemptsData = await attemptsRes.json(); const attemptsData = await attemptsRes.json();
@@ -60,12 +77,21 @@ export default function CBTPage() {
setLoading(false); setLoading(false);
} }
} }
loadCBTExams(); loadTests();
}, [userId, userLoading]); }, [userId, selectedPattern]);
const filteredExams = exams.filter(exam => const filteredExams = exams.filter(exam => {
exam.title.toLowerCase().includes(search.toLowerCase()) const matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase());
); if (!matchesSearch) return false;
if (dateFilter) {
if (!exam.scheduled_date) return false;
const scheduledDate = new Date(exam.scheduled_date).toISOString().split('T')[0];
if (scheduledDate !== dateFilter) return false;
}
return true;
});
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' }); return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
@@ -73,6 +99,9 @@ export default function CBTPage() {
// Get the highest score for each test if attempted multiple times // Get the highest score for each test if attempted multiple times
const attemptsByTest = attempts.reduce((acc, curr) => { 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) { if (!acc[curr.source_id] || acc[curr.source_id].score < curr.score) {
acc[curr.source_id] = curr; acc[curr.source_id] = curr;
} }
@@ -88,18 +117,45 @@ export default function CBTPage() {
Real exam environment simulations to conquer {targetExam || "NEET/JEE"}. Real exam environment simulations to conquer {targetExam || "NEET/JEE"}.
</p> </p>
</div> </div>
<div className="relative w-64"> <div className="flex flex-col sm:flex-row gap-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} /> <div className="relative w-full sm:w-48">
<input <input
type="text" type="date"
placeholder="Search exams..." value={dateFilter}
value={search} onChange={(e) => setDateFilter(e.target.value)}
onChange={(e) => setSearch(e.target.value)} className="w-full px-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[#00F0FF] outline-none text-muted-foreground"
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[#00F0FF] outline-none" />
/> </div>
<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-[#00F0FF] outline-none"
/>
</div>
</div> </div>
</div> </div>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
{["JEE_MAIN", "JEE_ADVANCED", "NEET"].map(p => {
// Normalize to match db/backend pattern
const dbPattern = p === "JEE_MAIN" ? "JEE_Main" : p === "JEE_ADVANCED" ? "JEE_Advanced" : "NEET";
return (
<Button
key={p}
variant={selectedPattern === dbPattern ? "default" : "outline"}
onClick={() => setSelectedPattern(dbPattern)}
className="rounded-full shadow-sm"
>
{p === "JEE_MAIN" ? "JEE Main" : p === "JEE_ADVANCED" ? "JEE Advanced" : "NEET"}
</Button>
)
})}
</div>
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-bold border-b border-border pb-2 flex items-center gap-2"> <h2 className="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
<Clock size={20} className="text-[#00F0FF]" /> Upcoming & Active Exams <Clock size={20} className="text-[#00F0FF]" /> Upcoming & Active Exams
@@ -122,10 +178,12 @@ export default function CBTPage() {
// Date logic // Date logic
const now = new Date(); const now = new Date();
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now; 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 isUpcoming = scheduledTime > now;
const isExpired = endTime ? now > endTime : false;
return ( return (
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked ? 'opacity-80' : ''}`}> <Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked || isExpired ? 'opacity-80' : ''}`}>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -146,6 +204,10 @@ export default function CBTPage() {
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1"> <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1">
<Lock size={12} /> Premium <Lock size={12} /> Premium
</Badge> </Badge>
) : isExpired ? (
<Badge variant="outline" className="bg-muted text-muted-foreground border-muted-foreground/30">
Expired
</Badge>
) : isAttempted ? ( ) : isAttempted ? (
<Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20"> <Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20">
Attempted Attempted
@@ -157,7 +219,7 @@ export default function CBTPage() {
</h3> </h3>
<div className="text-sm text-muted-foreground space-y-1 mb-6"> <div className="text-sm text-muted-foreground space-y-1 mb-6">
<div className="font-medium text-foreground"> <div className="font-medium text-foreground">
{isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : 'Available Now'} {isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'}
</div> </div>
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div> <div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
</div> </div>
@@ -173,10 +235,29 @@ export default function CBTPage() {
> >
<Lock size={16} /> Unlock Test <Lock size={16} /> Unlock Test
</Button> </Button>
) : isExpired && !isAttempted ? (
<Button disabled className="w-full gap-2 rounded-xl bg-secondary/50 text-muted-foreground border border-border font-bold">
Expired
</Button>
) : isAttempted ? (
<div className="flex gap-2">
<Link href={`/cbt/${exam.id}/result`} className="flex-1">
<Button className="w-full gap-2 rounded-xl bg-secondary border border-border text-foreground hover:bg-secondary/80 font-bold">
<PlayCircle size={16} /> Result
</Button>
</Link>
{!isExpired && (
<Link href={`/cbt/${exam.id}`} className="flex-1">
<Button className="w-full gap-2 rounded-xl bg-[#00F0FF]/10 border border-[#00F0FF]/30 text-[#00F0FF] hover:bg-[#00F0FF]/20 font-bold">
Retake
</Button>
</Link>
)}
</div>
) : ( ) : (
<Link href={isAttempted ? `/cbt/${exam.id}/result` : `/cbt/${exam.id}`}> <Link href={`/cbt/${exam.id}`}>
<Button className={`w-full gap-2 rounded-xl transition-colors font-bold ${isAttempted ? 'bg-secondary border border-border text-foreground hover:bg-secondary/80' : 'bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background'}`}> <Button className="w-full gap-2 rounded-xl bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background font-bold transition-colors">
<PlayCircle size={16} /> {isAttempted ? 'View Result' : 'Start Exam'} <PlayCircle size={16} /> Start Exam
</Button> </Button>
</Link> </Link>
)} )}
@@ -207,10 +288,12 @@ export default function CBTPage() {
// Date logic // Date logic
const now = new Date(); const now = new Date();
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now; 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 isUpcoming = scheduledTime > now;
const isExpired = endTime ? now > endTime : false;
return ( return (
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked ? 'opacity-80' : ''}`}> <Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked || isExpired ? 'opacity-80' : ''}`}>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -231,6 +314,10 @@ export default function CBTPage() {
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1"> <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1">
<Lock size={12} /> Premium <Lock size={12} /> Premium
</Badge> </Badge>
) : isExpired ? (
<Badge variant="outline" className="bg-muted text-muted-foreground border-muted-foreground/30">
Expired
</Badge>
) : isAttempted ? ( ) : isAttempted ? (
<Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20"> <Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20">
Attempted Attempted
@@ -242,7 +329,7 @@ export default function CBTPage() {
</h3> </h3>
<div className="text-sm text-muted-foreground space-y-1 mb-6"> <div className="text-sm text-muted-foreground space-y-1 mb-6">
<div className="font-medium text-foreground"> <div className="font-medium text-foreground">
{isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : 'Available Now'} {isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'}
</div> </div>
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div> <div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
</div> </div>
@@ -258,10 +345,29 @@ export default function CBTPage() {
> >
<Lock size={16} /> Unlock Test <Lock size={16} /> Unlock Test
</Button> </Button>
) : isExpired && !isAttempted ? (
<Button disabled className="w-full gap-2 rounded-xl bg-secondary/50 text-muted-foreground border border-border font-bold">
Expired
</Button>
) : isAttempted ? (
<div className="flex gap-2">
<Link href={`/cbt/${exam.id}/result`} className="flex-1">
<Button className="w-full gap-2 rounded-xl bg-secondary border border-border text-foreground hover:bg-secondary/80 font-bold">
<PlayCircle size={16} /> Result
</Button>
</Link>
{!isExpired && (
<Link href={`/cbt/${exam.id}`} className="flex-1">
<Button className="w-full gap-2 rounded-xl bg-[#00F0FF]/10 border border-[#00F0FF]/30 text-[#00F0FF] hover:bg-[#00F0FF]/20 font-bold">
Retake
</Button>
</Link>
)}
</div>
) : ( ) : (
<Link href={isAttempted ? `/cbt/${exam.id}/result` : `/cbt/${exam.id}`}> <Link href={`/cbt/${exam.id}`}>
<Button className={`w-full gap-2 rounded-xl transition-colors font-bold ${isAttempted ? 'bg-secondary border border-border text-foreground hover:bg-secondary/80' : 'bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background'}`}> <Button className="w-full gap-2 rounded-xl bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background font-bold transition-colors">
<PlayCircle size={16} /> {isAttempted ? 'View Result' : 'Start Exam'} <PlayCircle size={16} /> Start Exam
</Button> </Button>
</Link> </Link>
)} )}

View File

@@ -9,9 +9,11 @@ import Link from "next/link"
import { useActiveUser } from "@/hooks/useActiveUser" import { useActiveUser } from "@/hooks/useActiveUser"
export default function DPPPage() { export default function DPPPage() {
const { user } = useActiveUser() const { user, userId } = useActiveUser()
const [dpps, setDpps] = useState<any[]>([]) const [dpps, setDpps] = useState<any[]>([])
const [attempts, setAttempts] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [dateFilter, setDateFilter] = useState("")
useEffect(() => { useEffect(() => {
async function fetchDpps() { async function fetchDpps() {
@@ -25,6 +27,14 @@ export default function DPPPage() {
const data = await res.json() const data = await res.json()
setDpps(data) setDpps(data)
} }
if (userId) {
const attemptsRes = await fetch(`/api/users/${userId}/attempts`);
if (attemptsRes.ok) {
const attemptsData = await attemptsRes.json();
setAttempts(attemptsData);
}
}
} catch (err) { } catch (err) {
console.error("Failed to fetch DPPs", err) console.error("Failed to fetch DPPs", err)
} finally { } finally {
@@ -43,6 +53,29 @@ export default function DPPPage() {
} }
} }
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>);
const filteredDpps = dpps.filter(dpp => {
if (dateFilter) {
if (!dpp.scheduled_date) return false;
const scheduledDate = new Date(dpp.scheduled_date).toISOString().split('T')[0];
if (scheduledDate !== dateFilter) return false;
}
return true;
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
}
return ( return (
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto"> <div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between p-8 bg-gradient-to-r from-secondary/50 to-background border border-border rounded-2xl shadow-sm gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between p-8 bg-gradient-to-r from-secondary/50 to-background border border-border rounded-2xl shadow-sm gap-6">
@@ -56,6 +89,12 @@ export default function DPPPage() {
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6"> <div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input
type="date"
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className="px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold focus:ring-2 focus:ring-[#00F0FF] outline-none text-muted-foreground mr-2"
/>
<span className="text-sm font-bold text-muted-foreground mr-2"><Filter size={16} className="inline mr-1"/> Difficulty:</span> <span className="text-sm font-bold text-muted-foreground mr-2"><Filter size={16} className="inline mr-1"/> Difficulty:</span>
{['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => ( {['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => (
<button key={diff} className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${diff === 'Mixed' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary/50'}`}> <button key={diff} className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${diff === 'Mixed' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary/50'}`}>
@@ -74,23 +113,31 @@ export default function DPPPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{loading ? ( {loading ? (
<div className="col-span-2 text-center py-12 text-muted-foreground">Loading AI-generated DPPs...</div> <div className="col-span-2 text-center py-12 text-muted-foreground">Loading AI-generated DPPs...</div>
) : dpps.length === 0 ? ( ) : filteredDpps.length === 0 ? (
<div className="col-span-2 text-center py-12 text-muted-foreground border border-dashed rounded-xl">No DPPs available yet. Upload some PDFs in the Admin Panel!</div> <div className="col-span-2 text-center py-12 text-muted-foreground border border-dashed rounded-xl">No DPPs available for the selected criteria.</div>
) : dpps.map((dpp, i) => { ) : filteredDpps.map((dpp, i) => {
// Calculate total questions dynamically from sections // Calculate total questions dynamically from sections
let count = 0; let count = 0;
if (dpp.sections) { if (dpp.sections) {
Object.values(dpp.sections).forEach((s: any) => { count += (s.questions?.length || 0) }); Object.values(dpp.sections).forEach((s: any) => { count += (s.questions?.length || 0) });
} }
// Temporary mock for status
let status: string = "Pending" const attempt = attemptsByTest[dpp.id];
const isAttempted = !!attempt;
// Date logic
const now = new Date();
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now;
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
const isUpcoming = scheduledTime > now;
const isExpired = endTime ? now > endTime : false;
return ( return (
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${status === 'Completed' ? 'opacity-70 hover:opacity-100 bg-secondary/10' : 'hover:border-[#00F0FF]/50 bg-background'}`}> <Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-70 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-80' : 'hover:border-[#00F0FF]/50 bg-background'}`}>
<CardContent className="p-6 flex items-center justify-between"> <CardContent className="p-6 flex items-center justify-between">
<div className="flex gap-4"> <div className="flex gap-4">
<div className={`mt-1 flex-shrink-0 ${status === 'Completed' ? 'text-green-500' : 'text-foreground'}`}> <div className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
{status === 'Completed' ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground group-hover:border-[#00F0FF] transition-colors flex items-center justify-center"></div>} {isAttempted ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground group-hover:border-[#00F0FF] transition-colors flex items-center justify-center"></div>}
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -99,6 +146,17 @@ export default function DPPPage() {
</span> </span>
<span className="text-muted-foreground text-xs font-bold">•</span> <span className="text-muted-foreground text-xs font-bold">•</span>
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{dpp.difficulty} Level</span> <span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{dpp.difficulty} Level</span>
{isUpcoming ? (
<>
<span className="text-muted-foreground text-xs font-bold">•</span>
<span className="text-[10px] font-bold uppercase text-yellow-500 bg-yellow-500/10 px-1.5 py-0.5 rounded">Upcoming</span>
</>
) : isExpired ? (
<>
<span className="text-muted-foreground text-xs font-bold">•</span>
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">Expired</span>
</>
) : null}
</div> </div>
<h3 className="font-bold text-lg leading-tight group-hover:text-[#00F0FF] transition-colors flex items-center gap-2"> <h3 className="font-bold text-lg leading-tight group-hover:text-[#00F0FF] transition-colors flex items-center gap-2">
{dpp.title} {dpp.title}
@@ -108,14 +166,30 @@ export default function DPPPage() {
</h3> </h3>
<div className="text-sm text-muted-foreground mt-2 font-medium"> <div className="text-sm text-muted-foreground mt-2 font-medium">
{count} Questions • {dpp.total_time} mins {count} Questions • {dpp.total_time} mins
{dpp.scheduled_date && (
<span className="ml-2 border-l border-border pl-2">
{isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="text-muted-foreground group-hover:text-foreground transition-colors pl-4 shrink-0"> <div className="flex flex-col sm:flex-row gap-2 shrink-0">
{status === 'Completed' ? ( {isUpcoming ? (
<Link href={`/dashboard/cbt/history/${dpp.id}`}> <Button disabled variant="outline" size="sm" className="font-bold">Starts Soon</Button>
<Button variant="ghost" size="sm" className="font-bold">Review</Button> ) : isExpired && !isAttempted ? (
</Link> <Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
) : isAttempted ? (
<>
<Link href={`/cbt/${dpp.id}/result`}>
<Button variant="outline" size="sm" className="font-bold border-border">Result</Button>
</Link>
{!isExpired && (
<Link href={`/cbt/${dpp.id}`}>
<Button variant="secondary" size="sm" className="font-bold bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20">Retake</Button>
</Link>
)}
</>
) : ( ) : (
<Link href={`/cbt/${dpp.id}`}> <Link href={`/cbt/${dpp.id}`}>
<Button variant="secondary" size="icon" className="rounded-full group-hover:bg-primary group-hover:text-primary-foreground hover:bg-primary hover:text-primary-foreground shadow-sm transition-all duration-300"> <Button variant="secondary" size="icon" className="rounded-full group-hover:bg-primary group-hover:text-primary-foreground hover:bg-primary hover:text-primary-foreground shadow-sm transition-all duration-300">

View File

@@ -60,8 +60,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
</p> </p>
</div> </div>
<NavItem href="/dashboard" icon={<LayoutDashboard size={18} />} label="Overview" active={pathname === "/dashboard"} /> <NavItem href="/dashboard" icon={<LayoutDashboard size={18} />} label="Overview" active={pathname === "/dashboard"} />
<NavItem href="/dashboard/dpp" icon={<FileText size={18} />} label="Daily Practice" active={pathname.startsWith("/dashboard/dpp")} /> <NavItem href="/dashboard/dpp" icon={<FileText size={18} />} label="Target DPPs" active={pathname.startsWith("/dashboard/dpp")} />
<NavItem href="/dashboard/materials" icon={<BookOpen size={18} />} label="Study Materials" active={pathname.startsWith("/dashboard/materials")} />
<NavItem href="/dashboard/progress" icon={<Activity size={18} />} label="Syllabus Tracker" active={pathname.startsWith("/dashboard/progress")} /> <NavItem href="/dashboard/progress" icon={<Activity size={18} />} label="Syllabus Tracker" active={pathname.startsWith("/dashboard/progress")} />
<NavItem href="/dashboard/leaderboard" icon={<Trophy size={18} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} /> <NavItem href="/dashboard/leaderboard" icon={<Trophy size={18} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} />
@@ -73,10 +72,17 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
<NavItem <NavItem
href="/dashboard/cbt" href="/dashboard/cbt"
icon={<Activity size={18} />} icon={<Activity size={18} />}
label="Exams (CBT)" label="Mock Tests (CBT)"
active={pathname.startsWith("/dashboard/cbt")} active={pathname.startsWith("/dashboard/cbt")}
isLocked={!hasAccessTo("weekly-test-series")} isLocked={!hasAccessTo("weekly-test-series")}
/> />
<NavItem
href="/dashboard/materials"
icon={<BookOpen size={18} />}
label="Quick Notes"
active={pathname.startsWith("/dashboard/materials")}
isLocked={!hasAccessTo("detailed-performance-analytics")}
/>
<NavItem <NavItem
href="/dashboard/analytics" href="/dashboard/analytics"
icon={<LineChart size={18} />} icon={<LineChart size={18} />}
@@ -84,13 +90,6 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
active={pathname.startsWith("/dashboard/analytics")} active={pathname.startsWith("/dashboard/analytics")}
isLocked={!hasAccessTo("detailed-performance-analytics")} isLocked={!hasAccessTo("detailed-performance-analytics")}
/> />
<NavItem
href="/dashboard/rank-predictor"
icon={<Trophy size={18} />}
label="Rank Predictor"
active={pathname.startsWith("/dashboard/rank-predictor")}
isLocked={!hasAccessTo("college-predictor")}
/>
<NavItem <NavItem
href="/dashboard/community" href="/dashboard/community"
icon={<BookOpen size={18} />} icon={<BookOpen size={18} />}
@@ -111,6 +110,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
active={pathname.startsWith("/dashboard/ai-coach")} active={pathname.startsWith("/dashboard/ai-coach")}
isLocked={!hasAccessTo("ai-tutor-chat")} isLocked={!hasAccessTo("ai-tutor-chat")}
/> />
<NavItem
href="/dashboard/rank-predictor"
icon={<Trophy size={18} />}
label="Rank Predictor"
active={pathname.startsWith("/dashboard/rank-predictor")}
isLocked={!hasAccessTo("college-predictor")}
/>
</nav> </nav>
<div className="p-4 border-t border-border space-y-2 bg-secondary/30"> <div className="p-4 border-t border-border space-y-2 bg-secondary/30">

View File

@@ -91,7 +91,10 @@ export default function DashboardPage() {
<div className="absolute inset-0 bg-gradient-to-br from-background to-secondary/50 z-0"></div> <div className="absolute inset-0 bg-gradient-to-br from-background to-secondary/50 z-0"></div>
<CardContent className="p-5 flex flex-col items-center text-center relative z-10"> <CardContent className="p-5 flex flex-col items-center text-center relative z-10">
<div className="p-3 bg-background border border-border shadow-sm rounded-full mb-3 text-foreground group-hover:scale-110 transition-transform"><Trophy size={24} /></div> <div className="p-3 bg-background border border-border shadow-sm rounded-full mb-3 text-foreground group-hover:scale-110 transition-transform"><Trophy size={24} /></div>
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">City Rank</p> <div className="flex items-center gap-2 mb-1">
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider">City Rank</p>
<Badge variant="outline" className="border-yellow-500/30 text-yellow-500 bg-yellow-500/5 rounded-md text-[10px] uppercase font-bold px-1.5 py-0">Pro</Badge>
</div>
<h3 className="text-2xl font-bold">#42 <span className="text-base text-muted-foreground font-medium">/ 1,200</span></h3> <h3 className="text-2xl font-bold">#42 <span className="text-base text-muted-foreground font-medium">/ 1,200</span></h3>
<p className="text-xs text-muted-foreground font-medium mt-2 text-[#00F0FF]">Top 5% in Jaipur</p> <p className="text-xs text-muted-foreground font-medium mt-2 text-[#00F0FF]">Top 5% in Jaipur</p>
</CardContent> </CardContent>

View File

@@ -109,7 +109,7 @@ export default function UpgradePage() {
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Free</CardTitle> <CardTitle className="text-2xl">Free</CardTitle>
<CardDescription>Basic access for everyone.</CardDescription> <CardDescription>Basic access for everyone.</CardDescription>
<div className="mt-4 font-black text-4xl">₹10<span className="text-xl text-muted-foreground font-normal">/month</span></div> <div className="mt-4 font-black text-4xl">₹10<span className="text-xl text-muted-foreground font-semibold">/year</span></div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm font-medium"> <CardContent className="space-y-4 text-sm font-medium">
<div className="flex items-center gap-3"><Check className="text-white" size={18}/> Subject-wise diagnostics</div> <div className="flex items-center gap-3"><Check className="text-white" size={18}/> Subject-wise diagnostics</div>
@@ -124,11 +124,11 @@ export default function UpgradePage() {
</Card> </Card>
{/* Pro Tier */} {/* Pro Tier */}
<Card className="bg-background/80 backdrop-blur-md border-yellow-500 p-6 rounded-[2rem] flex flex-col h-full z-10 relative"> <Card className="bg-background/80 backdrop-blur-md border-2 border-yellow-500 shadow-[0_0_25px_-5px_var(--color-yellow-500)] p-6 rounded-[2rem] flex flex-col h-full z-10 relative">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl text-yellow-500">Pro</CardTitle> <CardTitle className="text-2xl text-yellow-500">Pro</CardTitle>
<CardDescription>For serious aspirants.</CardDescription> <CardDescription>For serious aspirants.</CardDescription>
<div className="mt-4 font-black text-4xl">₹1299<span className="text-xl text-muted-foreground font-normal">/month</span></div> <div className="mt-4 font-black text-4xl">₹1299<span className="text-xl text-muted-foreground font-semibold">/year</span></div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm font-medium"> <CardContent className="space-y-4 text-sm font-medium">
<div className="flex items-center gap-3"><Check className="text-yellow-500" size={18}/> Everything in Free</div> <div className="flex items-center gap-3"><Check className="text-yellow-500" size={18}/> Everything in Free</div>
@@ -150,11 +150,11 @@ export default function UpgradePage() {
</Card> </Card>
{/* AI Coach Tier */} {/* AI Coach Tier */}
<Card className="bg-secondary/30 backdrop-blur-xl border-[#00F0FF] p-6 rounded-[2rem] flex flex-col transition-all h-full relative"> <Card className="bg-secondary/30 backdrop-blur-xl border-2 border-[#00F0FF] shadow-[0_0_25px_-5px_#00F0FF] p-6 rounded-[2rem] flex flex-col transition-all h-full relative">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-[#00F0FF]">AI Coach <ShieldCheck size={24} className="text-[#00F0FF]"/></CardTitle> <CardTitle className="text-2xl flex items-center gap-2 text-[#00F0FF]">AI Coach <ShieldCheck size={24} className="text-[#00F0FF]"/></CardTitle>
<CardDescription>Personalized mastery loop.</CardDescription> <CardDescription>Personalized mastery loop.</CardDescription>
<div className="mt-4 font-black text-4xl">₹1599<span className="text-xl text-muted-foreground font-normal">/month</span></div> <div className="mt-4 font-black text-4xl">₹1599<span className="text-xl text-muted-foreground font-semibold">/year</span></div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm font-medium"> <CardContent className="space-y-4 text-sm font-medium">
<div className="flex items-center gap-3"><Check className="text-[#00F0FF]" size={18}/> Everything in Pro</div> <div className="flex items-center gap-3"><Check className="text-[#00F0FF]" size={18}/> Everything in Pro</div>

View File

@@ -56,10 +56,10 @@ export function MistakeHeatmap() {
{row.attempts.map((errRate, i) => ( {row.attempts.map((errRate, i) => (
<div <div
key={i} key={i}
className={`flex-1 h-8 rounded-md border transition-all duration-300 group-hover:scale-105 flex items-center justify-center cursor-help relative ${getColor(errRate)}`} className={`flex-1 h-8 rounded-md border transition-all duration-300 group-hover:scale-105 flex items-center justify-center cursor-help ${getColor(errRate)}`}
title={`Error Rate: ${errRate}%`} title={`Error Rate: ${errRate}%`}
> >
<span className="opacity-0 group-hover:opacity-100 text-[10px] font-bold text-foreground absolute">{errRate}%</span> <span className="text-[10px] font-bold text-foreground/80">{errRate}%</span>
</div> </div>
))} ))}
</div> </div>