From 22b167b9858a47fe1b7bce2876a288591cd1dd19 Mon Sep 17 00:00:00 2001 From: ectasy663 Date: Thu, 9 Jul 2026 17:05:40 +0530 Subject: [PATCH] UI/UX Polishing: CBT dashboard, sidebar tiers, landing pricing, and exam disclaimer --- backend/main.py | 70 +++++++- backend/models.py | 1 + src/app/cbt/[examId]/page.tsx | 124 +++++++++---- src/app/dashboard/cbt/page.tsx | 222 +++++++++++++++++------- src/app/dashboard/community/page.tsx | 2 +- src/app/dashboard/dpp/page.tsx | 3 - src/app/dashboard/layout.tsx | 65 ++++--- src/app/dashboard/page.tsx | 16 +- src/app/page.tsx | 79 ++++----- src/app/parent/dashboard/page.tsx | 2 + src/app/upgrade/page.tsx | 46 +++-- src/components/AI/AdaptiveStudyPlan.tsx | 2 + src/components/AI/MistakeHeatmap.tsx | 2 + src/components/CBT/Scratchpad.tsx | 5 +- src/hooks/useActiveUser.ts | 44 ++++- 15 files changed, 481 insertions(+), 202 deletions(-) diff --git a/backend/main.py b/backend/main.py index 255925c..7addfb7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,6 +85,40 @@ def get_test_by_id(test_id: UUID, user_id: UUID, session: Session = Depends(get_ raise HTTPException(status_code=404, detail="Requested examination structure not found.") return test +@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)): + test = session.get(models.Test, test_id) + if not test: + raise HTTPException(status_code=404, detail="Requested examination structure not found.") + + # Extract all question IDs from sections + question_ids = [] + for section_name, section_data in test.sections.items(): + question_ids.extend(section_data.get("questions", [])) + + # Fetch all questions in one query + if not question_ids: + return [] + + # Ensure they are UUID objects + import uuid + valid_ids = [] + for q_id in question_ids: + if isinstance(q_id, str): + try: + valid_ids.append(uuid.UUID(q_id)) + except ValueError: + continue + elif isinstance(q_id, uuid.UUID): + valid_ids.append(q_id) + + query = select(models.Question).where(models.Question.id.in_(valid_ids)) + questions = session.exec(query).all() + + # Order them to match the section order if needed, but the frontend currently doesn't rely on it strictly + # except for section bounds which it reconstructs. + return questions + @app.get("/api/questions/{question_id}", response_model=models.Question, tags=["CBT Engine"]) def get_question_by_id(question_id: UUID, session: Session = Depends(get_session)): question = session.get(models.Question, question_id) @@ -143,7 +177,7 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_ if not question: continue - correct_ans = question.correct_answer + correct_ans = question.correct_answer or {} # Support both {"value": "A"} and {"correct": "A"} key formats correct_value = correct_ans.get("value") or correct_ans.get("correct") @@ -151,8 +185,13 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_ # Subjective: award credit for any non-trivial written answer student_text = str(selected or "").strip() is_correct = len(student_text) > 5 + elif isinstance(correct_value, list): + # Multiple Choice (Multi-Correct) + selected_list = sorted([s.strip() for s in str(selected or "").split(",") if s.strip()]) + correct_list = sorted([str(c).strip() for c in correct_value]) + is_correct = (selected_list == correct_list) else: - is_correct = bool(correct_value) and correct_value == selected + is_correct = bool(correct_value) and str(correct_value).strip() == str(selected or "").strip() if is_correct: correct_count += 1 @@ -241,8 +280,16 @@ def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(ge raise HTTPException(status_code=404, detail="Mistake not found.") question = session.get(models.Question, mistake.question_id) - correct_value = question.correct_answer.get("value") or question.correct_answer.get("correct") - is_correct = correct_value == selected_answer + correct_ans = question.correct_answer or {} + correct_value = correct_ans.get("value") or correct_ans.get("correct") + + if isinstance(correct_value, list): + # Multiple Choice (Multi-Correct) + selected_list = sorted([s.strip() for s in str(selected_answer or "").split(",") if s.strip()]) + correct_list = sorted([str(c).strip() for c in correct_value]) + is_correct = (selected_list == correct_list) + else: + is_correct = bool(correct_value) and str(correct_value).strip() == str(selected_answer or "").strip() q_value = 4 if is_correct else 1 days_since_creation = (datetime.utcnow() - mistake.created_at).days @@ -450,6 +497,21 @@ def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = D session.refresh(user) return user +class UserSessionUpdate(BaseModel): + session_token: str + +@app.post("/api/users/{user_id}/session", response_model=models.User, tags=["Users"]) +def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Session = Depends(get_session)): + user = session.get(models.User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.active_session_token = payload.session_token + session.add(user) + session.commit() + session.refresh(user) + return user + from storage import upload_file @app.post("/api/users/{user_id}/upload-avatar", tags=["Users"]) diff --git a/backend/models.py b/backend/models.py index 7885acb..7ad5384 100644 --- a/backend/models.py +++ b/backend/models.py @@ -62,6 +62,7 @@ class User(SQLModel, table=True): longest_streak: int = Field(default=0) created_at: datetime = Field(default_factory=datetime.utcnow) last_active: datetime = Field(default_factory=datetime.utcnow) + active_session_token: Optional[str] = None class Parent(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) diff --git a/src/app/cbt/[examId]/page.tsx b/src/app/cbt/[examId]/page.tsx index 8548849..64e3a0c 100644 --- a/src/app/cbt/[examId]/page.tsx +++ b/src/app/cbt/[examId]/page.tsx @@ -32,6 +32,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri const resolvedParams = use(params); const examId = resolvedParams.examId; const [mounted, setMounted] = useState(false); + const [examType, setExamType] = useState('Test'); const [showCalc, setShowCalc] = useState(false); const [showScratchpad, setShowScratchpad] = useState(false); const [showPeriodicTable, setShowPeriodicTable] = useState(false); @@ -61,7 +62,10 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri submitAttemptToServer, resetExamState, status, - attemptId + attemptId, + currentQuestionId, + getQuestion, + examState } = useCBTStore(); const { formattedTime, isTimedOut } = useExamTimer(); @@ -75,6 +79,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri 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"); const testData = await testRes.json(); + setExamType(testData.type); // Extract all question IDs from sections const questionIds: string[] = []; @@ -82,23 +87,31 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri questionIds.push(...section.questions); }); - // Fetch full question objects - const fetchedQuestions = await Promise.all( - questionIds.map(async (id, idx) => { - const qRes = await fetch(`${apiUrl}/api/questions/${id}`); - const qData = await qRes.json(); - return { - id: qData.id, - number: idx + 1, - subject: qData.subject, - type: qData.type, - text_en: qData.question_text, - text_hi: qData.question_text_hi || qData.question_text, - options: Object.values(qData.options || {}), - options_hi: Object.values(qData.options_hi || qData.options || {}), - }; - }) - ); + const qRes = await fetch(`${apiUrl}/api/tests/${examId}/questions`); + if (!qRes.ok) throw new Error("Failed to fetch questions in bulk"); + const allQuestionsData = await qRes.json(); + + // Map questions by ID for fast lookup + const questionsMap = new Map(); + allQuestionsData.forEach((q: any) => { + questionsMap.set(q.id, q); + }); + + // Construct fetchedQuestions retaining the exact order of questionIds + const fetchedQuestions = questionIds.map((id, idx) => { + const qData = questionsMap.get(id); + if (!qData) return null; // Fallback in case of mismatch + return { + id: qData.id, + number: idx + 1, + subject: qData.subject, + type: qData.type, + text_en: qData.question_text, + text_hi: qData.question_text_hi || qData.question_text, + options: Object.values(qData.options || {}), + options_hi: Object.values(qData.options_hi || qData.options || {}), + }; + }).filter(Boolean) as any[]; initExam(examId, fetchedQuestions, testData.total_time * 60); setMounted(true); @@ -221,6 +234,10 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri +
+ Disclaimer: Only the score obtained in your first attempt will be considered for growth tracking and performance evaluation. Any subsequent attempts are intended solely for practice and will not be included in the official assessment. +
+

