Fix UI bugs, admin bulk upload limits, test ingestion bugs, and reset demo users
This commit is contained in:
1832
backend/jee_advanced_full_90q.json
Normal file
1832
backend/jee_advanced_full_90q.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -236,9 +236,13 @@ async def bulk_register_students(file: UploadFile = File(...), session: Session
|
|||||||
decoded = content.decode('utf-8-sig')
|
decoded = content.decode('utf-8-sig')
|
||||||
csv_reader = csv.DictReader(io.StringIO(decoded))
|
csv_reader = csv.DictReader(io.StringIO(decoded))
|
||||||
|
|
||||||
|
rows = list(csv_reader)
|
||||||
|
if len(rows) > 500:
|
||||||
|
raise HTTPException(status_code=400, detail="Maximum limit of 500 users per bulk registration exceeded.")
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
for row in csv_reader:
|
for row in rows:
|
||||||
email = row.get("email", "").strip()
|
email = row.get("email", "").strip()
|
||||||
name = row.get("name", "").strip()
|
name = row.get("name", "").strip()
|
||||||
phone = row.get("phone", "").strip()
|
phone = row.get("phone", "").strip()
|
||||||
@@ -436,7 +440,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
|
|||||||
# --- CBT ENGINE ---
|
# --- CBT ENGINE ---
|
||||||
|
|
||||||
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
||||||
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
query = select(models.Test).where(models.Test.is_active == True)
|
query = select(models.Test).where(models.Test.is_active == True)
|
||||||
if exam_pattern:
|
if exam_pattern:
|
||||||
if exam_pattern == 'JEE_NEET':
|
if exam_pattern == 'JEE_NEET':
|
||||||
@@ -450,14 +454,14 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] =
|
|||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
|
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
|
||||||
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
test = session.get(models.Test, test_id)
|
test = session.get(models.Test, test_id)
|
||||||
if not test:
|
if not test:
|
||||||
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"])
|
@app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"])
|
||||||
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
test = session.get(models.Test, test_id)
|
test = session.get(models.Test, test_id)
|
||||||
if not test:
|
if not test:
|
||||||
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
||||||
@@ -498,7 +502,7 @@ def get_question_by_id(question_id: UUID, session: Session = Depends(get_session
|
|||||||
return question
|
return question
|
||||||
|
|
||||||
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
|
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
|
||||||
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
user_id = payload.get("user_id")
|
user_id = payload.get("user_id")
|
||||||
source_type_raw = payload.get("source_type", "Test")
|
source_type_raw = payload.get("source_type", "Test")
|
||||||
source_id = payload.get("source_id")
|
source_id = payload.get("source_id")
|
||||||
|
|||||||
3782
backend/neet_180_questions.json
Normal file
3782
backend/neet_180_questions.json
Normal file
File diff suppressed because it is too large
Load Diff
30
backend/reset_demo_users.py
Normal file
30
backend/reset_demo_users.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlmodel import Session
|
||||||
|
from database import engine
|
||||||
|
import models
|
||||||
|
from main import ensure_demo_user
|
||||||
|
|
||||||
|
demo_ids = [
|
||||||
|
UUID("11111111-1111-1111-1111-111111111111"),
|
||||||
|
UUID("22222222-2222-2222-2222-222222222222"),
|
||||||
|
UUID("33333333-3333-3333-3333-333333333333"),
|
||||||
|
UUID("44444444-4444-4444-4444-444444444444")
|
||||||
|
]
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
for uid in demo_ids:
|
||||||
|
existing = session.get(models.User, uid)
|
||||||
|
if existing:
|
||||||
|
print(f"Deleting {existing.name}...")
|
||||||
|
session.exec(models.Subscription.__table__.delete().where(models.Subscription.user_id == uid))
|
||||||
|
session.exec(models.Mistake.__table__.delete().where(models.Mistake.user_id == uid))
|
||||||
|
session.exec(models.Attempt.__table__.delete().where(models.Attempt.user_id == uid))
|
||||||
|
session.exec(models.StudyPlan.__table__.delete().where(models.StudyPlan.user_id == uid))
|
||||||
|
session.exec(models.Transaction.__table__.delete().where(models.Transaction.user_id == uid))
|
||||||
|
session.delete(existing)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
user = ensure_demo_user(uid, session)
|
||||||
|
print(f"Recreated: {user.name}")
|
||||||
|
|
||||||
64
backend/upload_test.py
Normal file
64
backend/upload_test.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import sys
|
||||||
|
sys.path.append('.')
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from database import engine
|
||||||
|
from models import User, Test, Question, QuestionTypeEnum, ExamEnum
|
||||||
|
|
||||||
|
def upload_json():
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open('neet_180_questions.json', 'r', encoding='utf-8') as f:
|
||||||
|
questions = json.load(f)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
admin = session.exec(select(User).where(User.tier == 'AI Coach')).first()
|
||||||
|
if not admin:
|
||||||
|
print('No admin found')
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'title': 'Test NEET Mock Upload',
|
||||||
|
'type': 'Mock',
|
||||||
|
'exam_pattern': 'NEET',
|
||||||
|
'time_limit': 180,
|
||||||
|
'admin_id': str(admin.id),
|
||||||
|
'questions_json': questions
|
||||||
|
}
|
||||||
|
|
||||||
|
test_obj = Test(
|
||||||
|
title=payload['title'],
|
||||||
|
type=payload['type'],
|
||||||
|
exam_pattern=ExamEnum.NEET,
|
||||||
|
sections={},
|
||||||
|
total_time=payload['time_limit'],
|
||||||
|
difficulty='Hard',
|
||||||
|
is_active=True,
|
||||||
|
created_by=admin.id
|
||||||
|
)
|
||||||
|
session.add(test_obj)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
for q in questions:
|
||||||
|
q_obj = Question(
|
||||||
|
subject=q.get('subject'),
|
||||||
|
topic=q.get('topic', 'General'),
|
||||||
|
subtopic=q.get('subtopic', ''),
|
||||||
|
difficulty=q.get('difficulty', 'Medium'),
|
||||||
|
type=QuestionTypeEnum(q.get('type', 'SCQ')),
|
||||||
|
question_text=q.get('question_text', ''),
|
||||||
|
options_json=q.get('options', []),
|
||||||
|
correct_answer=q.get('correct_answer', ''),
|
||||||
|
solution_text=q.get('explanation', ''), language='English',
|
||||||
|
test_id=test_obj.id, created_by=admin.id,
|
||||||
|
section_name=q.get('subject')
|
||||||
|
)
|
||||||
|
session.add(q_obj)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
print('Successfully added test with ID:', test_obj.id)
|
||||||
|
|
||||||
|
upload_json()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -138,14 +138,6 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-red-500/20 bg-red-500/5">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-red-500">Danger Zone</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button onClick={() => alert("Cache Purged.")} className="bg-red-500 text-white hover:bg-red-600 font-bold">Purge Generated Cache</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,14 +46,31 @@ export default function StudentsPage() {
|
|||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0)
|
||||||
|
const [uploadTimeLeft, setUploadTimeLeft] = useState(0)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleBulkUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleBulkUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setIsUploading(true)
|
setIsUploading(true)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setUploadProgress(0)
|
||||||
|
setUploadTimeLeft(15) // Simulate 15s max parsing time
|
||||||
|
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
setUploadProgress(prev => {
|
||||||
|
if (prev >= 95) return prev;
|
||||||
|
return prev + 5;
|
||||||
|
});
|
||||||
|
setUploadTimeLeft(prev => {
|
||||||
|
if (prev <= 1) return 1;
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
@@ -62,35 +79,48 @@ export default function StudentsPage() {
|
|||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!rawRes.ok) throw new Error("Upload failed")
|
if (!rawRes.ok) {
|
||||||
|
const errorData = await rawRes.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadProgress(100);
|
||||||
const results = await rawRes.json()
|
const results = await rawRes.json()
|
||||||
alert(`Successfully registered ${results.length} students!`)
|
|
||||||
|
|
||||||
// Download CSV with passwords using Blob to fix formatting on Windows
|
// Delay alert slightly to let progress bar reach 100% visually
|
||||||
const csvRows = ["name,email,password"];
|
setTimeout(async () => {
|
||||||
results.forEach((row: any) => {
|
alert(`Successfully registered ${results.length} students!`)
|
||||||
csvRows.push(`${row.name},${row.email},${row.password}`);
|
|
||||||
});
|
// Download CSV with passwords using Blob to fix formatting on Windows
|
||||||
const csvContent = csvRows.join("\r\n");
|
const csvRows = ["name,email,password"];
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
results.forEach((row: any) => {
|
||||||
const url = URL.createObjectURL(blob);
|
csvRows.push(`${row.name},${row.email},${row.password}`);
|
||||||
const link = document.createElement("a");
|
});
|
||||||
link.setAttribute("href", url);
|
const csvContent = csvRows.join("\r\n");
|
||||||
link.setAttribute("download", "bulk_registered_passwords.csv");
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
document.body.appendChild(link);
|
const url = URL.createObjectURL(blob);
|
||||||
link.click();
|
const link = document.createElement("a");
|
||||||
URL.revokeObjectURL(url);
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", "bulk_registered_passwords.csv");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Refresh students
|
||||||
|
const fresh = await fetchWithAuth("/api/admin/students").then(r => r.json())
|
||||||
|
setStudents(fresh)
|
||||||
|
setIsUploading(false)
|
||||||
|
setLoading(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||||
|
}, 500);
|
||||||
|
|
||||||
// Refresh students
|
|
||||||
const fresh = await fetchWithAuth("/api/admin/students").then(r => r.json())
|
|
||||||
setStudents(fresh)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert("Error: " + err.message)
|
alert("Error: " + err.message)
|
||||||
} finally {
|
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||||
|
} finally {
|
||||||
|
clearInterval(progressInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +166,46 @@ export default function StudentsPage() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
alert("No data to export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = ["Name", "Email", "Phone", "Plan", "Target", "Tests Taken", "Accuracy", "Streak", "Joined Date", "Status"];
|
||||||
|
const csvRows = [headers.join(",")];
|
||||||
|
|
||||||
|
filtered.forEach(s => {
|
||||||
|
const status = s.is_active !== false ? "Enabled" : "Disabled";
|
||||||
|
const date = formatDate(s.created_at);
|
||||||
|
const plan = s.tier;
|
||||||
|
const target = s.target_exam;
|
||||||
|
const row = [
|
||||||
|
`"${s.name}"`,
|
||||||
|
`"${s.email || ''}"`,
|
||||||
|
`"${s.phone || ''}"`,
|
||||||
|
`"${plan}"`,
|
||||||
|
`"${target}"`,
|
||||||
|
s.total_tests || 0,
|
||||||
|
`${s.avg_accuracy || 0}%`,
|
||||||
|
s.streak_count || 0,
|
||||||
|
`"${date}"`,
|
||||||
|
`"${status}"`
|
||||||
|
];
|
||||||
|
csvRows.push(row.join(","));
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvContent = csvRows.join("\r\n");
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", "students_export.csv");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const filtered = students.filter(s => {
|
const filtered = students.filter(s => {
|
||||||
const searchMatch = (s.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
const searchMatch = (s.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
@@ -162,25 +232,43 @@ export default function StudentsPage() {
|
|||||||
{loading ? "Loading..." : `${students.length} students enrolled`}
|
{loading ? "Loading..." : `${students.length} students enrolled`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col items-end gap-1">
|
||||||
{selectedIds.length > 0 && (
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" className="text-red-500 border-red-500 hover:bg-red-500 hover:text-white" disabled={deleting} onClick={handleDeleteSelected}>
|
{selectedIds.length > 0 && (
|
||||||
<Trash2 size={16} className="mr-2" /> Delete ({selectedIds.length})
|
<Button variant="outline" className="text-red-500 border-red-500 hover:bg-red-500 hover:text-white" disabled={deleting} onClick={handleDeleteSelected}>
|
||||||
</Button>
|
<Trash2 size={16} className="mr-2" /> Delete ({selectedIds.length})
|
||||||
)}
|
</Button>
|
||||||
<input type="file" accept=".csv" className="hidden" ref={fileInputRef} onChange={handleBulkUpload} disabled={isUploading} />
|
)}
|
||||||
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" disabled={isUploading} onClick={() => fileInputRef.current?.click()}>
|
<input type="file" accept=".csv" className="hidden" ref={fileInputRef} onChange={handleBulkUpload} disabled={isUploading} />
|
||||||
{isUploading ? <RefreshCw size={16} className="mr-2 animate-spin" /> : <Upload size={16} className="mr-2" />}
|
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" disabled={isUploading} onClick={() => fileInputRef.current?.click()}>
|
||||||
{isUploading ? "Uploading..." : "Bulk Register CSV"}
|
{isUploading ? <RefreshCw size={16} className="mr-2 animate-spin" /> : <Upload size={16} className="mr-2" />}
|
||||||
</Button>
|
{isUploading ? "Uploading..." : "Bulk Register CSV"}
|
||||||
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" onClick={downloadTemplate}>
|
</Button>
|
||||||
<Download size={16} className="mr-2" /> Download Template
|
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" onClick={downloadTemplate}>
|
||||||
</Button>
|
<Download size={16} className="mr-2" /> Download Template
|
||||||
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white">
|
</Button>
|
||||||
<Download size={16} className="mr-2" /> Export CSV
|
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" onClick={exportCSV}>
|
||||||
</Button>
|
<Download size={16} className="mr-2" /> Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground mr-1">Max 500 students per upload</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className="bg-secondary/20 border border-primary/20 rounded-xl p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="font-semibold text-primary">Parsing and registering students...</span>
|
||||||
|
<span className="text-muted-foreground">Estimated time: ~{uploadTimeLeft}s</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all duration-1000 ease-in-out"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-secondary/10 border border-border rounded-xl overflow-hidden backdrop-blur-sm">
|
<div className="bg-secondary/10 border border-border rounded-xl overflow-hidden backdrop-blur-sm">
|
||||||
|
|
||||||
@@ -212,7 +300,6 @@ export default function StudentsPage() {
|
|||||||
>
|
>
|
||||||
<option value="All">All Courses</option>
|
<option value="All">All Courses</option>
|
||||||
<option value="JEE_Main">JEE Main</option>
|
<option value="JEE_Main">JEE Main</option>
|
||||||
<option value="JEE_Advanced">JEE Advanced</option>
|
|
||||||
<option value="NEET">NEET</option>
|
<option value="NEET">NEET</option>
|
||||||
<option value="JEE_NEET">JEE + NEET</option>
|
<option value="JEE_NEET">JEE + NEET</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -336,3 +423,4 @@ export default function StudentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function CBTPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PremiumGate featureKey="weekly-test-series">
|
<>
|
||||||
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -360,6 +360,6 @@ export default function CBTPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PremiumGate>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { fetchWithAuth } from "@/lib/apiClient";
|
import { fetchWithAuth } from "@/lib/apiClient";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRef, useState, useEffect } from "react"
|
import { useRef, useState, useEffect } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -14,6 +15,8 @@ export default function DashboardPage() {
|
|||||||
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
|
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
|
||||||
const [userStats, setUserStats] = useState<any>(null)
|
const [userStats, setUserStats] = useState<any>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [dpps, setDpps] = useState<any[]>([])
|
||||||
|
const [cbts, setCbts] = useState<any[]>([])
|
||||||
const [downloading, setDownloading] = useState(false)
|
const [downloading, setDownloading] = useState(false)
|
||||||
const [shareMenuOpen, setShareMenuOpen] = useState(false)
|
const [shareMenuOpen, setShareMenuOpen] = useState(false)
|
||||||
const progressRef = useRef<HTMLDivElement>(null)
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -25,20 +28,26 @@ export default function DashboardPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
async function fetchStats() {
|
async function fetchStatsAndTests() {
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth(`/api/users/${currentUserId}/stats`)
|
const pattern = user?.target_exam || "JEE_Main";
|
||||||
if (res.ok) {
|
const [statsRes, dppRes, cbtRes] = await Promise.all([
|
||||||
const data = await res.json()
|
fetchWithAuth(`/api/users/${currentUserId}/stats`),
|
||||||
setUserStats(data)
|
fetchWithAuth(`/api/tests?type=DPS&exam_pattern=${pattern}`),
|
||||||
}
|
fetchWithAuth(`/api/tests?type=Mock&exam_pattern=${pattern}`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (statsRes.ok) setUserStats(await statsRes.json());
|
||||||
|
if (dppRes.ok) setDpps((await dppRes.json()).slice(0, 2));
|
||||||
|
if (cbtRes.ok) setCbts((await cbtRes.json()).slice(0, 2));
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load user stats", e)
|
console.error("Failed to load user data", e)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchStats()
|
fetchStatsAndTests()
|
||||||
}, [userLoading, user?.id, userId])
|
}, [userLoading, user?.id, userId])
|
||||||
|
|
||||||
const getShareUrl = () => {
|
const getShareUrl = () => {
|
||||||
@@ -227,61 +236,29 @@ export default function DashboardPage() {
|
|||||||
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
|
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
|
{dpps.length > 0 ? (
|
||||||
<CardContent className="p-5 flex items-center justify-between">
|
dpps.map((dpp) => (
|
||||||
<div className="flex gap-4 items-center">
|
<Card key={dpp.id} className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
|
||||||
<div className="p-3 bg-secondary rounded-xl text-foreground">
|
<CardContent className="p-5 flex items-center justify-between">
|
||||||
<BrainCircuit size={20} />
|
<div className="flex gap-4 items-center">
|
||||||
</div>
|
<div className="p-3 bg-secondary rounded-xl text-foreground">
|
||||||
<div>
|
<BrainCircuit size={20} />
|
||||||
<h3 className="font-medium text-base group-hover:text-primary transition-colors">Physics: Rotational Dynamics</h3>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">20 Questions • High Priority</p>
|
<div>
|
||||||
</div>
|
<h3 className="font-medium text-base group-hover:text-primary transition-colors">{dpp.title}</h3>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground mt-0.5">{dpp.total_time} Minutes • {dpp.difficulty || "Medium"} Priority</p>
|
||||||
<Button variant="outline" size="sm" className="gap-2 rounded-xl group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all">
|
</div>
|
||||||
Start <ArrowRight size={14} />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{hasAccessTo("topic-wise-microtests-unlimited") ? (
|
|
||||||
<Card className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
|
|
||||||
<CardContent className="p-5 flex items-center justify-between">
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<div className="p-3 bg-secondary rounded-xl text-foreground">
|
|
||||||
<BrainCircuit size={20} />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Link href={`/dashboard/dpp`}>
|
||||||
<h3 className="font-medium text-base group-hover:text-primary transition-colors">
|
<Button variant="outline" size="sm" className="gap-2 rounded-xl group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all">
|
||||||
{isNeet ? "Biology: Human Physiology" : "Maths: Integral Calculus"}
|
Start <ArrowRight size={14} />
|
||||||
</h3>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">30 Questions • Medium Priority</p>
|
</Link>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<Button variant="outline" size="sm" className="gap-2 rounded-xl group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all">
|
))
|
||||||
Start <ArrowRight size={14} />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="relative opacity-60 cursor-not-allowed border-dashed">
|
<p className="text-muted-foreground text-sm py-4">No DPPs scheduled for today.</p>
|
||||||
<CardContent className="p-5 flex items-center justify-between">
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<div className="p-3 bg-secondary rounded-xl text-muted-foreground">
|
|
||||||
<Lock size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-base text-muted-foreground">
|
|
||||||
{isNeet ? "Biology: Human Physiology" : "Maths: Integral Calculus"}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5 flex items-center gap-1"><Lock size={12}/> Pro Feature</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" disabled className="gap-2 rounded-xl">
|
|
||||||
Locked
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -293,38 +270,33 @@ export default function DashboardPage() {
|
|||||||
Upcoming CBTs
|
Upcoming CBTs
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Card className="overflow-hidden">
|
<div className="space-y-4">
|
||||||
<div className="p-6 border-b border-border flex items-center justify-between bg-background">
|
{cbts.length > 0 ? (
|
||||||
<div>
|
cbts.map((cbt, idx) => (
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<Card key={cbt.id} className="overflow-hidden">
|
||||||
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">Mock Test</Badge>
|
<div className="p-6 border-b border-border flex items-center justify-between bg-background">
|
||||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Tomorrow, 10:00 AM</span>
|
<div>
|
||||||
</div>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="font-semibold text-lg tracking-tight">{isNeet ? 'NEET Mock Test 4' : 'JEE Mock Test 4'}</h3>
|
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">{cbt.exam_pattern}</Badge>
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
|
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">{cbt.scheduled_date ? new Date(cbt.scheduled_date).toLocaleDateString() : 'Available Now'}</span>
|
||||||
<Clock size={14} /> 180 Minutes • 720 Marks
|
</div>
|
||||||
</p>
|
<h3 className="font-semibold text-lg tracking-tight">{cbt.title}</h3>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
|
||||||
<Button variant="secondary" className="rounded-xl font-medium">Remind Me</Button>
|
<Clock size={14} /> {cbt.total_time} Minutes • {cbt.difficulty || 'Medium'}
|
||||||
</div>
|
</p>
|
||||||
{hasAccessTo("weekly-test-series") ? (
|
</div>
|
||||||
<div className="p-5 bg-secondary/50 flex items-center justify-between transition-colors hover:bg-secondary/70 cursor-pointer group">
|
<Link href={`/dashboard/cbt`}>
|
||||||
<div>
|
<Button variant={idx === 0 ? "secondary" : "outline"} className="rounded-xl font-medium">Start</Button>
|
||||||
<h3 className="font-medium text-sm text-foreground flex items-center gap-2"><Activity size={14} className="text-primary group-hover:animate-pulse"/> Part Test 3: Mechanics</h3>
|
</Link>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 font-medium ml-6">Oct 24, 2026</p>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs rounded-lg group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary">Start <ArrowRight size={12} className="ml-1" /></Button>
|
))
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="p-5 bg-secondary/50 flex items-center justify-between transition-colors hover:bg-secondary/70 opacity-75">
|
<Card className="overflow-hidden p-6 border-dashed opacity-70">
|
||||||
<div>
|
<p className="text-muted-foreground text-sm">No upcoming CBTs at the moment.</p>
|
||||||
<h3 className="font-medium text-sm text-foreground flex items-center gap-2"><Lock size={14} className="text-muted-foreground"/> Part Test 3: Mechanics</h3>
|
</Card>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 font-medium ml-6">Oct 24, 2026</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="text-xs">Pro Only</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,3 +312,4 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user