fix(dashboard): DPP tags properly dynamic based on user target exam
This commit is contained in:
BIN
backend.log
Normal file
BIN
backend.log
Normal file
Binary file not shown.
65
backend/fix.py
Normal file
65
backend/fix.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import sys
|
||||
sys.path.append('/app')
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
import models
|
||||
import re
|
||||
|
||||
def fix_text(text):
|
||||
if not text: return text
|
||||
|
||||
# The DB currently has string literals with backslash caret 3 d backslash dollar.
|
||||
# Like '\\^3d\\$' and '\\^3d^2\\$'
|
||||
|
||||
text = text.replace('\\^3d^2\\$', '$sp^3d^2$')
|
||||
text = text.replace('\\^3d\\$', '$sp^3d$')
|
||||
text = text.replace('\\^3\\$', '$sp^3$')
|
||||
text = text.replace('\\^2\\$', '$sp^2$')
|
||||
|
||||
text = text.replace('^3d^2$', '$sp^3d^2$')
|
||||
text = text.replace('^3d$', '$sp^3d$')
|
||||
text = text.replace('^3$', '$sp^3$')
|
||||
text = text.replace('^2$', '$sp^2$')
|
||||
|
||||
# Original targets in case we run on fresh seed
|
||||
text = re.sub(r'\bsp3d2\b', r'$sp^3d^2$', text)
|
||||
text = re.sub(r'\bsp3d\b', r'$sp^3d$', text)
|
||||
text = re.sub(r'\bsp3\b', r'$sp^3$', text)
|
||||
text = re.sub(r'\bsp2\b', r'$sp^2$', text)
|
||||
|
||||
mols = ['PCl5', 'SF6', 'H2O', 'NH3', 'CO2', 'CH4', 'C2H4', 'C2H2', 'BF3', 'BeCl2', 'O2', 'N2', 'F2']
|
||||
for mol in mols:
|
||||
formatted = '$' + re.sub(r'(\d+)', r'_\1', mol) + '$'
|
||||
text = re.sub(r'\b' + mol + r'\b', formatted, text)
|
||||
|
||||
text = text.replace('z1 = 1 + i', '$z_1 = 1 + i$')
|
||||
text = text.replace('z2 = 2 − i', '$z_2 = 2 - i$')
|
||||
text = text.replace('z1\nz2', '$\\frac{z_1}{z_2}$')
|
||||
text = text.replace('z−1\nz+1', '$\\frac{z-1}{z+1}$')
|
||||
text = text.replace('z2 = ¯z', '$z^2 = \\bar{z}$')
|
||||
text = text.replace('x2 − 3x + 5 = 0', '$x^2 - 3x + 5 = 0$')
|
||||
text = text.replace('x2 + 2x + 5 = 0', '$x^2 + 2x + 5 = 0$')
|
||||
text = text.replace('1+i\n1−i', '$\\frac{1+i}{1-i}$')
|
||||
text = text.replace('z + 1\nz', '$\\frac{z+1}{z}$')
|
||||
text = text.replace('z − 1\nz i', '$\\frac{z-1}{z i}$')
|
||||
text = text.replace('30◦', '30°')
|
||||
text = text.replace('⃗v = 3', '$\\vec{v} = 3$')
|
||||
|
||||
text = text.replace('σ bonds', '$\\sigma$ bonds')
|
||||
text = text.replace('π bonds', '$\\pi$ bonds')
|
||||
|
||||
return text
|
||||
|
||||
with Session(engine) as session:
|
||||
questions = session.exec(select(models.Question)).all()
|
||||
count = 0
|
||||
for q in questions:
|
||||
old_text = q.question_text
|
||||
new_text = fix_text(old_text)
|
||||
if old_text != new_text:
|
||||
q.question_text = new_text
|
||||
session.add(q)
|
||||
count += 1
|
||||
print(f'Updated Q {q.id}')
|
||||
session.commit()
|
||||
print(f'Successfully updated {count} questions.')
|
||||
116
backend/fix_from_source.py
Normal file
116
backend/fix_from_source.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import sys
|
||||
sys.path.append('/app')
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
import models
|
||||
import glob
|
||||
import os
|
||||
import fitz
|
||||
import re
|
||||
|
||||
def parse_pdf(filepath):
|
||||
doc = fitz.open(filepath)
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += page.get_text() + "\n"
|
||||
|
||||
q_block = text.split("Questions\n")[-1].split("Answer Key\n")[0]
|
||||
q_pattern = re.compile(r'^(\d+)\.\s+(.*?)(?=^\d+\.\s+|\Z)', re.DOTALL | re.MULTILINE)
|
||||
|
||||
questions_dict = {}
|
||||
for match in q_pattern.finditer(q_block):
|
||||
q_num = int(match.group(1))
|
||||
if 1 <= q_num <= 15:
|
||||
questions_dict[q_num] = match.group(2).strip()
|
||||
|
||||
answers_dict = {}
|
||||
if "Answer Key\n" in text:
|
||||
a_block = text.split("Answer Key\n")[-1]
|
||||
a_pattern = re.compile(r'^(\d+)\s+(.*?)(?=^\d+\s+|\Z)', re.DOTALL | re.MULTILINE)
|
||||
for match in a_pattern.finditer(a_block):
|
||||
q_num = int(match.group(1))
|
||||
if 1 <= q_num <= 15:
|
||||
if q_num not in answers_dict:
|
||||
answers_dict[q_num] = match.group(2).strip()
|
||||
|
||||
return questions_dict, answers_dict
|
||||
|
||||
def format_text(text):
|
||||
if not text: return text
|
||||
|
||||
# Original targets
|
||||
text = re.sub(r'\bsp3d2\b', r'$sp^3d^2$', text)
|
||||
text = re.sub(r'\bsp3d\b', r'$sp^3d$', text)
|
||||
text = re.sub(r'\bsp3\b', r'$sp^3$', text)
|
||||
text = re.sub(r'\bsp2\b', r'$sp^2$', text)
|
||||
|
||||
def repl(m):
|
||||
word = m.group(0)
|
||||
if word.lower() in ["sp2", "sp3", "sp3d", "sp3d2", "3d", "a1", "b1", "c1"]:
|
||||
return word
|
||||
return '$' + re.sub(r'(\d+)', r'_\1', word) + '$'
|
||||
|
||||
text = re.sub(r'\b[A-Z][a-z]*[A-Z]*[a-z]*\d+[A-Za-z0-9]*\b', repl, text)
|
||||
|
||||
text = text.replace('z1 = 1 + i', '$z_1 = 1 + i$')
|
||||
text = text.replace('z2 = 2 − i', '$z_2 = 2 - i$')
|
||||
text = text.replace('z1\nz2', '$\\frac{z_1}{z_2}$')
|
||||
text = text.replace('z−1\nz+1', '$\\frac{z-1}{z+1}$')
|
||||
text = text.replace('z2 = ¯z', '$z^2 = \\bar{z}$')
|
||||
text = text.replace('x2 − 3x + 5 = 0', '$x^2 - 3x + 5 = 0$')
|
||||
text = text.replace('x2 + 2x + 5 = 0', '$x^2 + 2x + 5 = 0$')
|
||||
text = text.replace('1+i\n1−i', '$\\frac{1+i}{1-i}$')
|
||||
text = text.replace('z + 1\nz', '$\\frac{z+1}{z}$')
|
||||
text = text.replace('z − 1\nz i', '$\\frac{z-1}{z i}$')
|
||||
text = text.replace('30◦', '30°')
|
||||
text = text.replace('⃗v = 3', '$\\vec{v} = 3$')
|
||||
|
||||
text = text.replace('σ bonds', '$\\sigma$ bonds')
|
||||
text = text.replace('π bonds', '$\\pi$ bonds')
|
||||
|
||||
return text
|
||||
|
||||
def fix_all_from_source():
|
||||
pdf_files = glob.glob('/app/pdfs_to_parse/*.pdf')
|
||||
with Session(engine) as session:
|
||||
for pdf_path in pdf_files:
|
||||
filename = os.path.basename(pdf_path)
|
||||
subject = "Physics"
|
||||
topic = "General"
|
||||
if "phy" in filename.lower():
|
||||
subject = "Physics"
|
||||
topic = "Laws of Motion"
|
||||
elif "che" in filename.lower():
|
||||
subject = "Chemistry"
|
||||
topic = "Chemical Bonding"
|
||||
elif "mth" in filename.lower():
|
||||
subject = "Mathematics"
|
||||
topic = "Calculus"
|
||||
elif "bio" in filename.lower():
|
||||
subject = "Biology"
|
||||
topic = "Genetics"
|
||||
|
||||
q_dict, a_dict = parse_pdf(pdf_path)
|
||||
|
||||
# Find the test for this topic
|
||||
test = session.exec(select(models.Test).where(models.Test.title == f"DPP: {subject} - {topic}")).first()
|
||||
if not test:
|
||||
continue
|
||||
|
||||
q_ids = test.sections.get("Section 1", {}).get("questions", [])
|
||||
for i, q_id in enumerate(q_ids):
|
||||
q_num = i + 1
|
||||
if q_num in q_dict:
|
||||
raw_text = q_dict[q_num].replace('\x00', '')
|
||||
formatted = format_text(raw_text)
|
||||
|
||||
q = session.exec(select(models.Question).where(models.Question.id == q_id)).first()
|
||||
if q:
|
||||
q.question_text = formatted
|
||||
session.add(q)
|
||||
|
||||
session.commit()
|
||||
print("Successfully updated questions from source.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
fix_all_from_source()
|
||||
59
backend/fix_ions.py
Normal file
59
backend/fix_ions.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import sys
|
||||
sys.path.append('/app')
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
import models
|
||||
|
||||
def fix_text(text):
|
||||
if not text: return text
|
||||
|
||||
# Fix NO3- where 3 is on the next line
|
||||
text = text.replace('NO−\n3', '$NO_3^-$')
|
||||
text = text.replace('NO-\n3', '$NO_3^-$')
|
||||
|
||||
text = text.replace('CO2−\n3', '$CO_3^{2-}$')
|
||||
text = text.replace('CO2-\n3', '$CO_3^{2-}$')
|
||||
|
||||
text = text.replace('SO2−\n4', '$SO_4^{2-}$')
|
||||
text = text.replace('SO2-\n4', '$SO_4^{2-}$')
|
||||
|
||||
text = text.replace('PO3−\n4', '$PO_4^{3-}$')
|
||||
text = text.replace('PO3-\n4', '$PO_4^{3-}$')
|
||||
|
||||
# Also generic charges if they are on same line
|
||||
text = text.replace('NO3-', '$NO_3^-$')
|
||||
text = text.replace('CO32-', '$CO_3^{2-}$')
|
||||
text = text.replace('SO42-', '$SO_4^{2-}$')
|
||||
text = text.replace('PO43-', '$PO_4^{3-}$')
|
||||
text = text.replace('O2-', '$O^{2-}$')
|
||||
text = text.replace('F-', '$F^-$')
|
||||
text = text.replace('Cl-', '$Cl^-$')
|
||||
text = text.replace('Br-', '$Br^-$')
|
||||
text = text.replace('I-', '$I^-$')
|
||||
text = text.replace('Na+', '$Na^+$')
|
||||
text = text.replace('K+', '$K^+$')
|
||||
text = text.replace('Ca2+', '$Ca^{2+}$')
|
||||
text = text.replace('Mg2+', '$Mg^{2+}$')
|
||||
text = text.replace('Fe2+', '$Fe^{2+}$')
|
||||
text = text.replace('Fe3+', '$Fe^{3+}$')
|
||||
text = text.replace('Cu2+', '$Cu^{2+}$')
|
||||
text = text.replace('Al3+', '$Al^{3+}$')
|
||||
|
||||
# Check if we have O3 (ozone) that wasn't replaced
|
||||
text = text.replace('O3', '$O_3$')
|
||||
|
||||
return text
|
||||
|
||||
with Session(engine) as session:
|
||||
questions = session.exec(select(models.Question)).all()
|
||||
count = 0
|
||||
for q in questions:
|
||||
old_text = q.question_text
|
||||
new_text = fix_text(old_text)
|
||||
if old_text != new_text:
|
||||
q.question_text = new_text
|
||||
session.add(q)
|
||||
count += 1
|
||||
print(f'Updated Q {q.id}')
|
||||
session.commit()
|
||||
print(f'Successfully updated {count} questions for ions.')
|
||||
304
backend/main.py
304
backend/main.py
@@ -5,7 +5,7 @@ from sqlmodel import Session, select
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, date
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import init_db, get_session
|
||||
@@ -14,13 +14,11 @@ from ai_coach import calculate_sm2
|
||||
from analytics import generate_parent_report
|
||||
from billing import generate_mock_order, verify_webhook_signature
|
||||
from llm import stream_tutor_response
|
||||
from analytics import generate_parent_report
|
||||
from predictor import calculate_readiness_score
|
||||
from growth import generate_referral_link, process_referral_reward
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Initialize database tables locally when the container boots up
|
||||
init_db()
|
||||
yield
|
||||
|
||||
@@ -30,7 +28,6 @@ app = FastAPI(
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Enable immediate communication with your Next.js frontend local environment
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
@@ -46,14 +43,10 @@ def health_check():
|
||||
# --- ACCESS CONTROL DEPENDENCY ---
|
||||
|
||||
def require_tier(allowed_tiers: List[models.TierEnum]):
|
||||
"""
|
||||
Dependency to block unauthorized access to premium routes.
|
||||
"""
|
||||
def tier_checker(user_id: UUID, session: Session = Depends(get_session)):
|
||||
user = session.get(models.User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found.")
|
||||
|
||||
if user.tier not in allowed_tiers:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -62,19 +55,18 @@ def require_tier(allowed_tiers: List[models.TierEnum]):
|
||||
return user
|
||||
return tier_checker
|
||||
|
||||
# --- BASELINE AUTHENTICATION MOCKS FOR FRONTEND UI VERIFICATION ---
|
||||
# --- AUTHENTICATION ---
|
||||
|
||||
@app.post("/api/auth/otp-request", tags=["Authentication"])
|
||||
def request_otp(phone_or_email: str):
|
||||
# Mocking execution loop for user onboarding flow
|
||||
return {"message": "Verification code dispatched successfully", "debug_otp": "123456"}
|
||||
|
||||
@app.post("/api/auth/otp-verify", tags=["Authentication"])
|
||||
def verify_otp(phone_or_email: str, code: str, session: Session = Depends(get_session)):
|
||||
# Add your logic matching the registration & profile onboarding flow later
|
||||
return {"status": "authenticated", "token": "mock_jwt_session_token_string"}
|
||||
|
||||
# --- CBT TEST ENVIRONMENT CONTENT DELIVERY ENDPOINTS ---
|
||||
# --- 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)):
|
||||
query = select(models.Test).where(models.Test.is_active == True)
|
||||
@@ -83,13 +75,11 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] =
|
||||
if type:
|
||||
query = query.where(models.Test.type == type)
|
||||
else:
|
||||
# If type isn't specified, don't return DPS in regular tests query
|
||||
query = query.where(models.Test.type != "DPS")
|
||||
return session.exec(query).all()
|
||||
|
||||
|
||||
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
|
||||
def get_test_by_id(test_id: UUID, user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
||||
def get_test_by_id(test_id: UUID, user_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.")
|
||||
@@ -105,42 +95,65 @@ def get_question_by_id(question_id: UUID, session: Session = Depends(get_session
|
||||
@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)):
|
||||
user_id = payload.get("user_id")
|
||||
source_type = payload.get("source_type")
|
||||
source_type_raw = payload.get("source_type", "Test")
|
||||
source_id = payload.get("source_id")
|
||||
client_answers = payload.get("answers", []) # List of dicts: {"question_id": "...", "selected_answer": "..."}
|
||||
client_answers = payload.get("answers", [])
|
||||
|
||||
if not user_id or not source_id:
|
||||
raise HTTPException(status_code=400, detail="Missing required deployment metadata parameters.")
|
||||
|
||||
# 0. Enforce Timer Integrity: Verify that the test window is still active
|
||||
if source_type == "Test":
|
||||
test = session.get(models.Test, source_id)
|
||||
if test and test.scheduled_date:
|
||||
# Removed strict time cutoff for development/testing
|
||||
pass
|
||||
# Normalize source_type string to match SourceTypeEnum values
|
||||
_st_map = {"dps": "DPS", "test": "Test", "microtest": "MicroTest"}
|
||||
source_type = _st_map.get(str(source_type_raw).lower(), "Test")
|
||||
|
||||
# 1. Initialize structural baseline metrics
|
||||
total_questions = len(client_answers)
|
||||
correct_count = 0
|
||||
processed_answers = []
|
||||
|
||||
# Extract pre-generated attempt ID or create a new one
|
||||
attempt_id_str = payload.get("id")
|
||||
import uuid
|
||||
attempt_id = uuid.UUID(attempt_id_str) if attempt_id_str else uuid.uuid4()
|
||||
import uuid as _uuid
|
||||
attempt_id = _uuid.UUID(attempt_id_str) if attempt_id_str else _uuid.uuid4()
|
||||
|
||||
attempt_record = models.Attempt(
|
||||
id=attempt_id,
|
||||
user_id=user_id,
|
||||
source_type=models.SourceTypeEnum(source_type),
|
||||
source_id=source_id,
|
||||
started_at=datetime.utcnow() - timedelta(seconds=payload.get("time_taken", 0)),
|
||||
submitted_at=datetime.utcnow(),
|
||||
time_taken=payload.get("time_taken", 0),
|
||||
answers=[],
|
||||
score=0,
|
||||
total_questions=total_questions,
|
||||
accuracy=0.0,
|
||||
status="Submitted"
|
||||
)
|
||||
session.add(attempt_record)
|
||||
session.flush()
|
||||
|
||||
# 2. Secure Server-Side Evaluation Loop
|
||||
for item in client_answers:
|
||||
q_id = item.get("question_id")
|
||||
selected = item.get("selected_answer")
|
||||
is_subjective = item.get("is_subjective", False)
|
||||
|
||||
# Fetch actual answer key securely from PostgreSQL (Do not trust frontend state values)
|
||||
question = session.get(models.Question, q_id)
|
||||
try:
|
||||
question = session.get(models.Question, q_id)
|
||||
except Exception:
|
||||
continue
|
||||
if not question:
|
||||
continue
|
||||
|
||||
# Check correctness against JSON database configurations
|
||||
is_correct = question.correct_answer.get("correct") == selected
|
||||
correct_ans = question.correct_answer
|
||||
# Support both {"value": "A"} and {"correct": "A"} key formats
|
||||
correct_value = correct_ans.get("value") or correct_ans.get("correct")
|
||||
|
||||
if is_subjective:
|
||||
# Subjective: award credit for any non-trivial written answer
|
||||
student_text = str(selected or "").strip()
|
||||
is_correct = len(student_text) > 5
|
||||
else:
|
||||
is_correct = bool(correct_value) and correct_value == selected
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
|
||||
@@ -148,59 +161,48 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
|
||||
"question_id": str(q_id),
|
||||
"selected_answer": selected,
|
||||
"is_correct": is_correct,
|
||||
"is_subjective": is_subjective,
|
||||
"time_spent": item.get("time_spent", 0)
|
||||
})
|
||||
|
||||
# 3. AI Coach Processing: If the answer is incorrect, extract it to the Mistake queue
|
||||
if not is_correct:
|
||||
# Check if this user has failed this specific question before to track execution loops
|
||||
existing_mistake = session.exec(
|
||||
select(models.Mistake).where(
|
||||
models.Mistake.user_id == user_id,
|
||||
models.Mistake.question_id == q_id
|
||||
)
|
||||
).first()
|
||||
# Track mistakes only for objective wrong answers
|
||||
if not is_correct and not is_subjective:
|
||||
try:
|
||||
existing_mistake = session.exec(
|
||||
select(models.Mistake).where(
|
||||
models.Mistake.user_id == user_id,
|
||||
models.Mistake.question_id == q_id
|
||||
)
|
||||
).first()
|
||||
except Exception:
|
||||
existing_mistake = None
|
||||
|
||||
if existing_mistake:
|
||||
# User failed it again: reset repetitions, assign low quality score (q=1)
|
||||
interval, ef = calculate_sm2(q=1, repetitions=existing_mistake.review_count, ease_factor=2.5, previous_interval=1)
|
||||
existing_mistake.review_count += 1
|
||||
existing_mistake.next_review = datetime.utcnow() + timedelta(days=interval)
|
||||
existing_mistake.consecutive_correct = 0
|
||||
session.add(existing_mistake)
|
||||
else:
|
||||
# Brand new mistake: enter into the spaced repetition queue for the first time
|
||||
new_mistake = models.Mistake(
|
||||
user_id=user_id,
|
||||
question_id=q_id,
|
||||
attempt_id=attempt_id, # Linking back to original source file attempt mapping
|
||||
error_type=models.ErrorTypeEnum.CONCEPTUAL, # Defaults to conceptual until AI tags it
|
||||
attempt_id=attempt_id,
|
||||
error_type=models.ErrorTypeEnum.CONCEPTUAL,
|
||||
topic=question.topic,
|
||||
subtopic=question.subtopic,
|
||||
next_review=datetime.utcnow() + timedelta(days=1), # Schedule for tomorrow review loop
|
||||
next_review=datetime.utcnow() + timedelta(days=1),
|
||||
review_count=1,
|
||||
consecutive_correct=0,
|
||||
is_mastered=False
|
||||
)
|
||||
session.add(new_mistake)
|
||||
|
||||
# 4. Save Core Attempt Records
|
||||
accuracy = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0
|
||||
|
||||
attempt_record = models.Attempt(
|
||||
id=attempt_id,
|
||||
user_id=user_id,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
started_at=datetime.utcnow() - timedelta(seconds=payload.get("time_taken", 0)),
|
||||
submitted_at=datetime.utcnow(),
|
||||
time_taken=payload.get("time_taken", 0),
|
||||
answers=processed_answers,
|
||||
score=correct_count,
|
||||
total_questions=total_questions,
|
||||
accuracy=accuracy,
|
||||
status="Submitted"
|
||||
)
|
||||
attempt_record.answers = processed_answers
|
||||
attempt_record.score = correct_count
|
||||
attempt_record.accuracy = accuracy
|
||||
|
||||
session.add(attempt_record)
|
||||
session.commit()
|
||||
@@ -208,11 +210,10 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
|
||||
|
||||
return attempt_record
|
||||
|
||||
# --- AI COACH SPАСED REPETITION ENDPOINTS ---
|
||||
# --- AI COACH SPACED REPETITION ENDPOINTS ---
|
||||
|
||||
@app.get("/api/mistakes/today", tags=["AI Coach"])
|
||||
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)):
|
||||
# Retrieve all un-mastered mistakes for the user scheduled on or before today
|
||||
now = datetime.utcnow()
|
||||
statement = select(models.Mistake, models.Question).join(
|
||||
models.Question, models.Mistake.question_id == models.Question.id
|
||||
@@ -222,13 +223,9 @@ def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)):
|
||||
models.Mistake.next_review <= now
|
||||
)
|
||||
results = session.exec(statement).all()
|
||||
|
||||
response_data = []
|
||||
for mistake, question in results:
|
||||
response_data.append({
|
||||
"mistake": mistake,
|
||||
"question": question
|
||||
})
|
||||
response_data.append({"mistake": mistake, "question": question})
|
||||
return response_data
|
||||
|
||||
@app.post("/api/mistakes/review", tags=["AI Coach"])
|
||||
@@ -244,16 +241,12 @@ 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
|
||||
|
||||
is_correct = question.correct_answer.get("correct") == selected_answer
|
||||
|
||||
# Calculate intervals
|
||||
q_value = 4 if is_correct else 1
|
||||
|
||||
# Very basic interval calculation for demo
|
||||
days_since_creation = (datetime.utcnow() - mistake.created_at).days
|
||||
prev_interval = max(1, days_since_creation)
|
||||
|
||||
interval, ef = calculate_sm2(q=q_value, repetitions=mistake.review_count, ease_factor=2.5, previous_interval=prev_interval)
|
||||
|
||||
if is_correct:
|
||||
@@ -270,13 +263,12 @@ def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(ge
|
||||
session.commit()
|
||||
session.refresh(mistake)
|
||||
|
||||
return {"mistake": mistake, "is_correct": is_correct, "correct_answer": question.correct_answer.get("correct")}
|
||||
return {"mistake": mistake, "is_correct": is_correct, "correct_answer": correct_value}
|
||||
|
||||
# --- PARENT SUBSYSTEM ENDPOINTS ---
|
||||
# --- PARENT SUBSYSTEM ---
|
||||
|
||||
@app.post("/api/parent/register", response_model=models.Parent, tags=["Parent Subsystem"])
|
||||
def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)):
|
||||
# Persist parent baseline identity profile
|
||||
session.add(parent_data)
|
||||
session.commit()
|
||||
session.refresh(parent_data)
|
||||
@@ -288,7 +280,6 @@ def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent account context not found.")
|
||||
|
||||
# Locate the target student account by phone or email criteria
|
||||
student = session.exec(
|
||||
select(models.User).where(
|
||||
(models.User.phone == student_phone_or_email) |
|
||||
@@ -299,7 +290,6 @@ def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session
|
||||
if not student:
|
||||
raise HTTPException(status_code=404, detail="Target student profile could not be verified.")
|
||||
|
||||
# Append the student's UUID string to the parent's linked array
|
||||
current_links = list(parent.linked_students or [])
|
||||
if str(student.id) not in current_links:
|
||||
current_links.append(str(student.id))
|
||||
@@ -315,16 +305,13 @@ def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Ses
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.")
|
||||
|
||||
# Security check: Ensure this parent is explicitly authorized to view this student's data
|
||||
if str(student_id) not in (parent.linked_students or []):
|
||||
raise HTTPException(status_code=403, detail="Access denied to requested student dashboard data.")
|
||||
|
||||
report = generate_parent_report(student_id, session)
|
||||
return report
|
||||
|
||||
|
||||
|
||||
# --- BILLING & SUBSCRIPTION ENDPOINTS ---
|
||||
# --- BILLING & SUBSCRIPTION ---
|
||||
|
||||
@app.post("/api/billing/create-order", tags=["Subscription Engine"])
|
||||
def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)):
|
||||
@@ -332,12 +319,7 @@ def create_subscription_order(user_id: UUID, plan_tier: str, session: Session =
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found.")
|
||||
|
||||
# Match PRD pricing parameters (amounts in paise)
|
||||
pricing_map = {
|
||||
"Pro": 29900, # ₹299/mo
|
||||
"Al_Coach": 59900 # ₹599/mo
|
||||
}
|
||||
|
||||
pricing_map = {"Pro": 29900, "Al_Coach": 59900}
|
||||
amount = pricing_map.get(plan_tier)
|
||||
if not amount:
|
||||
raise HTTPException(status_code=400, detail="Invalid subscription tier requested.")
|
||||
@@ -347,21 +329,16 @@ def create_subscription_order(user_id: UUID, plan_tier: str, session: Session =
|
||||
|
||||
@app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"])
|
||||
async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)):
|
||||
"""
|
||||
Receives server-to-server notifications when a payment succeeds.
|
||||
"""
|
||||
payload_bytes = await request.body()
|
||||
payload_str = payload_bytes.decode('utf-8')
|
||||
payload_str = payload_bytes.decode("utf-8")
|
||||
signature = request.headers.get("X-Razorpay-Signature", "")
|
||||
|
||||
# Security Check: Verify signature
|
||||
if not verify_webhook_signature(payload_str, signature):
|
||||
raise HTTPException(status_code=400, detail="Invalid webhook signature.")
|
||||
|
||||
import json
|
||||
data = json.loads(payload_str)
|
||||
|
||||
# Process the successful payment event
|
||||
if data.get("event") == "payment.captured":
|
||||
notes = data["payload"]["payment"]["entity"]["notes"]
|
||||
user_id = notes.get("user_id")
|
||||
@@ -369,12 +346,8 @@ async def razorpay_webhook_handler(request: Request, session: Session = Depends(
|
||||
|
||||
user = session.get(models.User, user_id)
|
||||
if user and tier_requested:
|
||||
# Upgrade the user's tier in the database
|
||||
user.tier = models.TierEnum(tier_requested)
|
||||
session.add(user)
|
||||
|
||||
# Log the subscription record
|
||||
from datetime import timedelta
|
||||
new_sub = models.Subscription(
|
||||
user_id=user.id,
|
||||
tier=models.TierEnum(tier_requested),
|
||||
@@ -390,11 +363,10 @@ async def razorpay_webhook_handler(request: Request, session: Session = Depends(
|
||||
|
||||
return {"status": "Webhook processed successfully"}
|
||||
|
||||
# --- STUDY PLAN ENDPOINTS ---
|
||||
from datetime import date
|
||||
# --- STUDY PLAN ---
|
||||
|
||||
@app.get("/api/ai-coach/study-plan/today", tags=["AI Coach"])
|
||||
def get_todays_study_plan(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||
def get_todays_study_plan(user_id: UUID, session: Session = Depends(get_session), current_user=Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||
today = datetime.utcnow().date()
|
||||
plan = session.exec(
|
||||
select(models.StudyPlan)
|
||||
@@ -413,32 +385,22 @@ class ChatRequest(BaseModel):
|
||||
|
||||
@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"])
|
||||
async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)):
|
||||
"""
|
||||
Streams the context-aware LLM response back to the client.
|
||||
"""
|
||||
user = session.get(models.User, request.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Student profile not found.")
|
||||
if user.tier != models.TierEnum.AI_COACH:
|
||||
raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.")
|
||||
|
||||
# Enforce Daily Limit (50 messages) as per PRD A-04
|
||||
# (In a full production build, you'd increment a Redis counter here)
|
||||
|
||||
return StreamingResponse(
|
||||
stream_tutor_response(request.user_id, request.message, session),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
|
||||
@app.get("/api/ai-coach/readiness/{user_id}", tags=["AI Coach"])
|
||||
def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||
"""
|
||||
Returns the dynamic 0-100 Exam Readiness Score and College Prediction.
|
||||
"""
|
||||
def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), current_user=Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||
readiness_data = calculate_readiness_score(user_id, session)
|
||||
if not readiness_data:
|
||||
raise HTTPException(status_code=404, detail="Could not calculate readiness score.")
|
||||
|
||||
return readiness_data
|
||||
|
||||
@app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"])
|
||||
@@ -448,15 +410,15 @@ def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session
|
||||
raise HTTPException(status_code=404, detail="User not found.")
|
||||
|
||||
link = generate_referral_link(user_id)
|
||||
|
||||
return {
|
||||
"referral_link": link,
|
||||
"total_referrals": 0, # In production, query a referral tracking table
|
||||
"total_referrals": 0,
|
||||
"rewards_earned_days": 0,
|
||||
"share_message": f"I'm on a {user.streak_count}-day study streak on PragatiVeer! Use my link to get 7 days of the AI Coach for free: {link}"
|
||||
}
|
||||
|
||||
# --- USER CRUD & TEST LISTING ---
|
||||
# --- USERS ---
|
||||
|
||||
@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
||||
def get_user_profile(user_id: UUID, session: Session = Depends(get_session)):
|
||||
user = session.get(models.User, user_id)
|
||||
@@ -477,23 +439,17 @@ def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = D
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if payload.name is not None:
|
||||
user.name = payload.name
|
||||
if payload.target_exam is not None:
|
||||
user.target_exam = payload.target_exam
|
||||
if payload.student_class is not None:
|
||||
user.student_class = payload.student_class
|
||||
if payload.school is not None:
|
||||
user.school = payload.school
|
||||
if payload.state is not None:
|
||||
user.state = payload.state
|
||||
if payload.name is not None: user.name = payload.name
|
||||
if payload.target_exam is not None: user.target_exam = payload.target_exam
|
||||
if payload.student_class is not None: user.student_class = payload.student_class
|
||||
if payload.school is not None: user.school = payload.school
|
||||
if payload.state is not None: user.state = payload.state
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
from fastapi import UploadFile, File
|
||||
from storage import upload_file
|
||||
|
||||
@app.post("/api/users/{user_id}/upload-avatar", tags=["Users"])
|
||||
@@ -512,20 +468,11 @@ async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), sessio
|
||||
|
||||
return {"url": public_url}
|
||||
|
||||
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
||||
def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)):
|
||||
query = select(models.Test)
|
||||
if exam_pattern is not None:
|
||||
query = query.where(models.Test.exam_pattern == exam_pattern)
|
||||
return session.exec(query).all()
|
||||
|
||||
@app.get("/api/users/{user_id}/attempts", tags=["Users"])
|
||||
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
|
||||
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
|
||||
return session.exec(query).all()
|
||||
|
||||
|
||||
|
||||
# --- ADMIN ROUTES ---
|
||||
|
||||
@app.get("/api/admin/tests", tags=["Admin"])
|
||||
@@ -533,6 +480,28 @@ def get_admin_tests(session: Session = Depends(get_session)):
|
||||
tests = session.exec(select(models.Test)).all()
|
||||
return tests
|
||||
|
||||
class ManualQuestionInput(BaseModel):
|
||||
section: str
|
||||
text_en: str
|
||||
text_hi: Optional[str] = None
|
||||
options_en: List[str]
|
||||
options_hi: Optional[List[str]] = None
|
||||
correct_answer: str
|
||||
type: str = "SingleChoice"
|
||||
topic: str = "General"
|
||||
difficulty: str = "Medium"
|
||||
marks: int = 4
|
||||
negative_marks: int = 1
|
||||
|
||||
class ManualTestCreate(BaseModel):
|
||||
title: str
|
||||
exam_pattern: str
|
||||
time_limit: int
|
||||
scheduled_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
admin_id: UUID
|
||||
questions_json: List[ManualQuestionInput]
|
||||
|
||||
@app.post("/api/admin/tests/upload", tags=["Admin"])
|
||||
async def upload_admin_test(
|
||||
file: UploadFile = File(...),
|
||||
@@ -553,7 +522,6 @@ async def upload_admin_test(
|
||||
if not url:
|
||||
raise HTTPException(status_code=500, detail="Failed to upload PDF to MinIO")
|
||||
|
||||
# Create inactive test
|
||||
test_obj = models.Test(
|
||||
title=title,
|
||||
type="Mock",
|
||||
@@ -562,14 +530,13 @@ async def upload_admin_test(
|
||||
total_time=time_limit,
|
||||
difficulty="Medium",
|
||||
scheduled_date=scheduled_date,
|
||||
is_active=False, # Inactive until processing finishes
|
||||
is_active=False,
|
||||
created_by=admin_id
|
||||
)
|
||||
session.add(test_obj)
|
||||
session.commit()
|
||||
session.refresh(test_obj)
|
||||
|
||||
# Trigger async background worker
|
||||
process_pdf_test.delay(
|
||||
test_id_str=str(test_obj.id),
|
||||
object_name=object_name,
|
||||
@@ -579,6 +546,65 @@ async def upload_admin_test(
|
||||
|
||||
return {"status": "processing", "test_id": test_obj.id, "message": "Test is being processed by AI"}
|
||||
|
||||
@app.post("/api/admin/tests/create_manual", tags=["Admin"])
|
||||
def create_manual_test(
|
||||
data: ManualTestCreate,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
import uuid
|
||||
# 1. Create the test object
|
||||
test_obj = models.Test(
|
||||
title=data.title,
|
||||
type="Mock",
|
||||
exam_pattern=models.ExamEnum(data.exam_pattern),
|
||||
sections={},
|
||||
total_time=data.time_limit,
|
||||
difficulty="Medium",
|
||||
scheduled_date=data.scheduled_date,
|
||||
end_date=data.end_date,
|
||||
is_active=True,
|
||||
created_by=data.admin_id
|
||||
)
|
||||
session.add(test_obj)
|
||||
session.commit()
|
||||
session.refresh(test_obj)
|
||||
|
||||
# 2. Parse and create questions
|
||||
sections_dict = {}
|
||||
|
||||
for q_data in data.questions_json:
|
||||
q_obj = models.Question(
|
||||
text_en=q_data.text_en,
|
||||
text_hi=q_data.text_hi,
|
||||
options_en=q_data.options_en,
|
||||
options_hi=q_data.options_hi,
|
||||
correct_answer=q_data.correct_answer,
|
||||
type=q_data.type,
|
||||
topic=q_data.topic,
|
||||
difficulty=q_data.difficulty,
|
||||
marks=q_data.marks,
|
||||
negative_marks=q_data.negative_marks
|
||||
)
|
||||
session.add(q_obj)
|
||||
session.commit()
|
||||
session.refresh(q_obj)
|
||||
|
||||
sec_name = q_data.section
|
||||
if sec_name not in sections_dict:
|
||||
sections_dict[sec_name] = {
|
||||
"name": sec_name,
|
||||
"questions": [],
|
||||
"time_limit": None
|
||||
}
|
||||
sections_dict[sec_name]["questions"].append(str(q_obj.id))
|
||||
|
||||
# 3. Update the test with the sections
|
||||
test_obj.sections = sections_dict
|
||||
session.add(test_obj)
|
||||
session.commit()
|
||||
|
||||
return {"message": "Test created successfully", "test_id": test_obj.id}
|
||||
|
||||
@app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"])
|
||||
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session)):
|
||||
test_obj = session.get(models.Test, test_id)
|
||||
|
||||
@@ -114,6 +114,7 @@ class Test(SQLModel, table=True):
|
||||
total_time: int
|
||||
difficulty: str
|
||||
scheduled_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
is_active: bool = Field(default=True)
|
||||
created_by: UUID
|
||||
|
||||
|
||||
0
backend/pragativeer.db
Normal file
0
backend/pragativeer.db
Normal file
@@ -100,10 +100,10 @@ async def seed_pdfs():
|
||||
topic=topic,
|
||||
subtopic="General",
|
||||
difficulty="Medium",
|
||||
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||
type=models.QuestionTypeEnum.NUMERICAL,
|
||||
question_text=q_text,
|
||||
options=options,
|
||||
correct_answer={"value": "A"},
|
||||
options=None,
|
||||
correct_answer={"value": "Subjective input"},
|
||||
solution_text=ans_text,
|
||||
language=models.MediumEnum.ENGLISH,
|
||||
exam=models.ExamEnum.JEE_MAIN,
|
||||
|
||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -8,7 +8,9 @@
|
||||
"name": "pragativeer-v2",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"framer-motion": "^12.41.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb-keyval": "^6.2.6",
|
||||
@@ -16,6 +18,7 @@
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-katex": "^3.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -467,6 +470,59 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -1746,6 +1802,27 @@
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-datepicker": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-6.2.0.tgz",
|
||||
"integrity": "sha512-+JtO4Fm97WLkJTH8j8/v3Ldh7JCNRwjMYjRaKh4KHH0M3jJoXtwiD3JBCsdlg3tsFIw9eQSqyAPeVDN2H2oM9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.2",
|
||||
"@types/react": "*",
|
||||
"date-fns": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-datepicker/node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
@@ -3194,6 +3271,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -7065,6 +7152,42 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-datepicker": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz",
|
||||
"integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.15",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns-tz": "^3.0.0",
|
||||
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"date-fns-tz": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-datepicker/node_modules/@floating-ui/react": {
|
||||
"version": "0.27.19",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
|
||||
"integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
@@ -7955,6 +8078,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
|
||||
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"framer-motion": "^12.41.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb-keyval": "^6.2.6",
|
||||
@@ -17,6 +19,7 @@
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-katex": "^3.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { ReactNode, useEffect } from "react"
|
||||
import { LayoutDashboard, Users, Database, Terminal, Settings, FileText } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
@@ -9,6 +9,10 @@ import { Logo } from "@/components/Logo"
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-[#00F0FF]/20">
|
||||
{/* Sidebar */}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Download, MoreVertical } from "lucide-react"
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Loader2, Upload, FileText, Clock, CalendarIcon } from "lucide-react"
|
||||
import DatePicker from "react-datepicker"
|
||||
import "react-datepicker/dist/react-datepicker.css"
|
||||
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
|
||||
@@ -15,13 +17,13 @@ export default function AdminTestsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const { userId } = useActiveUser()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Upload Form State
|
||||
const [title, setTitle] = useState("")
|
||||
const [examPattern, setExamPattern] = useState("JEE_Main")
|
||||
const [timeLimit, setTimeLimit] = useState("180")
|
||||
const [scheduledDate, setScheduledDate] = useState("")
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | null>(null)
|
||||
const [endDate, setEndDate] = useState<Date | null>(null)
|
||||
const [questionsJson, setQuestionsJson] = useState("")
|
||||
|
||||
const fetchTests = async () => {
|
||||
try {
|
||||
@@ -56,37 +58,48 @@ export default function AdminTestsPage() {
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!fileInputRef.current?.files?.[0]) return
|
||||
if (!questionsJson) return
|
||||
if (!userId) return
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileInputRef.current.files[0])
|
||||
formData.append("title", title)
|
||||
formData.append("exam_pattern", examPattern)
|
||||
formData.append("time_limit", timeLimit)
|
||||
formData.append("admin_id", userId)
|
||||
if (scheduledDate) {
|
||||
formData.append("scheduled_date", new Date(scheduledDate).toISOString())
|
||||
|
||||
let parsedQuestions = [];
|
||||
try {
|
||||
parsedQuestions = JSON.parse(questionsJson);
|
||||
} catch(err) {
|
||||
alert("Invalid JSON format. Please paste a valid JSON array.");
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
exam_pattern: examPattern,
|
||||
time_limit: parseInt(timeLimit),
|
||||
admin_id: userId,
|
||||
questions_json: parsedQuestions,
|
||||
scheduled_date: scheduledDate ? scheduledDate.toISOString() : null,
|
||||
end_date: endDate ? endDate.toISOString() : null,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/admin/tests/upload", {
|
||||
const res = await fetch("http://localhost:8000/api/admin/tests/create_manual", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
alert("Test uploaded and sent to AI for processing!")
|
||||
alert("Test created successfully!")
|
||||
setTitle("")
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
setQuestionsJson("")
|
||||
fetchTests()
|
||||
} else {
|
||||
alert("Upload failed")
|
||||
alert("Creation failed")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert("Upload failed")
|
||||
alert("Creation failed")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -106,9 +119,9 @@ export default function AdminTestsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
Upload New Test
|
||||
Create Manual Test
|
||||
</CardTitle>
|
||||
<CardDescription>Upload a PDF containing questions. AI will automatically extract and format them.</CardDescription>
|
||||
<CardDescription>Paste the structured JSON block of questions in LaTeX format to create a deterministic test.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpload} className="space-y-4">
|
||||
@@ -135,20 +148,52 @@ export default function AdminTestsPage() {
|
||||
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Scheduled Date (Optional)</label>
|
||||
<Input type="datetime-local" value={scheduledDate} onChange={e => setScheduledDate(e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<label className="text-sm font-medium">Scheduled Start (Optional)</label>
|
||||
<DatePicker
|
||||
selected={scheduledDate}
|
||||
onChange={(date) => setScheduledDate(date)}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
timeCaption="time"
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
placeholderText="Select start date & time"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<label className="text-sm font-medium">Scheduled End (Optional)</label>
|
||||
<DatePicker
|
||||
selected={endDate}
|
||||
onChange={(date) => setEndDate(date)}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
timeCaption="time"
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
placeholderText="Select end date & time"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Test PDF File</label>
|
||||
<Input required type="file" accept=".pdf" ref={fileInputRef} className="cursor-pointer" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Make sure it contains clear questions and options.</p>
|
||||
<label className="text-sm font-medium">Questions JSON (LaTeX Format)</label>
|
||||
<textarea
|
||||
required
|
||||
value={questionsJson}
|
||||
onChange={e => setQuestionsJson(e.target.value)}
|
||||
className="w-full min-h-[250px] p-3 text-sm font-mono border rounded-md bg-background focus:ring-primary focus:border-primary"
|
||||
placeholder='[{"section": "Physics", "text_en": "Question text...", "options_en": ["A", "B", "C", "D"], "correct_answer": "A"}]'
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Paste your questions as a JSON array.</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={uploading}>
|
||||
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Upload className="w-4 h-4 mr-2" />}
|
||||
{uploading ? "Processing PDF via AI..." : "Upload & Parse"}
|
||||
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
||||
{uploading ? "Creating Test..." : "Create Test from JSON"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
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 || {}),
|
||||
@@ -351,7 +352,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mt-8">
|
||||
{currentQuestion.options.map((option: string, idx: number) => {
|
||||
{(currentQuestion.type === 'Subjective' || currentQuestion.type === 'Numerical') ? (
|
||||
<textarea
|
||||
className="w-full min-h-[150px] p-4 rounded-xl border border-border bg-background focus:ring-2 focus:ring-primary focus:border-transparent resize-y text-base"
|
||||
placeholder="Type your answer or explanation here..."
|
||||
value={currentState?.selected_answer || ""}
|
||||
onChange={(e) => selectAnswer(currentQuestion.id, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
currentQuestion.options?.map((option: string, idx: number) => {
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
const isSelected = currentState?.selected_answer === letter;
|
||||
return (
|
||||
@@ -374,7 +383,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,6 +397,9 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
<Badge variant="outline" className="px-3 py-1 text-sm text-muted-foreground font-medium rounded-lg">प्रश्न {currentQuestion.number}</Badge>
|
||||
<span className="text-[10px] uppercase font-bold text-muted-foreground bg-secondary/30 px-2 py-0.5 rounded-md border border-border">हिंदी</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-relaxed text-foreground font-medium">
|
||||
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||
@@ -395,7 +408,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mt-8">
|
||||
{currentQuestion.options_hi?.map((option: string, idx: number) => {
|
||||
{(currentQuestion.type === 'Subjective' || currentQuestion.type === 'Numerical') ? (
|
||||
<textarea
|
||||
className="w-full min-h-[150px] p-4 rounded-xl border border-border bg-background focus:ring-2 focus:ring-primary focus:border-transparent resize-y text-base"
|
||||
placeholder="अपना उत्तर या स्पष्टीकरण यहाँ टाइप करें..."
|
||||
value={currentState?.selected_answer || ""}
|
||||
onChange={(e) => selectAnswer(currentQuestion.id, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
currentQuestion.options_hi?.map((option: string, idx: number) => {
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
const isSelected = currentState?.selected_answer === letter;
|
||||
return (
|
||||
@@ -418,7 +439,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,7 +525,13 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
</div>
|
||||
<div className="p-4 border-t border-border bg-background flex flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => markForReview(currentQuestion.id)} className="flex-1 text-xs font-bold h-9 bg-secondary/50 hover:bg-amber-500/10 hover:text-amber-500 hover:border-amber-500/50"><Flag size={14} className="mr-1.5"/> Mark</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => markForReview(currentQuestion.id)}
|
||||
className={`flex-1 text-xs font-bold h-9 transition-colors ${currentState?.status.includes('MARKED') ? 'bg-amber-500/10 text-amber-500 border-amber-500/50' : 'bg-secondary/50 hover:bg-amber-500/10 hover:text-amber-500 hover:border-amber-500/50'}`}
|
||||
>
|
||||
<Flag size={14} className="mr-1.5"/> {currentState?.status.includes('MARKED') ? 'Unmark' : 'Mark'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => clearResponse(currentQuestion.id)} className="flex-1 text-xs font-bold h-9 bg-secondary/50 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"><XCircle size={14} className="mr-1.5"/> Clear</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function AnalyticsPage() {
|
||||
<Tooltip
|
||||
cursor={{fill: 'transparent'}}
|
||||
contentStyle={{ backgroundColor: '#1f2937', borderColor: '#374151', borderRadius: '8px', color: '#fff' }}
|
||||
formatter={(value: number) => [`${value}% Accuracy`, 'Accuracy']}
|
||||
formatter={(value: any) => [`${value}% Accuracy`, 'Accuracy']}
|
||||
/>
|
||||
<Bar dataKey="accuracy" radius={[0, 4, 4, 0]} barSize={12}>
|
||||
{heatmapData.map((entry, index) => (
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg leading-relaxed">{q.text}</h3>
|
||||
<h3 className="font-medium text-lg leading-relaxed">{q.question_text || q.text_en || q.text}</h3>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs">{q.topic}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{q.difficulty}</Badge>
|
||||
@@ -141,13 +141,19 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-muted-foreground uppercase">Your Answer</p>
|
||||
<div className={`p-3 rounded-lg border ${ans.is_correct ? 'bg-green-500/10 border-green-500/30 text-green-500' : 'bg-red-500/10 border-red-500/30 text-red-500'}`}>
|
||||
{q.options[ans.selected_answer] ? `${ans.selected_answer}) ${q.options[ans.selected_answer]}` : 'Not Attempted'}
|
||||
{ans.is_subjective || q.type === 'Subjective' || q.type === 'Numerical'
|
||||
? (ans.selected_answer || 'Not Attempted')
|
||||
: (q.options && q.options[ans.selected_answer] ? `${ans.selected_answer}) ${q.options[ans.selected_answer]}` : ans.selected_answer || 'Not Attempted')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-muted-foreground uppercase">Correct Answer</p>
|
||||
<div className="p-3 rounded-lg border bg-green-500/10 border-green-500/30 text-green-500">
|
||||
{q.correct_answer?.correct}) {q.options[q.correct_answer?.correct]}
|
||||
{ans.is_subjective || q.type === 'Subjective' || q.type === 'Numerical'
|
||||
? (q.correct_answer?.value || q.correct_answer?.correct || 'Subjective text')
|
||||
: (q.options && q.options[q.correct_answer?.correct || q.correct_answer?.value]
|
||||
? `${q.correct_answer?.correct || q.correct_answer?.value}) ${q.options[q.correct_answer?.correct || q.correct_answer?.value]}`
|
||||
: (q.correct_answer?.correct || q.correct_answer?.value || 'N/A'))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function CBTPage() {
|
||||
if (!userRes.ok) throw new Error("Failed to load user target exam");
|
||||
const userData = await userRes.json();
|
||||
const pattern = userData.target_exam;
|
||||
setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main");
|
||||
setTargetExam(pattern === "NEET" ? "NEET" : pattern === "JEE_Advanced" ? "JEE Advanced" : "JEE Main");
|
||||
|
||||
// 2. Fetch tests filtered by exam
|
||||
const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||
import { MessageSquare, ThumbsUp, ChevronUp, Search, Image as ImageIcon, CheckCircle } from "lucide-react"
|
||||
import { MessageSquare, ThumbsUp, ChevronUp, Search, Image as ImageIcon, CheckCircle, Lock } from "lucide-react"
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
|
||||
interface Doubt {
|
||||
id: string
|
||||
@@ -20,6 +21,7 @@ interface Doubt {
|
||||
}
|
||||
|
||||
export default function CommunityPage() {
|
||||
const { hasAccessTo } = useActiveUser()
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'my-doubts' | 'solved' | 'unsolved'>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
@@ -87,9 +89,16 @@ export default function CommunityPage() {
|
||||
<p className="text-muted-foreground mt-2 font-medium">Ask questions, share knowledge, and learn together with the community.</p>
|
||||
</div>
|
||||
|
||||
<button className="px-5 py-2.5 bg-[#00F0FF] text-black font-bold rounded-xl shadow-[0_0_15px_-3px_rgba(0,240,255,0.4)] hover:bg-[#00d0dd] transition-colors">
|
||||
Ask a Doubt
|
||||
</button>
|
||||
{hasAccessTo("ask-doubts-from-tutors") ? (
|
||||
<button className="px-5 py-2.5 bg-[#00F0FF] text-black font-bold rounded-xl shadow-[0_0_15px_-3px_rgba(0,240,255,0.4)] hover:bg-[#00d0dd] transition-colors">
|
||||
Ask a Doubt
|
||||
</button>
|
||||
) : (
|
||||
<button disabled className="px-5 py-2.5 bg-secondary text-muted-foreground font-bold rounded-xl flex items-center gap-2 cursor-not-allowed">
|
||||
<Lock size={16} />
|
||||
Pro Feature
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
|
||||
@@ -6,15 +6,21 @@ import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock } from "lucide
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
|
||||
export default function DPPPage() {
|
||||
const { user } = useActiveUser()
|
||||
const [dpps, setDpps] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDpps() {
|
||||
if (!user) return; // Wait for user to be available
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/tests?type=DPS")
|
||||
const pattern = user.target_exam || "JEE_Main";
|
||||
// Also fetch from the correct Next.js proxied route instead of localhost:8000
|
||||
const res = await fetch(`/api/tests?type=DPS&exam_pattern=${pattern}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDpps(data)
|
||||
@@ -26,7 +32,7 @@ export default function DPPPage() {
|
||||
}
|
||||
}
|
||||
fetchDpps()
|
||||
}, [])
|
||||
}, [user])
|
||||
|
||||
const getDifficultyColor = (diff: string) => {
|
||||
switch(diff) {
|
||||
@@ -80,7 +86,7 @@ export default function DPPPage() {
|
||||
Object.values(dpp.sections).forEach((s: any) => { count += (s.questions?.length || 0) });
|
||||
}
|
||||
// Temporary mock for status
|
||||
const status = "Pending"
|
||||
let status: string = "Pending"
|
||||
|
||||
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'}`}>
|
||||
@@ -91,7 +97,9 @@ export default function DPPPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[#00F0FF]">{dpp.exam_pattern?.replace("_", " ") || "JEE/NEET"}</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[#00F0FF]">
|
||||
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
||||
</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>
|
||||
</div>
|
||||
@@ -113,7 +121,7 @@ export default function DPPPage() {
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/cbt/${dpp.id}`}>
|
||||
<Button variant="secondary" size="icon" className="rounded-full group-hover:bg-[#00F0FF] group-hover:text-black dark:group-hover:text-black hover:bg-[#00F0FF] hover:text-black dark:hover:text-black 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">
|
||||
<Play size={16} className="ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -59,7 +59,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
<NavItem href="/dashboard/leaderboard" icon={<Trophy size={18} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} />
|
||||
<NavItem href="/dashboard/progress" icon={<Activity size={18} />} label="Syllabus Tracker" active={pathname.startsWith("/dashboard/progress")} />
|
||||
<NavItem href="/dashboard/community" icon={<BookOpen size={18} />} label="Doubt Forum" active={pathname.startsWith("/dashboard/community")} />
|
||||
<NavItem
|
||||
href="/dashboard/community"
|
||||
icon={<BookOpen size={18} />}
|
||||
label="Doubt Forum"
|
||||
active={pathname.startsWith("/dashboard/community")}
|
||||
isLocked={!hasAccessTo("ask-doubts-from-tutors")}
|
||||
/>
|
||||
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-3 text-xs font-bold uppercase tracking-widest text-muted-foreground">AI Intelligence</p>
|
||||
@@ -159,7 +165,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Streak count moved to top header */}
|
||||
{user && (
|
||||
<Link href="/dashboard/streaks" className="hover:opacity-85 transition-opacity flex items-center gap-1.5 bg-secondary/50 border border-border px-3 py-1 rounded-xl">
|
||||
<Link href="/dashboard/streaks" className="hover:opacity-85 transition-opacity">
|
||||
<StreakCounter streak={user.streak_count} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -30,12 +30,7 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-6xl mx-auto space-y-10 animate-in fade-in duration-500 slide-in-from-bottom-4">
|
||||
<header className="flex justify-end mb-2">
|
||||
<Button size="lg" className="gap-2 shadow-sm bg-[#00F0FF] text-black hover:bg-[#00F0FF]/90 font-bold">
|
||||
<BrainCircuit size={18} />
|
||||
Generate AI Plan
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
|
||||
{/* Free Tier Upgrade CTA Banner */}
|
||||
<Card className="bg-primary/5 border-primary/20 relative overflow-hidden">
|
||||
@@ -47,7 +42,7 @@ export default function DashboardPage() {
|
||||
<Badge className="bg-primary text-primary-foreground font-bold tracking-widest uppercase mb-2">Upgrade to Pro</Badge>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Unlock Unlimited DPS & Mocks</h2>
|
||||
<p className="text-muted-foreground max-w-xl font-medium">
|
||||
You're currently on the Free Tier. Upgrade to Pro for ₹299/mo to access unlimited Daily Practice Sheets, weekly CBT tests, and advanced analytics.
|
||||
You're currently on the Free Tier. Upgrade to Pro for ₹2499/yr to access unlimited Daily Practice Sheets, weekly CBT tests, and advanced analytics.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" className="shrink-0 rounded-xl shadow-[0_0_20px_-5px_var(--color-primary)] font-bold">
|
||||
|
||||
@@ -34,10 +34,14 @@ export default function RootLayout({
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
if (window.location.pathname.startsWith('/dashboard') || window.location.pathname.startsWith('/cbt')) {
|
||||
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
} catch (_) {}
|
||||
`,
|
||||
|
||||
@@ -7,9 +7,13 @@ import { BrainCircuit, Activity, Zap, CheckCircle2, Shield, ArrowRight, Play, Bo
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { motion, useScroll, useTransform, Variants } from "framer-motion"
|
||||
import { useRef } from "react"
|
||||
import { useRef, useEffect } from "react"
|
||||
|
||||
export default function MarketingPage() {
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
@@ -153,7 +157,7 @@ export default function MarketingPage() {
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center bg-background/80 border border-border/50 px-6 py-1.5 rounded-full text-xs font-mono text-muted-foreground w-80 justify-center gap-2">
|
||||
<div className="size-2 rounded-full bg-[#00F0FF] animate-pulse"></div>
|
||||
pragativeer.ai/dashboard/student
|
||||
pragativeer.com/dashboard/student
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-[#00F0FF]/30 text-[#00F0FF] bg-[#00F0FF]/5 px-2 py-0.5 rounded-md text-[10px] uppercase tracking-wider font-bold">
|
||||
@@ -405,17 +409,40 @@ export default function MarketingPage() {
|
||||
<p className="text-muted-foreground text-xl max-w-2xl mx-auto">Unlock the ultimate unfair advantage for your NEET/JEE preparation.</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{/* Free Plan */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, type: "spring" }}
|
||||
>
|
||||
<Card className="bg-background/80 backdrop-blur-md border-border p-10 rounded-[2.5rem] flex flex-col hover:border-border/80 transition-colors h-full z-10 relative shadow-xl">
|
||||
<Card className="bg-background/80 backdrop-blur-md border-border p-8 rounded-[2.5rem] flex flex-col hover:border-border/80 transition-colors h-full z-10 relative shadow-xl">
|
||||
<h3 className="text-3xl font-bold mb-2">Free</h3>
|
||||
<p className="text-muted-foreground mb-8 text-lg">Perfect to get started.</p>
|
||||
<div className="text-6xl font-bold tracking-tighter mb-10">₹0<span className="text-xl text-muted-foreground font-medium">/yr</span></div>
|
||||
<ul className="space-y-5 mb-10 flex-1 font-medium text-lg">
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-muted-foreground" /> 1 Daily Practice Sheet</li>
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-muted-foreground" /> Basic Performance Analytics</li>
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-muted-foreground" /> Community Access</li>
|
||||
</ul>
|
||||
<Link href="/signup">
|
||||
<Button variant="outline" size="lg" className="w-full rounded-2xl h-16 border-border font-bold text-lg hover:bg-secondary hover:text-foreground">Get Started Free</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, type: "spring", delay: 0.1 }}
|
||||
>
|
||||
<Card className="bg-background/80 backdrop-blur-md border-border p-8 rounded-[2.5rem] flex flex-col hover:border-border/80 transition-colors h-full z-10 relative shadow-xl">
|
||||
<h3 className="text-3xl font-bold mb-2">Pro</h3>
|
||||
<p className="text-muted-foreground mb-8 text-lg">Perfect for self-paced practice.</p>
|
||||
<div className="text-6xl font-bold tracking-tighter mb-10">₹299<span className="text-xl text-muted-foreground font-medium">/mo</span></div>
|
||||
<div className="text-6xl font-bold tracking-tighter mb-10">₹2,499<span className="text-xl text-muted-foreground font-medium">/yr</span></div>
|
||||
<ul className="space-y-5 mb-10 flex-1 font-medium text-lg">
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-primary" /> Unlimited Daily Practice Sheets</li>
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-primary" /> Weekly Test Series & Mock Tests</li>
|
||||
@@ -423,27 +450,30 @@ export default function MarketingPage() {
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-primary" /> Parent Dashboard Access</li>
|
||||
</ul>
|
||||
<Link href="/signup">
|
||||
<Button variant="outline" size="lg" className="w-full rounded-2xl h-16 border-border font-bold text-lg hover:bg-secondary/50">Choose Pro</Button>
|
||||
<Button variant="outline" size="lg" className="w-full rounded-2xl h-16 border-border font-bold text-lg hover:bg-secondary hover:text-foreground">Choose Pro</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* AI Coach Plan */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, type: "spring", delay: 0.1 }}
|
||||
className="relative"
|
||||
transition={{ duration: 0.6, type: "spring", delay: 0.2 }}
|
||||
className="relative mt-8 lg:mt-0"
|
||||
>
|
||||
<Card className="bg-secondary/30 backdrop-blur-xl border-primary p-10 rounded-[2.5rem] flex flex-col shadow-[0_0_60px_-15px_var(--color-primary)] hover:shadow-[0_0_80px_-15px_var(--color-primary)] transition-all h-full overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-shine bg-gradient-to-r from-transparent via-white/10 to-transparent z-0 pointer-events-none"></div>
|
||||
<div className="absolute top-0 right-10 transform -translate-y-1/2 z-20">
|
||||
<Badge className="bg-primary text-primary-foreground font-bold px-5 py-2 text-sm rounded-full shadow-[0_0_15px_-2px_var(--color-primary)]">MOST POPULAR</Badge>
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 z-20">
|
||||
<Badge className="bg-primary text-primary-foreground font-extrabold px-6 py-2 text-sm rounded-full shadow-[0_0_20px_-2px_var(--color-primary)]">MOST POPULAR</Badge>
|
||||
</div>
|
||||
<Card className="bg-secondary/30 backdrop-blur-xl border-primary p-8 rounded-[2.5rem] flex flex-col shadow-[0_0_60px_-15px_var(--color-primary)] hover:shadow-[0_0_80px_-15px_var(--color-primary)] transition-all h-full relative">
|
||||
<div className="absolute inset-0 rounded-[2.5rem] overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-shine bg-gradient-to-r from-transparent via-white/10 to-transparent z-0 pointer-events-none"></div>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
<div className="relative z-10 flex flex-col h-full mt-4">
|
||||
<h3 className="text-3xl font-bold mb-2">AI Coach</h3>
|
||||
<p className="text-muted-foreground mb-8 text-lg">The complete AI-driven preparation system.</p>
|
||||
<div className="text-6xl font-bold tracking-tighter mb-10 text-primary drop-shadow-[0_0_10px_var(--color-primary)]">₹599<span className="text-xl text-foreground font-medium">/mo</span></div>
|
||||
<p className="text-muted-foreground mb-8 text-lg">The complete AI-driven system.</p>
|
||||
<div className="text-6xl font-bold tracking-tighter mb-10 text-primary drop-shadow-[0_0_10px_var(--color-primary)]">₹4,999<span className="text-xl text-foreground font-medium">/yr</span></div>
|
||||
<ul className="space-y-5 mb-10 flex-1 font-medium text-lg">
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-[#00F0FF]" /> Everything in Pro</li>
|
||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-[#00F0FF]" /> AI Spaced Repetition (Mistakes)</li>
|
||||
|
||||
@@ -115,7 +115,11 @@ export function ExamResult({ payload, onRetake }: ExamResultProps) {
|
||||
<RotateCcw className="size-5" /> Retake Mock Test
|
||||
</Button>
|
||||
<Link href="/dashboard/cbt">
|
||||
<Button size="lg" className="rounded-xl h-14 px-8 font-bold text-lg shadow-[0_0_20px_-5px_var(--color-primary)] gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-xl h-14 px-8 font-bold text-lg shadow-[0_0_20px_-5px_var(--color-primary)] gap-2 w-full sm:w-auto"
|
||||
onClick={() => document.fullscreenElement && document.exitFullscreen()}
|
||||
>
|
||||
Dashboard <ArrowRight className="size-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -14,19 +14,17 @@ export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col w-[90vw] max-w-5xl h-[85vh]">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col w-[95vw] max-w-[1400px] h-[95vh]">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/80">
|
||||
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm"><ExternalLink size={14} className="mr-2"/> Open Fullscreen</Button>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-2 p-2">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-zinc-950 p-6 flex items-center justify-center relative overflow-auto">
|
||||
{/* We will use a high quality remote SVG/image for the periodic table reference */}
|
||||
<div className="relative w-full h-full min-w-[800px] min-h-[500px]">
|
||||
<div className="flex-1 bg-white p-4 flex items-start justify-start relative overflow-auto">
|
||||
<div className="relative w-[1800px] h-[1100px] shrink-0">
|
||||
{/* Using a reliable wikimedia SVG for Periodic Table as a reference */}
|
||||
<Image
|
||||
src="https://upload.wikimedia.org/wikipedia/commons/4/4d/Periodic_table_large.svg"
|
||||
|
||||
@@ -23,9 +23,11 @@ export function StreakOnboardingModal() {
|
||||
|
||||
const streak = user.streak_count;
|
||||
|
||||
const targetExam = user.target_exam?.replace("_", " ") || "JEE/NEET";
|
||||
|
||||
// Streak-based wishes and titles
|
||||
let wishTitle = "Welcome to PragatiVeer! 🌟";
|
||||
let wishMessage = "Prepare yourself to conquer JEE/NEET. Your daily practice starts here!";
|
||||
let wishMessage = `Prepare yourself to conquer ${targetExam}. Your daily practice starts here!`;
|
||||
let iconTheme = "text-[#00F0FF] bg-[#00F0FF]/10";
|
||||
let iconComponent = <Sparkles className="size-10" />;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Flame } from "lucide-react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface StreakCounterProps {
|
||||
streak: number
|
||||
@@ -8,9 +9,15 @@ interface StreakCounterProps {
|
||||
|
||||
export function StreakCounter({ streak }: StreakCounterProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 border border-orange-500/20 text-orange-500 font-bold shadow-[0_0_10px_-2px_rgba(249,115,22,0.5)] cursor-default transition-transform hover:scale-105">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0, y: -10 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
whileHover={{ scale: 1.1, rotate: [0, -5, 5, -5, 0] }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 15 }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 border border-orange-500/20 text-orange-500 font-bold shadow-[0_0_10px_-2px_rgba(249,115,22,0.5)] cursor-default"
|
||||
>
|
||||
<Flame size={18} className="animate-pulse" />
|
||||
<span>{streak}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ExamAttemptPayload {
|
||||
question_id: string
|
||||
selected_answer: string | null
|
||||
is_correct: boolean // Typically evaluated on server, but requested in payload structure
|
||||
is_subjective: boolean
|
||||
time_spent: number
|
||||
}>
|
||||
score: number
|
||||
@@ -200,16 +201,17 @@ export const useCBTStore = create<CBTState>()(
|
||||
const timeTaken = Math.floor((Date.now() - state.startTime) / 1000);
|
||||
const answerList = Object.values(state.answers);
|
||||
|
||||
// Mock evaluation (In reality, server does this)
|
||||
let correctCount = 0;
|
||||
// Backend will perform secure evaluation
|
||||
let correctCount = 0; // Removing mock count
|
||||
const finalAnswers = answerList.map(a => {
|
||||
// Assuming 'A' is correct for dummy data
|
||||
const isCorrect = a.selected_answer === 'A';
|
||||
if (isCorrect) correctCount++;
|
||||
const q = state.questions.find(quest => quest.id === a.question_id);
|
||||
const isSubjective = q?.type === 'Subjective' || q?.type === 'Numerical';
|
||||
|
||||
return {
|
||||
question_id: a.question_id,
|
||||
selected_answer: a.selected_answer,
|
||||
is_correct: isCorrect,
|
||||
is_correct: false,
|
||||
is_subjective: isSubjective,
|
||||
time_spent: a.time_spent
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user