fix(dashboard): DPP tags properly dynamic based on user target exam

This commit is contained in:
2026-07-08 12:46:44 +05:30
parent c87b305646
commit 1a6168f724
28 changed files with 853 additions and 304 deletions

BIN
backend.log Normal file

Binary file not shown.

65
backend/fix.py Normal file
View 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
View 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
View 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.')

View File

@@ -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)

View File

@@ -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
View File

View 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 */}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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">

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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}`);

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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">

View File

@@ -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 (_) {}
`, `,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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" />;

View File

@@ -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>
) )
} }

View File

@@ -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
} }
}); });