Preferred Theme

@@ -247,19 +264,34 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri { + onRetake={examType === 'DPS' ? () => { resetExamState(); window.location.reload(); - }} + } : undefined} /> ) } - const currentQuestion = questions[currentQuestionIndex]; - const currentState = answers[currentQuestion.id]; + const currentQuestion = getQuestion(currentQuestionId) + const currentState = currentQuestion ? examState?.answers[currentQuestion.id] : null - // Derive palette stats - const paletteStats = Object.values(answers).reduce( + const handleOptionSelect = (qId: string, letter: string, type: string) => { + if (type === 'Multiple_Choice') { + const currentSelected = currentState?.selected_answer || ""; + const selectedArray = currentSelected.split(',').filter(Boolean); + if (selectedArray.includes(letter)) { + const newArray = selectedArray.filter((l: string) => l !== letter).sort(); + selectAnswer(qId, newArray.length > 0 ? newArray.join(',') : null); + } else { + selectedArray.push(letter); + selectAnswer(qId, selectedArray.sort().join(',')); + } + } else { + selectAnswer(qId, letter); + } + } + + // Effect to close overlay tools on EscapealetteStats = Object.values(answers).reduce( (acc, val) => { acc[val.status]++; return acc; @@ -322,9 +354,9 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
- - - + + +
@@ -368,7 +400,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
- {(currentQuestion.type === 'Subjective' || currentQuestion.type === 'Numerical') ? ( + {currentQuestion.type === 'Numerical' ? ( + selectAnswer(currentQuestion.id, e.target.value)} + /> + ) : currentQuestion.type === 'Subjective' ? (