feat: complete UI tasks, backend optimizations, coupon admin, user kundli
This commit is contained in:
@@ -148,6 +148,14 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
|
|||||||
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,
|
||||||
user_id=user_id,
|
user_id=user_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")
|
||||||
@@ -740,3 +751,57 @@ def delete_test(test_id: UUID, session: Session = Depends(get_session)):
|
|||||||
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"}
|
||||||
@@ -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
BIN
backend_logs.txt
Normal file
Binary file not shown.
BIN
full_logs.txt
Normal file
BIN
full_logs.txt
Normal file
Binary file not shown.
155
src/app/admin/coupons/page.tsx
Normal file
155
src/app/admin/coupons/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")} />
|
||||||
|
|||||||
202
src/app/admin/students/[studentId]/page.tsx
Normal file
202
src/app/admin/students/[studentId]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)) {
|
||||||
@@ -422,6 +466,13 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
</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' ? (
|
||||||
<input
|
<input
|
||||||
@@ -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">
|
||||||
@@ -489,6 +549,13 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
</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' ? (
|
||||||
<input
|
<input
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
// 2. Fetch tests filtered by exam
|
// Only set the initial pattern if it hasn't been changed by the user
|
||||||
const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`);
|
if (!selectedPattern) {
|
||||||
|
setSelectedPattern(pattern);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading user:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadUser();
|
||||||
|
}, [userId, userLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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,7 +117,16 @@ 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">
|
||||||
|
<div className="relative w-full sm:w-48">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFilter}
|
||||||
|
onChange={(e) => setDateFilter(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"
|
||||||
|
/>
|
||||||
|
</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} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -99,6 +137,24 @@ export default function CBTPage() {
|
|||||||
/>
|
/>
|
||||||
</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">
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
<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>
|
</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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user