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 contextlib import asynccontextmanager
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, date
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from database import init_db, get_session
|
from database import init_db, get_session
|
||||||
@@ -14,13 +14,11 @@ from ai_coach import calculate_sm2
|
|||||||
from analytics import generate_parent_report
|
from analytics import generate_parent_report
|
||||||
from billing import generate_mock_order, verify_webhook_signature
|
from billing import generate_mock_order, verify_webhook_signature
|
||||||
from llm import stream_tutor_response
|
from llm import stream_tutor_response
|
||||||
from analytics import generate_parent_report
|
|
||||||
from predictor import calculate_readiness_score
|
from predictor import calculate_readiness_score
|
||||||
from growth import generate_referral_link, process_referral_reward
|
from growth import generate_referral_link, process_referral_reward
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Initialize database tables locally when the container boots up
|
|
||||||
init_db()
|
init_db()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -30,7 +28,6 @@ app = FastAPI(
|
|||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
# Enable immediate communication with your Next.js frontend local environment
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||||
@@ -46,14 +43,10 @@ def health_check():
|
|||||||
# --- ACCESS CONTROL DEPENDENCY ---
|
# --- ACCESS CONTROL DEPENDENCY ---
|
||||||
|
|
||||||
def require_tier(allowed_tiers: List[models.TierEnum]):
|
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)):
|
def tier_checker(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
user = session.get(models.User, user_id)
|
user = session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found.")
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
|
||||||
if user.tier not in allowed_tiers:
|
if user.tier not in allowed_tiers:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
@@ -62,19 +55,18 @@ def require_tier(allowed_tiers: List[models.TierEnum]):
|
|||||||
return user
|
return user
|
||||||
return tier_checker
|
return tier_checker
|
||||||
|
|
||||||
# --- BASELINE AUTHENTICATION MOCKS FOR FRONTEND UI VERIFICATION ---
|
# --- AUTHENTICATION ---
|
||||||
|
|
||||||
@app.post("/api/auth/otp-request", tags=["Authentication"])
|
@app.post("/api/auth/otp-request", tags=["Authentication"])
|
||||||
def request_otp(phone_or_email: str):
|
def request_otp(phone_or_email: str):
|
||||||
# Mocking execution loop for user onboarding flow
|
|
||||||
return {"message": "Verification code dispatched successfully", "debug_otp": "123456"}
|
return {"message": "Verification code dispatched successfully", "debug_otp": "123456"}
|
||||||
|
|
||||||
@app.post("/api/auth/otp-verify", tags=["Authentication"])
|
@app.post("/api/auth/otp-verify", tags=["Authentication"])
|
||||||
def verify_otp(phone_or_email: str, code: str, session: Session = Depends(get_session)):
|
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"}
|
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"])
|
@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)):
|
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)
|
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:
|
if type:
|
||||||
query = query.where(models.Test.type == type)
|
query = query.where(models.Test.type == type)
|
||||||
else:
|
else:
|
||||||
# If type isn't specified, don't return DPS in regular tests query
|
|
||||||
query = query.where(models.Test.type != "DPS")
|
query = query.where(models.Test.type != "DPS")
|
||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
|
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
|
||||||
def get_test_by_id(test_id: UUID, user_id: 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)
|
test = session.get(models.Test, test_id)
|
||||||
if not test:
|
if not test:
|
||||||
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
||||||
@@ -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"])
|
@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)):
|
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session)):
|
||||||
user_id = payload.get("user_id")
|
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")
|
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:
|
if not user_id or not source_id:
|
||||||
raise HTTPException(status_code=400, detail="Missing required deployment metadata parameters.")
|
raise HTTPException(status_code=400, detail="Missing required deployment metadata parameters.")
|
||||||
|
|
||||||
# 0. Enforce Timer Integrity: Verify that the test window is still active
|
# Normalize source_type string to match SourceTypeEnum values
|
||||||
if source_type == "Test":
|
_st_map = {"dps": "DPS", "test": "Test", "microtest": "MicroTest"}
|
||||||
test = session.get(models.Test, source_id)
|
source_type = _st_map.get(str(source_type_raw).lower(), "Test")
|
||||||
if test and test.scheduled_date:
|
|
||||||
# Removed strict time cutoff for development/testing
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 1. Initialize structural baseline metrics
|
|
||||||
total_questions = len(client_answers)
|
total_questions = len(client_answers)
|
||||||
correct_count = 0
|
correct_count = 0
|
||||||
processed_answers = []
|
processed_answers = []
|
||||||
|
|
||||||
# Extract pre-generated attempt ID or create a new one
|
|
||||||
attempt_id_str = payload.get("id")
|
attempt_id_str = payload.get("id")
|
||||||
import uuid
|
import uuid as _uuid
|
||||||
attempt_id = uuid.UUID(attempt_id_str) if attempt_id_str else uuid.uuid4()
|
attempt_id = _uuid.UUID(attempt_id_str) if attempt_id_str else _uuid.uuid4()
|
||||||
|
|
||||||
|
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:
|
for item in client_answers:
|
||||||
q_id = item.get("question_id")
|
q_id = item.get("question_id")
|
||||||
selected = item.get("selected_answer")
|
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)
|
try:
|
||||||
question = session.get(models.Question, q_id)
|
question = session.get(models.Question, q_id)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
if not question:
|
if not question:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check correctness against JSON database configurations
|
correct_ans = question.correct_answer
|
||||||
is_correct = question.correct_answer.get("correct") == selected
|
# 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:
|
if is_correct:
|
||||||
correct_count += 1
|
correct_count += 1
|
||||||
|
|
||||||
@@ -148,59 +161,48 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
|
|||||||
"question_id": str(q_id),
|
"question_id": str(q_id),
|
||||||
"selected_answer": selected,
|
"selected_answer": selected,
|
||||||
"is_correct": is_correct,
|
"is_correct": is_correct,
|
||||||
|
"is_subjective": is_subjective,
|
||||||
"time_spent": item.get("time_spent", 0)
|
"time_spent": item.get("time_spent", 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
# 3. AI Coach Processing: If the answer is incorrect, extract it to the Mistake queue
|
# Track mistakes only for objective wrong answers
|
||||||
if not is_correct:
|
if not is_correct and not is_subjective:
|
||||||
# Check if this user has failed this specific question before to track execution loops
|
try:
|
||||||
existing_mistake = session.exec(
|
existing_mistake = session.exec(
|
||||||
select(models.Mistake).where(
|
select(models.Mistake).where(
|
||||||
models.Mistake.user_id == user_id,
|
models.Mistake.user_id == user_id,
|
||||||
models.Mistake.question_id == q_id
|
models.Mistake.question_id == q_id
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
except Exception:
|
||||||
|
existing_mistake = None
|
||||||
|
|
||||||
if existing_mistake:
|
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)
|
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.review_count += 1
|
||||||
existing_mistake.next_review = datetime.utcnow() + timedelta(days=interval)
|
existing_mistake.next_review = datetime.utcnow() + timedelta(days=interval)
|
||||||
existing_mistake.consecutive_correct = 0
|
existing_mistake.consecutive_correct = 0
|
||||||
session.add(existing_mistake)
|
session.add(existing_mistake)
|
||||||
else:
|
else:
|
||||||
# Brand new mistake: enter into the spaced repetition queue for the first time
|
|
||||||
new_mistake = models.Mistake(
|
new_mistake = models.Mistake(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
question_id=q_id,
|
question_id=q_id,
|
||||||
attempt_id=attempt_id, # Linking back to original source file attempt mapping
|
attempt_id=attempt_id,
|
||||||
error_type=models.ErrorTypeEnum.CONCEPTUAL, # Defaults to conceptual until AI tags it
|
error_type=models.ErrorTypeEnum.CONCEPTUAL,
|
||||||
topic=question.topic,
|
topic=question.topic,
|
||||||
subtopic=question.subtopic,
|
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,
|
review_count=1,
|
||||||
consecutive_correct=0,
|
consecutive_correct=0,
|
||||||
is_mastered=False
|
is_mastered=False
|
||||||
)
|
)
|
||||||
session.add(new_mistake)
|
session.add(new_mistake)
|
||||||
|
|
||||||
# 4. Save Core Attempt Records
|
|
||||||
accuracy = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0
|
accuracy = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0
|
||||||
|
|
||||||
attempt_record = models.Attempt(
|
attempt_record.answers = processed_answers
|
||||||
id=attempt_id,
|
attempt_record.score = correct_count
|
||||||
user_id=user_id,
|
attempt_record.accuracy = accuracy
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(attempt_record)
|
session.add(attempt_record)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -208,11 +210,10 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
|
|||||||
|
|
||||||
return attempt_record
|
return attempt_record
|
||||||
|
|
||||||
# --- AI COACH SPАСED REPETITION ENDPOINTS ---
|
# --- AI COACH SPACED REPETITION ENDPOINTS ---
|
||||||
|
|
||||||
@app.get("/api/mistakes/today", tags=["AI Coach"])
|
@app.get("/api/mistakes/today", tags=["AI Coach"])
|
||||||
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)):
|
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()
|
now = datetime.utcnow()
|
||||||
statement = select(models.Mistake, models.Question).join(
|
statement = select(models.Mistake, models.Question).join(
|
||||||
models.Question, models.Mistake.question_id == models.Question.id
|
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
|
models.Mistake.next_review <= now
|
||||||
)
|
)
|
||||||
results = session.exec(statement).all()
|
results = session.exec(statement).all()
|
||||||
|
|
||||||
response_data = []
|
response_data = []
|
||||||
for mistake, question in results:
|
for mistake, question in results:
|
||||||
response_data.append({
|
response_data.append({"mistake": mistake, "question": question})
|
||||||
"mistake": mistake,
|
|
||||||
"question": question
|
|
||||||
})
|
|
||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
@app.post("/api/mistakes/review", tags=["AI Coach"])
|
@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.")
|
raise HTTPException(status_code=404, detail="Mistake not found.")
|
||||||
|
|
||||||
question = session.get(models.Question, mistake.question_id)
|
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
|
q_value = 4 if is_correct else 1
|
||||||
|
|
||||||
# Very basic interval calculation for demo
|
|
||||||
days_since_creation = (datetime.utcnow() - mistake.created_at).days
|
days_since_creation = (datetime.utcnow() - mistake.created_at).days
|
||||||
prev_interval = max(1, days_since_creation)
|
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)
|
interval, ef = calculate_sm2(q=q_value, repetitions=mistake.review_count, ease_factor=2.5, previous_interval=prev_interval)
|
||||||
|
|
||||||
if is_correct:
|
if is_correct:
|
||||||
@@ -270,13 +263,12 @@ def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(ge
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(mistake)
|
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"])
|
@app.post("/api/parent/register", response_model=models.Parent, tags=["Parent Subsystem"])
|
||||||
def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)):
|
def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)):
|
||||||
# Persist parent baseline identity profile
|
|
||||||
session.add(parent_data)
|
session.add(parent_data)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(parent_data)
|
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:
|
if not parent:
|
||||||
raise HTTPException(status_code=404, detail="Parent account context not found.")
|
raise HTTPException(status_code=404, detail="Parent account context not found.")
|
||||||
|
|
||||||
# Locate the target student account by phone or email criteria
|
|
||||||
student = session.exec(
|
student = session.exec(
|
||||||
select(models.User).where(
|
select(models.User).where(
|
||||||
(models.User.phone == student_phone_or_email) |
|
(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:
|
if not student:
|
||||||
raise HTTPException(status_code=404, detail="Target student profile could not be verified.")
|
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 [])
|
current_links = list(parent.linked_students or [])
|
||||||
if str(student.id) not in current_links:
|
if str(student.id) not in current_links:
|
||||||
current_links.append(str(student.id))
|
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:
|
if not parent:
|
||||||
raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.")
|
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 []):
|
if str(student_id) not in (parent.linked_students or []):
|
||||||
raise HTTPException(status_code=403, detail="Access denied to requested student dashboard data.")
|
raise HTTPException(status_code=403, detail="Access denied to requested student dashboard data.")
|
||||||
|
|
||||||
report = generate_parent_report(student_id, session)
|
report = generate_parent_report(student_id, session)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
# --- BILLING & SUBSCRIPTION ---
|
||||||
|
|
||||||
# --- BILLING & SUBSCRIPTION ENDPOINTS ---
|
|
||||||
|
|
||||||
@app.post("/api/billing/create-order", tags=["Subscription Engine"])
|
@app.post("/api/billing/create-order", tags=["Subscription Engine"])
|
||||||
def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)):
|
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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found.")
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
|
||||||
# Match PRD pricing parameters (amounts in paise)
|
pricing_map = {"Pro": 29900, "Al_Coach": 59900}
|
||||||
pricing_map = {
|
|
||||||
"Pro": 29900, # ₹299/mo
|
|
||||||
"Al_Coach": 59900 # ₹599/mo
|
|
||||||
}
|
|
||||||
|
|
||||||
amount = pricing_map.get(plan_tier)
|
amount = pricing_map.get(plan_tier)
|
||||||
if not amount:
|
if not amount:
|
||||||
raise HTTPException(status_code=400, detail="Invalid subscription tier requested.")
|
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"])
|
@app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"])
|
||||||
async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)):
|
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_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", "")
|
signature = request.headers.get("X-Razorpay-Signature", "")
|
||||||
|
|
||||||
# Security Check: Verify signature
|
|
||||||
if not verify_webhook_signature(payload_str, signature):
|
if not verify_webhook_signature(payload_str, signature):
|
||||||
raise HTTPException(status_code=400, detail="Invalid webhook signature.")
|
raise HTTPException(status_code=400, detail="Invalid webhook signature.")
|
||||||
|
|
||||||
import json
|
import json
|
||||||
data = json.loads(payload_str)
|
data = json.loads(payload_str)
|
||||||
|
|
||||||
# Process the successful payment event
|
|
||||||
if data.get("event") == "payment.captured":
|
if data.get("event") == "payment.captured":
|
||||||
notes = data["payload"]["payment"]["entity"]["notes"]
|
notes = data["payload"]["payment"]["entity"]["notes"]
|
||||||
user_id = notes.get("user_id")
|
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)
|
user = session.get(models.User, user_id)
|
||||||
if user and tier_requested:
|
if user and tier_requested:
|
||||||
# Upgrade the user's tier in the database
|
|
||||||
user.tier = models.TierEnum(tier_requested)
|
user.tier = models.TierEnum(tier_requested)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|
||||||
# Log the subscription record
|
|
||||||
from datetime import timedelta
|
|
||||||
new_sub = models.Subscription(
|
new_sub = models.Subscription(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
tier=models.TierEnum(tier_requested),
|
tier=models.TierEnum(tier_requested),
|
||||||
@@ -390,11 +363,10 @@ async def razorpay_webhook_handler(request: Request, session: Session = Depends(
|
|||||||
|
|
||||||
return {"status": "Webhook processed successfully"}
|
return {"status": "Webhook processed successfully"}
|
||||||
|
|
||||||
# --- STUDY PLAN ENDPOINTS ---
|
# --- STUDY PLAN ---
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
@app.get("/api/ai-coach/study-plan/today", tags=["AI Coach"])
|
@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()
|
today = datetime.utcnow().date()
|
||||||
plan = session.exec(
|
plan = session.exec(
|
||||||
select(models.StudyPlan)
|
select(models.StudyPlan)
|
||||||
@@ -413,32 +385,22 @@ class ChatRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"])
|
@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"])
|
||||||
async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)):
|
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)
|
user = session.get(models.User, request.user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="Student profile not found.")
|
raise HTTPException(status_code=404, detail="Student profile not found.")
|
||||||
if user.tier != models.TierEnum.AI_COACH:
|
if user.tier != models.TierEnum.AI_COACH:
|
||||||
raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.")
|
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(
|
return StreamingResponse(
|
||||||
stream_tutor_response(request.user_id, request.message, session),
|
stream_tutor_response(request.user_id, request.message, session),
|
||||||
media_type="text/event-stream"
|
media_type="text/event-stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/ai-coach/readiness/{user_id}", tags=["AI Coach"])
|
@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]))):
|
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.
|
|
||||||
"""
|
|
||||||
readiness_data = calculate_readiness_score(user_id, session)
|
readiness_data = calculate_readiness_score(user_id, session)
|
||||||
if not readiness_data:
|
if not readiness_data:
|
||||||
raise HTTPException(status_code=404, detail="Could not calculate readiness score.")
|
raise HTTPException(status_code=404, detail="Could not calculate readiness score.")
|
||||||
|
|
||||||
return readiness_data
|
return readiness_data
|
||||||
|
|
||||||
@app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"])
|
@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.")
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
|
||||||
link = generate_referral_link(user_id)
|
link = generate_referral_link(user_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"referral_link": link,
|
"referral_link": link,
|
||||||
"total_referrals": 0, # In production, query a referral tracking table
|
"total_referrals": 0,
|
||||||
"rewards_earned_days": 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}"
|
"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"])
|
@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
||||||
def get_user_profile(user_id: UUID, session: Session = Depends(get_session)):
|
def get_user_profile(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
user = session.get(models.User, user_id)
|
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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
if payload.name is not None:
|
if payload.name is not None: user.name = payload.name
|
||||||
user.name = payload.name
|
if payload.target_exam is not None: user.target_exam = payload.target_exam
|
||||||
if payload.target_exam is not None:
|
if payload.student_class is not None: user.student_class = payload.student_class
|
||||||
user.target_exam = payload.target_exam
|
if payload.school is not None: user.school = payload.school
|
||||||
if payload.student_class is not None:
|
if payload.state is not None: user.state = payload.state
|
||||||
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.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
from fastapi import UploadFile, File
|
|
||||||
from storage import upload_file
|
from storage import upload_file
|
||||||
|
|
||||||
@app.post("/api/users/{user_id}/upload-avatar", tags=["Users"])
|
@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}
|
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"])
|
@app.get("/api/users/{user_id}/attempts", tags=["Users"])
|
||||||
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
|
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
|
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
|
||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- ADMIN ROUTES ---
|
# --- ADMIN ROUTES ---
|
||||||
|
|
||||||
@app.get("/api/admin/tests", tags=["Admin"])
|
@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()
|
tests = session.exec(select(models.Test)).all()
|
||||||
return tests
|
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"])
|
@app.post("/api/admin/tests/upload", tags=["Admin"])
|
||||||
async def upload_admin_test(
|
async def upload_admin_test(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -553,7 +522,6 @@ async def upload_admin_test(
|
|||||||
if not url:
|
if not url:
|
||||||
raise HTTPException(status_code=500, detail="Failed to upload PDF to MinIO")
|
raise HTTPException(status_code=500, detail="Failed to upload PDF to MinIO")
|
||||||
|
|
||||||
# Create inactive test
|
|
||||||
test_obj = models.Test(
|
test_obj = models.Test(
|
||||||
title=title,
|
title=title,
|
||||||
type="Mock",
|
type="Mock",
|
||||||
@@ -562,14 +530,13 @@ async def upload_admin_test(
|
|||||||
total_time=time_limit,
|
total_time=time_limit,
|
||||||
difficulty="Medium",
|
difficulty="Medium",
|
||||||
scheduled_date=scheduled_date,
|
scheduled_date=scheduled_date,
|
||||||
is_active=False, # Inactive until processing finishes
|
is_active=False,
|
||||||
created_by=admin_id
|
created_by=admin_id
|
||||||
)
|
)
|
||||||
session.add(test_obj)
|
session.add(test_obj)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(test_obj)
|
session.refresh(test_obj)
|
||||||
|
|
||||||
# Trigger async background worker
|
|
||||||
process_pdf_test.delay(
|
process_pdf_test.delay(
|
||||||
test_id_str=str(test_obj.id),
|
test_id_str=str(test_obj.id),
|
||||||
object_name=object_name,
|
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"}
|
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"])
|
@app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"])
|
||||||
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session)):
|
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session)):
|
||||||
test_obj = session.get(models.Test, test_id)
|
test_obj = session.get(models.Test, test_id)
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class Test(SQLModel, table=True):
|
|||||||
total_time: int
|
total_time: int
|
||||||
difficulty: str
|
difficulty: str
|
||||||
scheduled_date: Optional[datetime] = None
|
scheduled_date: Optional[datetime] = None
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
created_by: UUID
|
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,
|
topic=topic,
|
||||||
subtopic="General",
|
subtopic="General",
|
||||||
difficulty="Medium",
|
difficulty="Medium",
|
||||||
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
type=models.QuestionTypeEnum.NUMERICAL,
|
||||||
question_text=q_text,
|
question_text=q_text,
|
||||||
options=options,
|
options=None,
|
||||||
correct_answer={"value": "A"},
|
correct_answer={"value": "Subjective input"},
|
||||||
solution_text=ans_text,
|
solution_text=ans_text,
|
||||||
language=models.MediumEnum.ENGLISH,
|
language=models.MediumEnum.ENGLISH,
|
||||||
exam=models.ExamEnum.JEE_MAIN,
|
exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
|||||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -8,7 +8,9 @@
|
|||||||
"name": "pragativeer-v2",
|
"name": "pragativeer-v2",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/react-datepicker": "^6.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"framer-motion": "^12.41.0",
|
"framer-motion": "^12.41.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"idb-keyval": "^6.2.6",
|
"idb-keyval": "^6.2.6",
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-katex": "^3.1.0",
|
"react-katex": "^3.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -467,6 +470,59 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1746,6 +1802,27 @@
|
|||||||
"csstype": "^3.2.2"
|
"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": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
@@ -3194,6 +3271,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -7065,6 +7152,42 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
@@ -7955,6 +8078,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/react-datepicker": "^6.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"framer-motion": "^12.41.0",
|
"framer-motion": "^12.41.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"idb-keyval": "^6.2.6",
|
"idb-keyval": "^6.2.6",
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-katex": "^3.1.0",
|
"react-katex": "^3.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ReactNode } from "react"
|
import { ReactNode, useEffect } from "react"
|
||||||
import { LayoutDashboard, Users, Database, Terminal, Settings, FileText } from "lucide-react"
|
import { LayoutDashboard, Users, Database, Terminal, Settings, FileText } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
@@ -9,6 +9,10 @@ import { Logo } from "@/components/Logo"
|
|||||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-[#00F0FF]/20">
|
<div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-[#00F0FF]/20">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Search, Download, MoreVertical } from "lucide-react"
|
import { Search, Download, MoreVertical } from "lucide-react"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Loader2, Upload, FileText, Clock, CalendarIcon } from "lucide-react"
|
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"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
@@ -15,13 +17,13 @@ export default function AdminTestsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const { userId } = useActiveUser()
|
const { userId } = useActiveUser()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
// Upload Form State
|
// Upload Form State
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const [examPattern, setExamPattern] = useState("JEE_Main")
|
const [examPattern, setExamPattern] = useState("JEE_Main")
|
||||||
const [timeLimit, setTimeLimit] = useState("180")
|
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 () => {
|
const fetchTests = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -56,37 +58,48 @@ export default function AdminTestsPage() {
|
|||||||
|
|
||||||
const handleUpload = async (e: React.FormEvent) => {
|
const handleUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!fileInputRef.current?.files?.[0]) return
|
if (!questionsJson) return
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", fileInputRef.current.files[0])
|
let parsedQuestions = [];
|
||||||
formData.append("title", title)
|
try {
|
||||||
formData.append("exam_pattern", examPattern)
|
parsedQuestions = JSON.parse(questionsJson);
|
||||||
formData.append("time_limit", timeLimit)
|
} catch(err) {
|
||||||
formData.append("admin_id", userId)
|
alert("Invalid JSON format. Please paste a valid JSON array.");
|
||||||
if (scheduledDate) {
|
setUploading(false);
|
||||||
formData.append("scheduled_date", new Date(scheduledDate).toISOString())
|
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 {
|
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",
|
method: "POST",
|
||||||
body: formData
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("Test uploaded and sent to AI for processing!")
|
alert("Test created successfully!")
|
||||||
setTitle("")
|
setTitle("")
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
setQuestionsJson("")
|
||||||
fetchTests()
|
fetchTests()
|
||||||
} else {
|
} else {
|
||||||
alert("Upload failed")
|
alert("Creation failed")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
alert("Upload failed")
|
alert("Creation failed")
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
@@ -106,9 +119,9 @@ export default function AdminTestsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Upload className="w-5 h-5 text-primary" />
|
<Upload className="w-5 h-5 text-primary" />
|
||||||
Upload New Test
|
Create Manual Test
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleUpload} className="space-y-4">
|
<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)} />
|
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<label className="text-sm font-medium">Scheduled Date (Optional)</label>
|
<div className="space-y-2 flex flex-col">
|
||||||
<Input type="datetime-local" value={scheduledDate} onChange={e => setScheduledDate(e.target.value)} />
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Test PDF File</label>
|
<label className="text-sm font-medium">Questions JSON (LaTeX Format)</label>
|
||||||
<Input required type="file" accept=".pdf" ref={fileInputRef} className="cursor-pointer" />
|
<textarea
|
||||||
<p className="text-xs text-muted-foreground mt-1">Make sure it contains clear questions and options.</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={uploading}>
|
<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 ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
||||||
{uploading ? "Processing PDF via AI..." : "Upload & Parse"}
|
{uploading ? "Creating Test..." : "Create Test from JSON"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
id: qData.id,
|
id: qData.id,
|
||||||
number: idx + 1,
|
number: idx + 1,
|
||||||
subject: qData.subject,
|
subject: qData.subject,
|
||||||
|
type: qData.type,
|
||||||
text_en: qData.question_text,
|
text_en: qData.question_text,
|
||||||
text_hi: qData.question_text_hi || qData.question_text,
|
text_hi: qData.question_text_hi || qData.question_text,
|
||||||
options: Object.values(qData.options || {}),
|
options: Object.values(qData.options || {}),
|
||||||
@@ -351,7 +352,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 mt-8">
|
<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 letter = String.fromCharCode(65 + idx);
|
||||||
const isSelected = currentState?.selected_answer === letter;
|
const isSelected = currentState?.selected_answer === letter;
|
||||||
return (
|
return (
|
||||||
@@ -374,7 +383,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<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>
|
<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>
|
||||||
|
<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>
|
||||||
<div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-relaxed text-foreground font-medium">
|
<div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-relaxed text-foreground font-medium">
|
||||||
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
@@ -395,7 +408,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 mt-8">
|
<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 letter = String.fromCharCode(65 + idx);
|
||||||
const isSelected = currentState?.selected_answer === letter;
|
const isSelected = currentState?.selected_answer === letter;
|
||||||
return (
|
return (
|
||||||
@@ -418,7 +439,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -503,7 +525,13 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t border-border bg-background flex flex-col gap-3">
|
<div className="p-4 border-t border-border bg-background flex flex-col gap-3">
|
||||||
<div className="flex gap-2">
|
<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>
|
<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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function AnalyticsPage() {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{fill: 'transparent'}}
|
cursor={{fill: 'transparent'}}
|
||||||
contentStyle={{ backgroundColor: '#1f2937', borderColor: '#374151', borderRadius: '8px', color: '#fff' }}
|
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}>
|
<Bar dataKey="accuracy" radius={[0, 4, 4, 0]} barSize={12}>
|
||||||
{heatmapData.map((entry, index) => (
|
{heatmapData.map((entry, index) => (
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
|
|||||||
{idx + 1}
|
{idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="flex gap-2 mt-2">
|
||||||
<Badge variant="outline" className="text-xs">{q.topic}</Badge>
|
<Badge variant="outline" className="text-xs">{q.topic}</Badge>
|
||||||
<Badge variant="outline" className="text-xs">{q.difficulty}</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">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-bold text-muted-foreground uppercase">Your Answer</p>
|
<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'}`}>
|
<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>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-bold text-muted-foreground uppercase">Correct Answer</p>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function CBTPage() {
|
|||||||
if (!userRes.ok) throw new Error("Failed to load user target exam");
|
if (!userRes.ok) throw new Error("Failed to load user target exam");
|
||||||
const userData = await userRes.json();
|
const userData = await userRes.json();
|
||||||
const pattern = userData.target_exam;
|
const pattern = userData.target_exam;
|
||||||
setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main");
|
setTargetExam(pattern === "NEET" ? "NEET" : pattern === "JEE_Advanced" ? "JEE Advanced" : "JEE Main");
|
||||||
|
|
||||||
// 2. Fetch tests filtered by exam
|
// 2. Fetch tests filtered by exam
|
||||||
const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`);
|
const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
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 {
|
interface Doubt {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +21,7 @@ interface Doubt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CommunityPage() {
|
export default function CommunityPage() {
|
||||||
|
const { hasAccessTo } = useActiveUser()
|
||||||
const [activeFilter, setActiveFilter] = useState<'all' | 'my-doubts' | 'solved' | 'unsolved'>('all')
|
const [activeFilter, setActiveFilter] = useState<'all' | 'my-doubts' | 'solved' | 'unsolved'>('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
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>
|
<p className="text-muted-foreground mt-2 font-medium">Ask questions, share knowledge, and learn together with the community.</p>
|
||||||
</div>
|
</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">
|
{hasAccessTo("ask-doubts-from-tutors") ? (
|
||||||
Ask a Doubt
|
<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">
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<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 { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
export default function DPPPage() {
|
export default function DPPPage() {
|
||||||
|
const { user } = useActiveUser()
|
||||||
const [dpps, setDpps] = useState<any[]>([])
|
const [dpps, setDpps] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchDpps() {
|
async function fetchDpps() {
|
||||||
|
if (!user) return; // Wait for user to be available
|
||||||
|
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setDpps(data)
|
setDpps(data)
|
||||||
@@ -26,7 +32,7 @@ export default function DPPPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchDpps()
|
fetchDpps()
|
||||||
}, [])
|
}, [user])
|
||||||
|
|
||||||
const getDifficultyColor = (diff: string) => {
|
const getDifficultyColor = (diff: string) => {
|
||||||
switch(diff) {
|
switch(diff) {
|
||||||
@@ -80,7 +86,7 @@ export default function DPPPage() {
|
|||||||
Object.values(dpp.sections).forEach((s: any) => { count += (s.questions?.length || 0) });
|
Object.values(dpp.sections).forEach((s: any) => { count += (s.questions?.length || 0) });
|
||||||
}
|
}
|
||||||
// Temporary mock for status
|
// Temporary mock for status
|
||||||
const status = "Pending"
|
let status: string = "Pending"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${status === 'Completed' ? 'opacity-70 hover:opacity-100 bg-secondary/10' : 'hover:border-[#00F0FF]/50 bg-background'}`}>
|
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${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>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<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-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{dpp.difficulty} Level</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{dpp.difficulty} Level</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +121,7 @@ export default function DPPPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href={`/cbt/${dpp.id}`}>
|
<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" />
|
<Play size={16} className="ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<NavItem href="/dashboard/leaderboard" icon={<Trophy size={18} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} />
|
<NavItem href="/dashboard/leaderboard" icon={<Trophy size={18} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} />
|
||||||
<NavItem href="/dashboard/progress" icon={<Activity size={18} />} label="Syllabus Tracker" active={pathname.startsWith("/dashboard/progress")} />
|
<NavItem href="/dashboard/progress" icon={<Activity size={18} />} label="Syllabus Tracker" active={pathname.startsWith("/dashboard/progress")} />
|
||||||
<NavItem href="/dashboard/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">
|
<div className="pt-4 pb-2">
|
||||||
<p className="px-3 text-xs font-bold uppercase tracking-widest text-muted-foreground">AI Intelligence</p>
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
{/* Streak count moved to top header */}
|
{/* Streak count moved to top header */}
|
||||||
{user && (
|
{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} />
|
<StreakCounter streak={user.streak_count} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-6xl mx-auto space-y-10 animate-in fade-in duration-500 slide-in-from-bottom-4">
|
<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 */}
|
{/* Free Tier Upgrade CTA Banner */}
|
||||||
<Card className="bg-primary/5 border-primary/20 relative overflow-hidden">
|
<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>
|
<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>
|
<h2 className="text-2xl font-bold tracking-tight">Unlock Unlimited DPS & Mocks</h2>
|
||||||
<p className="text-muted-foreground max-w-xl font-medium">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" className="shrink-0 rounded-xl shadow-[0_0_20px_-5px_var(--color-primary)] font-bold">
|
<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={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
if (window.location.pathname.startsWith('/dashboard') || window.location.pathname.startsWith('/cbt')) {
|
||||||
document.documentElement.classList.add('dark')
|
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 {
|
} else {
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.add('dark')
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import { BrainCircuit, Activity, Zap, CheckCircle2, Shield, ArrowRight, Play, Bo
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { motion, useScroll, useTransform, Variants } from "framer-motion"
|
import { motion, useScroll, useTransform, Variants } from "framer-motion"
|
||||||
import { useRef } from "react"
|
import { useRef, useEffect } from "react"
|
||||||
|
|
||||||
export default function MarketingPage() {
|
export default function MarketingPage() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
const { scrollYProgress } = useScroll({
|
const { scrollYProgress } = useScroll({
|
||||||
target: containerRef,
|
target: containerRef,
|
||||||
@@ -153,7 +157,7 @@ export default function MarketingPage() {
|
|||||||
</div>
|
</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="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>
|
<div className="size-2 rounded-full bg-[#00F0FF] animate-pulse"></div>
|
||||||
pragativeer.ai/dashboard/student
|
pragativeer.com/dashboard/student
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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>
|
<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>
|
</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
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -50 }}
|
initial={{ opacity: 0, x: -50 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6, type: "spring" }}
|
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>
|
<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>
|
<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">
|
<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" /> Unlimited Daily Practice Sheets</li>
|
||||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-primary" /> Weekly Test Series & Mock Tests</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>
|
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-primary" /> Parent Dashboard Access</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Link href="/signup">
|
<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>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* AI Coach Plan */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 50 }}
|
initial={{ opacity: 0, x: 50 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6, type: "spring", delay: 0.1 }}
|
transition={{ duration: 0.6, type: "spring", delay: 0.2 }}
|
||||||
className="relative"
|
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 -top-4 left-1/2 -translate-x-1/2 z-20">
|
||||||
<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>
|
<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 className="absolute top-0 right-10 transform -translate-y-1/2 z-20">
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
<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)]">₹599<span className="text-xl text-foreground font-medium">/mo</span></div>
|
<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">
|
<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]" /> Everything in Pro</li>
|
||||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-[#00F0FF]" /> AI Spaced Repetition (Mistakes)</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
|
<RotateCcw className="size-5" /> Retake Mock Test
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/dashboard/cbt">
|
<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" />
|
Dashboard <ArrowRight className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -14,19 +14,17 @@ export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) {
|
|||||||
|
|
||||||
return (
|
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="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">
|
<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>
|
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
|
||||||
<div className="flex gap-2">
|
<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">
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-2 p-2">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-zinc-950 p-6 flex items-center justify-center relative overflow-auto">
|
<div className="flex-1 bg-white p-4 flex items-start justify-start relative overflow-auto">
|
||||||
{/* We will use a high quality remote SVG/image for the periodic table reference */}
|
<div className="relative w-[1800px] h-[1100px] shrink-0">
|
||||||
<div className="relative w-full h-full min-w-[800px] min-h-[500px]">
|
|
||||||
{/* Using a reliable wikimedia SVG for Periodic Table as a reference */}
|
{/* Using a reliable wikimedia SVG for Periodic Table as a reference */}
|
||||||
<Image
|
<Image
|
||||||
src="https://upload.wikimedia.org/wikipedia/commons/4/4d/Periodic_table_large.svg"
|
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 streak = user.streak_count;
|
||||||
|
|
||||||
|
const targetExam = user.target_exam?.replace("_", " ") || "JEE/NEET";
|
||||||
|
|
||||||
// Streak-based wishes and titles
|
// Streak-based wishes and titles
|
||||||
let wishTitle = "Welcome to PragatiVeer! 🌟";
|
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 iconTheme = "text-[#00F0FF] bg-[#00F0FF]/10";
|
||||||
let iconComponent = <Sparkles className="size-10" />;
|
let iconComponent = <Sparkles className="size-10" />;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Flame } from "lucide-react"
|
import { Flame } from "lucide-react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
interface StreakCounterProps {
|
interface StreakCounterProps {
|
||||||
streak: number
|
streak: number
|
||||||
@@ -8,9 +9,15 @@ interface StreakCounterProps {
|
|||||||
|
|
||||||
export function StreakCounter({ streak }: StreakCounterProps) {
|
export function StreakCounter({ streak }: StreakCounterProps) {
|
||||||
return (
|
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" />
|
<Flame size={18} className="animate-pulse" />
|
||||||
<span>{streak}</span>
|
<span>{streak}</span>
|
||||||
</div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface ExamAttemptPayload {
|
|||||||
question_id: string
|
question_id: string
|
||||||
selected_answer: string | null
|
selected_answer: string | null
|
||||||
is_correct: boolean // Typically evaluated on server, but requested in payload structure
|
is_correct: boolean // Typically evaluated on server, but requested in payload structure
|
||||||
|
is_subjective: boolean
|
||||||
time_spent: number
|
time_spent: number
|
||||||
}>
|
}>
|
||||||
score: number
|
score: number
|
||||||
@@ -200,16 +201,17 @@ export const useCBTStore = create<CBTState>()(
|
|||||||
const timeTaken = Math.floor((Date.now() - state.startTime) / 1000);
|
const timeTaken = Math.floor((Date.now() - state.startTime) / 1000);
|
||||||
const answerList = Object.values(state.answers);
|
const answerList = Object.values(state.answers);
|
||||||
|
|
||||||
// Mock evaluation (In reality, server does this)
|
// Backend will perform secure evaluation
|
||||||
let correctCount = 0;
|
let correctCount = 0; // Removing mock count
|
||||||
const finalAnswers = answerList.map(a => {
|
const finalAnswers = answerList.map(a => {
|
||||||
// Assuming 'A' is correct for dummy data
|
const q = state.questions.find(quest => quest.id === a.question_id);
|
||||||
const isCorrect = a.selected_answer === 'A';
|
const isSubjective = q?.type === 'Subjective' || q?.type === 'Numerical';
|
||||||
if (isCorrect) correctCount++;
|
|
||||||
return {
|
return {
|
||||||
question_id: a.question_id,
|
question_id: a.question_id,
|
||||||
selected_answer: a.selected_answer,
|
selected_answer: a.selected_answer,
|
||||||
is_correct: isCorrect,
|
is_correct: false,
|
||||||
|
is_subjective: isSubjective,
|
||||||
time_spent: a.time_spent
|
time_spent: a.time_spent
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user