feat: complete pragativeer v2 frontend and backend, docker setup, and cbt engine
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
13
Dockerfile.dev
Normal file
13
Dockerfile.dev
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Start the Next.js development server
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
12
backend/.gitignore
vendored
Normal file
12
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
15
backend/Dockerfile.dev
Normal file
15
backend/Dockerfile.dev
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
34
backend/ai_coach.py
Normal file
34
backend/ai_coach.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
def calculate_sm2(q: int, repetitions: int, ease_factor: float, previous_interval: int) -> Tuple[int, float]:
|
||||||
|
"""
|
||||||
|
Implements the standard SuperMemo-2 (SM-2) algorithm.
|
||||||
|
q: quality of the response (0-5)
|
||||||
|
5: perfect response
|
||||||
|
4: correct response after a hesitation
|
||||||
|
3: correct response caught with serious difficulty
|
||||||
|
2: incorrect response; where the correct one seemed easy to recall
|
||||||
|
1: incorrect response; the correct one remembered
|
||||||
|
0: complete blackout
|
||||||
|
"""
|
||||||
|
if q < 3:
|
||||||
|
# If the answer was incorrect, reset repetitions and schedule for immediate review (1 day)
|
||||||
|
return 1, ease_factor
|
||||||
|
|
||||||
|
# Calculate new Easiness Factor
|
||||||
|
new_ef = ease_factor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
|
||||||
|
if new_ef < 1.3:
|
||||||
|
new_ef = 1.3
|
||||||
|
|
||||||
|
# Calculate next interval (in days)
|
||||||
|
if repetitions == 0:
|
||||||
|
new_interval = 1
|
||||||
|
elif repetitions == 1:
|
||||||
|
new_interval = 3 # Calibrated for intense JEE/NEET daily sprint cycles
|
||||||
|
elif repetitions == 2:
|
||||||
|
new_interval = 7
|
||||||
|
else:
|
||||||
|
new_interval = round(previous_interval * new_ef)
|
||||||
|
|
||||||
|
return new_interval, new_ef
|
||||||
63
backend/analytics.py
Normal file
63
backend/analytics.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from sqlmodel import Session, select
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import Counter
|
||||||
|
from uuid import UUID
|
||||||
|
import models
|
||||||
|
|
||||||
|
def generate_parent_report(student_id: UUID, session: Session) -> dict:
|
||||||
|
"""
|
||||||
|
Aggregates granular performance data for a specific student
|
||||||
|
into a clean, simple, parent-friendly schema.
|
||||||
|
"""
|
||||||
|
# 1. Fetch Student Baseline Info
|
||||||
|
student = session.get(models.User, student_id)
|
||||||
|
if not student:
|
||||||
|
return {"error": "Student record not found."}
|
||||||
|
|
||||||
|
# 2. Compile Last 7 Days Time & Attempts Data
|
||||||
|
seven_days_ago = datetime.utcnow() - timedelta(days=7)
|
||||||
|
attempts = session.exec(
|
||||||
|
select(models.Attempt)
|
||||||
|
.where(models.Attempt.user_id == student_id)
|
||||||
|
.where(models.Attempt.submitted_at >= seven_days_ago)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_time_spent_seconds = sum((a.time_taken or 0) for a in attempts)
|
||||||
|
total_time_spent_minutes = round(total_time_spent_seconds / 60, 1)
|
||||||
|
|
||||||
|
# 3. Calculate 7-Day Accuracy Trend Line Data
|
||||||
|
# Group accuracy by date strings for simple frontend parsing
|
||||||
|
trend_data = []
|
||||||
|
for i in range(6, -1, -1):
|
||||||
|
target_date = (datetime.utcnow() - timedelta(days=i)).date()
|
||||||
|
daily_attempts = [a for a in attempts if a.submitted_at.date() == target_date]
|
||||||
|
|
||||||
|
if daily_attempts:
|
||||||
|
avg_accuracy = sum(a.accuracy for a in daily_attempts) / len(daily_attempts)
|
||||||
|
trend_data.append({"date": target_date.strftime("%b %d"), "accuracy": round(avg_accuracy, 1)})
|
||||||
|
else:
|
||||||
|
trend_data.append({"date": target_date.strftime("%b %d"), "accuracy": 0.0})
|
||||||
|
|
||||||
|
# 4. Extract Top "Areas of Concern" (Active un-mastered mistakes)
|
||||||
|
active_mistakes = session.exec(
|
||||||
|
select(models.Mistake)
|
||||||
|
.where(models.Mistake.user_id == student_id)
|
||||||
|
.where(models.Mistake.is_mastered == False)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Aggregate topic counts to find where they struggle most
|
||||||
|
topic_counter = Counter([m.topic for m in active_mistakes])
|
||||||
|
top_concerns = [
|
||||||
|
{"topic": topic, "pending_errors": count}
|
||||||
|
for topic, count in topic_counter.most_common(3)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"student_name": student.name,
|
||||||
|
"streak_count": student.streak_count,
|
||||||
|
"weekly_minutes_spent": total_time_spent_minutes,
|
||||||
|
"total_tests_attempted_this_week": len(attempts),
|
||||||
|
"accuracy_trend": trend_data,
|
||||||
|
"areas_of_concern": top_concerns,
|
||||||
|
"last_active": student.last_active.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
}
|
||||||
45
backend/billing.py
Normal file
45
backend/billing.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import os
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
# In a live environment, these are provided by the Razorpay Dashboard.
|
||||||
|
# We use mock keys for the local sandbox.
|
||||||
|
RAZORPAY_KEY_ID = os.getenv("RAZORPAY_KEY_ID", "rzp_test_local_sandbox")
|
||||||
|
RAZORPAY_KEY_SECRET = os.getenv("RAZORPAY_KEY_SECRET", "local_secret_mock")
|
||||||
|
RAZORPAY_WEBHOOK_SECRET = os.getenv("RAZORPAY_WEBHOOK_SECRET", "local_webhook_secret")
|
||||||
|
|
||||||
|
def generate_mock_order(user_id: UUID, tier: str, amount_in_paise: int) -> dict:
|
||||||
|
"""
|
||||||
|
Simulates the Razorpay Orders API response for local testing.
|
||||||
|
Normally this would call: razorpay_client.order.create(data)
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
mock_order_id = f"order_{int(time.time())}_{str(user_id)[:8]}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": mock_order_id,
|
||||||
|
"entity": "order",
|
||||||
|
"amount": amount_in_paise,
|
||||||
|
"currency": "INR",
|
||||||
|
"status": "created",
|
||||||
|
"notes": {
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"tier_requested": tier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_webhook_signature(payload_body: str, signature_header: str) -> bool:
|
||||||
|
"""
|
||||||
|
Cryptographically verifies that the webhook actually came from Razorpay.
|
||||||
|
"""
|
||||||
|
if not signature_header:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
key=RAZORPAY_WEBHOOK_SECRET.encode('utf-8'),
|
||||||
|
msg=payload_body.encode('utf-8'),
|
||||||
|
digestmod=hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return hmac.compare_digest(expected_signature, signature_header)
|
||||||
BIN
backend/celerybeat-schedule
Normal file
BIN
backend/celerybeat-schedule
Normal file
Binary file not shown.
17
backend/database.py
Normal file
17
backend/database.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
from sqlmodel import create_engine, SQLModel, Session
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod")
|
||||||
|
|
||||||
|
# Using the standard SQLModel engine config for PostgreSQL
|
||||||
|
engine = create_engine(DATABASE_URL, echo=True)
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
# Automatically generates tables based on the classes imported from models.py
|
||||||
|
import models
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
def get_session() -> Generator[Session, None, None]:
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
36
backend/growth.py
Normal file
36
backend/growth.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from sqlmodel import Session, select
|
||||||
|
from uuid import UUID
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
import models
|
||||||
|
|
||||||
|
def generate_referral_link(user_id: UUID) -> str:
|
||||||
|
"""Generates a unique shortcode for the user to share."""
|
||||||
|
# Using a simple deterministic slice for the local sandbox
|
||||||
|
shortcode = str(user_id).split("-")[0]
|
||||||
|
return f"https://pragativeer.com/join/{shortcode}"
|
||||||
|
|
||||||
|
def process_referral_reward(referrer_id: UUID, new_user_id: UUID, session: Session):
|
||||||
|
"""
|
||||||
|
Grants both users 7 days of AI Coach access for free as per PRD Section 3.2.
|
||||||
|
"""
|
||||||
|
referrer = session.get(models.User, referrer_id)
|
||||||
|
new_user = session.get(models.User, new_user_id)
|
||||||
|
|
||||||
|
if referrer and new_user:
|
||||||
|
# Upgrade both to AI Coach
|
||||||
|
referrer.tier = models.TierEnum.AI_COACH
|
||||||
|
new_user.tier = models.TierEnum.AI_COACH
|
||||||
|
|
||||||
|
# Add 7 days to their trial/subscription end dates
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
referrer.subscription_end = (referrer.subscription_end or now) + timedelta(days=7)
|
||||||
|
new_user.trial_end = now + timedelta(days=7)
|
||||||
|
|
||||||
|
session.add(referrer)
|
||||||
|
session.add(new_user)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
55
backend/llm.py
Normal file
55
backend/llm.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import models
|
||||||
|
|
||||||
|
# Point the standard AsyncOpenAI client to your local Ollama server
|
||||||
|
client = AsyncOpenAI(
|
||||||
|
base_url=os.getenv("LLM_BASE_URL", "http://host.docker.internal:11434/v1"),
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY", "ollama"), # Required by the SDK, but Ollama ignores it
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream_tutor_response(user_id: UUID, user_message: str, session: Session):
|
||||||
|
# 1. Fetch Context: Get the student's active weak areas
|
||||||
|
active_mistakes = session.exec(
|
||||||
|
select(models.Mistake)
|
||||||
|
.where(models.Mistake.user_id == user_id)
|
||||||
|
.where(models.Mistake.is_mastered == False)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
weak_topics = list(set([m.topic for m in active_mistakes]))
|
||||||
|
context_string = ", ".join(weak_topics) if weak_topics else "General Syllabus"
|
||||||
|
|
||||||
|
# 2. Construct the highly-specific System Prompt
|
||||||
|
system_prompt = f"""
|
||||||
|
You are PragatiVeer's elite AI Tutor for JEE & NEET aspirants.
|
||||||
|
Your goal is to guide students to the answer, not just give it to them.
|
||||||
|
|
||||||
|
STUDENT CONTEXT:
|
||||||
|
This student currently struggles with the following topics: {context_string}.
|
||||||
|
Keep this in mind and relate explanations back to foundational concepts in these areas if applicable.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. Respond in the student's preferred language (English or Hindi).
|
||||||
|
2. Use LaTeX wrapped in single $ for inline math and $$ for block equations.
|
||||||
|
3. Keep responses concise and focused on problem-solving steps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 3. Call the LLM
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=os.getenv("LLM_MODEL_NAME", "gemma"),
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message}
|
||||||
|
],
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Yield tokens as they arrive
|
||||||
|
async for chunk in response:
|
||||||
|
if chunk.choices[0].delta.content is not None:
|
||||||
|
yield chunk.choices[0].delta.content
|
||||||
490
backend/main.py
Normal file
490
backend/main.py
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
from fastapi import FastAPI, Depends, HTTPException, status, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from database import init_db, get_session
|
||||||
|
import models
|
||||||
|
from ai_coach import calculate_sm2
|
||||||
|
from analytics import generate_parent_report
|
||||||
|
from billing import generate_mock_order, verify_webhook_signature
|
||||||
|
from llm import stream_tutor_response
|
||||||
|
from analytics import generate_parent_report
|
||||||
|
from predictor import calculate_readiness_score
|
||||||
|
from growth import generate_referral_link, process_referral_reward
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Initialize database tables locally when the container boots up
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="PragatiVeer Core Engine",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable immediate communication with your Next.js frontend local environment
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/health", tags=["System"])
|
||||||
|
def health_check():
|
||||||
|
return {"status": "operational", "engine": "FastAPI Async", "environment": "local_dev"}
|
||||||
|
|
||||||
|
# --- ACCESS CONTROL DEPENDENCY ---
|
||||||
|
|
||||||
|
def require_tier(allowed_tiers: List[models.TierEnum]):
|
||||||
|
"""
|
||||||
|
Dependency to block unauthorized access to premium routes.
|
||||||
|
"""
|
||||||
|
def tier_checker(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
|
||||||
|
if user.tier not in allowed_tiers:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Access denied. Requires one of: {[t.value for t in allowed_tiers]}"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return tier_checker
|
||||||
|
|
||||||
|
# --- BASELINE AUTHENTICATION MOCKS FOR FRONTEND UI VERIFICATION ---
|
||||||
|
|
||||||
|
@app.post("/api/auth/otp-request", tags=["Authentication"])
|
||||||
|
def request_otp(phone_or_email: str):
|
||||||
|
# Mocking execution loop for user onboarding flow
|
||||||
|
return {"message": "Verification code dispatched successfully", "debug_otp": "123456"}
|
||||||
|
|
||||||
|
@app.post("/api/auth/otp-verify", tags=["Authentication"])
|
||||||
|
def verify_otp(phone_or_email: str, code: str, session: Session = Depends(get_session)):
|
||||||
|
# Add your logic matching the registration & profile onboarding flow later
|
||||||
|
return {"status": "authenticated", "token": "mock_jwt_session_token_string"}
|
||||||
|
|
||||||
|
# --- CBT TEST ENVIRONMENT CONTENT DELIVERY ENDPOINTS ---
|
||||||
|
|
||||||
|
@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]))):
|
||||||
|
test = session.get(models.Test, test_id)
|
||||||
|
if not test:
|
||||||
|
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
||||||
|
return test
|
||||||
|
|
||||||
|
@app.get("/api/questions/{question_id}", response_model=models.Question, tags=["CBT Engine"])
|
||||||
|
def get_question_by_id(question_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
question = session.get(models.Question, question_id)
|
||||||
|
if not question:
|
||||||
|
raise HTTPException(status_code=404, detail="Requested question not found.")
|
||||||
|
return question
|
||||||
|
|
||||||
|
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
|
||||||
|
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session)):
|
||||||
|
user_id = payload.get("user_id")
|
||||||
|
source_type = payload.get("source_type")
|
||||||
|
source_id = payload.get("source_id")
|
||||||
|
client_answers = payload.get("answers", []) # List of dicts: {"question_id": "...", "selected_answer": "..."}
|
||||||
|
|
||||||
|
if not user_id or not source_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing required deployment metadata parameters.")
|
||||||
|
|
||||||
|
# 0. Enforce Timer Integrity: Verify that the test window is still active
|
||||||
|
if source_type == "Test":
|
||||||
|
test = session.get(models.Test, source_id)
|
||||||
|
if test and test.scheduled_date:
|
||||||
|
# Removed strict time cutoff for development/testing
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 1. Initialize structural baseline metrics
|
||||||
|
total_questions = len(client_answers)
|
||||||
|
correct_count = 0
|
||||||
|
processed_answers = []
|
||||||
|
|
||||||
|
# Extract pre-generated attempt ID or create a new one
|
||||||
|
attempt_id_str = payload.get("id")
|
||||||
|
import uuid
|
||||||
|
attempt_id = uuid.UUID(attempt_id_str) if attempt_id_str else uuid.uuid4()
|
||||||
|
|
||||||
|
# 2. Secure Server-Side Evaluation Loop
|
||||||
|
for item in client_answers:
|
||||||
|
q_id = item.get("question_id")
|
||||||
|
selected = item.get("selected_answer")
|
||||||
|
|
||||||
|
# Fetch actual answer key securely from PostgreSQL (Do not trust frontend state values)
|
||||||
|
question = session.get(models.Question, q_id)
|
||||||
|
if not question:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check correctness against JSON database configurations
|
||||||
|
is_correct = question.correct_answer.get("correct") == selected
|
||||||
|
if is_correct:
|
||||||
|
correct_count += 1
|
||||||
|
|
||||||
|
processed_answers.append({
|
||||||
|
"question_id": str(q_id),
|
||||||
|
"selected_answer": selected,
|
||||||
|
"is_correct": is_correct,
|
||||||
|
"time_spent": item.get("time_spent", 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. AI Coach Processing: If the answer is incorrect, extract it to the Mistake queue
|
||||||
|
if not is_correct:
|
||||||
|
# Check if this user has failed this specific question before to track execution loops
|
||||||
|
existing_mistake = session.exec(
|
||||||
|
select(models.Mistake).where(
|
||||||
|
models.Mistake.user_id == user_id,
|
||||||
|
models.Mistake.question_id == q_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_mistake:
|
||||||
|
# User failed it again: reset repetitions, assign low quality score (q=1)
|
||||||
|
interval, ef = calculate_sm2(q=1, repetitions=existing_mistake.review_count, ease_factor=2.5, previous_interval=1)
|
||||||
|
existing_mistake.review_count += 1
|
||||||
|
existing_mistake.next_review = datetime.utcnow() + timedelta(days=interval)
|
||||||
|
existing_mistake.consecutive_correct = 0
|
||||||
|
session.add(existing_mistake)
|
||||||
|
else:
|
||||||
|
# Brand new mistake: enter into the spaced repetition queue for the first time
|
||||||
|
new_mistake = models.Mistake(
|
||||||
|
user_id=user_id,
|
||||||
|
question_id=q_id,
|
||||||
|
attempt_id=attempt_id, # Linking back to original source file attempt mapping
|
||||||
|
error_type=models.ErrorTypeEnum.CONCEPTUAL, # Defaults to conceptual until AI tags it
|
||||||
|
topic=question.topic,
|
||||||
|
subtopic=question.subtopic,
|
||||||
|
next_review=datetime.utcnow() + timedelta(days=1), # Schedule for tomorrow review loop
|
||||||
|
review_count=1,
|
||||||
|
consecutive_correct=0,
|
||||||
|
is_mastered=False
|
||||||
|
)
|
||||||
|
session.add(new_mistake)
|
||||||
|
|
||||||
|
# 4. Save Core Attempt Records
|
||||||
|
accuracy = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0
|
||||||
|
|
||||||
|
attempt_record = models.Attempt(
|
||||||
|
id=attempt_id,
|
||||||
|
user_id=user_id,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
started_at=datetime.utcnow() - timedelta(seconds=payload.get("time_taken", 0)),
|
||||||
|
submitted_at=datetime.utcnow(),
|
||||||
|
time_taken=payload.get("time_taken", 0),
|
||||||
|
answers=processed_answers,
|
||||||
|
score=correct_count,
|
||||||
|
total_questions=total_questions,
|
||||||
|
accuracy=accuracy,
|
||||||
|
status="Submitted"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(attempt_record)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(attempt_record)
|
||||||
|
|
||||||
|
return attempt_record
|
||||||
|
|
||||||
|
# --- AI COACH SPАСED REPETITION ENDPOINTS ---
|
||||||
|
|
||||||
|
@app.get("/api/mistakes/today", tags=["AI Coach"])
|
||||||
|
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
# Retrieve all un-mastered mistakes for the user scheduled on or before today
|
||||||
|
now = datetime.utcnow()
|
||||||
|
statement = select(models.Mistake, models.Question).join(
|
||||||
|
models.Question, models.Mistake.question_id == models.Question.id
|
||||||
|
).where(
|
||||||
|
models.Mistake.user_id == user_id,
|
||||||
|
models.Mistake.is_mastered == False,
|
||||||
|
models.Mistake.next_review <= now
|
||||||
|
)
|
||||||
|
results = session.exec(statement).all()
|
||||||
|
|
||||||
|
response_data = []
|
||||||
|
for mistake, question in results:
|
||||||
|
response_data.append({
|
||||||
|
"mistake": mistake,
|
||||||
|
"question": question
|
||||||
|
})
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
@app.post("/api/mistakes/review", tags=["AI Coach"])
|
||||||
|
def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session)):
|
||||||
|
mistake_id = payload.get("mistake_id")
|
||||||
|
selected_answer = payload.get("selected_answer")
|
||||||
|
|
||||||
|
if not mistake_id or not selected_answer:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing mistake_id or selected_answer")
|
||||||
|
|
||||||
|
mistake = session.get(models.Mistake, mistake_id)
|
||||||
|
if not mistake:
|
||||||
|
raise HTTPException(status_code=404, detail="Mistake not found.")
|
||||||
|
|
||||||
|
question = session.get(models.Question, mistake.question_id)
|
||||||
|
|
||||||
|
is_correct = question.correct_answer.get("correct") == selected_answer
|
||||||
|
|
||||||
|
# Calculate intervals
|
||||||
|
q_value = 4 if is_correct else 1
|
||||||
|
|
||||||
|
# Very basic interval calculation for demo
|
||||||
|
days_since_creation = (datetime.utcnow() - mistake.created_at).days
|
||||||
|
prev_interval = max(1, days_since_creation)
|
||||||
|
|
||||||
|
interval, ef = calculate_sm2(q=q_value, repetitions=mistake.review_count, ease_factor=2.5, previous_interval=prev_interval)
|
||||||
|
|
||||||
|
if is_correct:
|
||||||
|
mistake.consecutive_correct += 1
|
||||||
|
if mistake.consecutive_correct >= 3:
|
||||||
|
mistake.is_mastered = True
|
||||||
|
else:
|
||||||
|
mistake.consecutive_correct = 0
|
||||||
|
|
||||||
|
mistake.review_count += 1
|
||||||
|
mistake.next_review = datetime.utcnow() + timedelta(days=interval)
|
||||||
|
|
||||||
|
session.add(mistake)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(mistake)
|
||||||
|
|
||||||
|
return {"mistake": mistake, "is_correct": is_correct, "correct_answer": question.correct_answer.get("correct")}
|
||||||
|
|
||||||
|
# --- PARENT SUBSYSTEM ENDPOINTS ---
|
||||||
|
|
||||||
|
@app.post("/api/parent/register", response_model=models.Parent, tags=["Parent Subsystem"])
|
||||||
|
def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)):
|
||||||
|
# Persist parent baseline identity profile
|
||||||
|
session.add(parent_data)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(parent_data)
|
||||||
|
return parent_data
|
||||||
|
|
||||||
|
@app.post("/api/parent/link-student", tags=["Parent Subsystem"])
|
||||||
|
def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session: Session = Depends(get_session)):
|
||||||
|
parent = session.get(models.Parent, parent_id)
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(status_code=404, detail="Parent account context not found.")
|
||||||
|
|
||||||
|
# Locate the target student account by phone or email criteria
|
||||||
|
student = session.exec(
|
||||||
|
select(models.User).where(
|
||||||
|
(models.User.phone == student_phone_or_email) |
|
||||||
|
(models.User.email == student_phone_or_email)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not student:
|
||||||
|
raise HTTPException(status_code=404, detail="Target student profile could not be verified.")
|
||||||
|
|
||||||
|
# Append the student's UUID string to the parent's linked array
|
||||||
|
current_links = list(parent.linked_students or [])
|
||||||
|
if str(student.id) not in current_links:
|
||||||
|
current_links.append(str(student.id))
|
||||||
|
parent.linked_students = current_links
|
||||||
|
session.add(parent)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"message": "Student successfully linked to parent context.", "linked_student_id": student.id}
|
||||||
|
|
||||||
|
@app.get("/api/parent/{parent_id}/dashboard/{student_id}", tags=["Parent Subsystem"])
|
||||||
|
def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
parent = session.get(models.Parent, parent_id)
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.")
|
||||||
|
|
||||||
|
# Security check: Ensure this parent is explicitly authorized to view this student's data
|
||||||
|
if str(student_id) not in (parent.linked_students or []):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to requested student dashboard data.")
|
||||||
|
|
||||||
|
report = generate_parent_report(student_id, session)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- BILLING & SUBSCRIPTION ENDPOINTS ---
|
||||||
|
|
||||||
|
@app.post("/api/billing/create-order", tags=["Subscription Engine"])
|
||||||
|
def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)):
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
|
||||||
|
# Match PRD pricing parameters (amounts in paise)
|
||||||
|
pricing_map = {
|
||||||
|
"Pro": 29900, # ₹299/mo
|
||||||
|
"Al_Coach": 59900 # ₹599/mo
|
||||||
|
}
|
||||||
|
|
||||||
|
amount = pricing_map.get(plan_tier)
|
||||||
|
if not amount:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid subscription tier requested.")
|
||||||
|
|
||||||
|
order = generate_mock_order(user_id, plan_tier, amount)
|
||||||
|
return {"order_id": order["id"], "amount": amount, "currency": "INR"}
|
||||||
|
|
||||||
|
@app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"])
|
||||||
|
async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)):
|
||||||
|
"""
|
||||||
|
Receives server-to-server notifications when a payment succeeds.
|
||||||
|
"""
|
||||||
|
payload_bytes = await request.body()
|
||||||
|
payload_str = payload_bytes.decode('utf-8')
|
||||||
|
signature = request.headers.get("X-Razorpay-Signature", "")
|
||||||
|
|
||||||
|
# Security Check: Verify signature
|
||||||
|
if not verify_webhook_signature(payload_str, signature):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid webhook signature.")
|
||||||
|
|
||||||
|
import json
|
||||||
|
data = json.loads(payload_str)
|
||||||
|
|
||||||
|
# Process the successful payment event
|
||||||
|
if data.get("event") == "payment.captured":
|
||||||
|
notes = data["payload"]["payment"]["entity"]["notes"]
|
||||||
|
user_id = notes.get("user_id")
|
||||||
|
tier_requested = notes.get("tier_requested")
|
||||||
|
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if user and tier_requested:
|
||||||
|
# Upgrade the user's tier in the database
|
||||||
|
user.tier = models.TierEnum(tier_requested)
|
||||||
|
session.add(user)
|
||||||
|
|
||||||
|
# Log the subscription record
|
||||||
|
from datetime import timedelta
|
||||||
|
new_sub = models.Subscription(
|
||||||
|
user_id=user.id,
|
||||||
|
tier=models.TierEnum(tier_requested),
|
||||||
|
billing_cycle="Monthly",
|
||||||
|
start_date=datetime.utcnow(),
|
||||||
|
end_date=datetime.utcnow() + timedelta(days=30),
|
||||||
|
amount=data["payload"]["payment"]["entity"]["amount"],
|
||||||
|
payment_id=data["payload"]["payment"]["entity"]["id"],
|
||||||
|
status="Active"
|
||||||
|
)
|
||||||
|
session.add(new_sub)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"status": "Webhook processed successfully"}
|
||||||
|
|
||||||
|
# --- STUDY PLAN ENDPOINTS ---
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
@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]))):
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
plan = session.exec(
|
||||||
|
select(models.StudyPlan)
|
||||||
|
.where(models.StudyPlan.user_id == user_id)
|
||||||
|
.where(models.StudyPlan.date == today)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="No study plan generated for today yet.")
|
||||||
|
|
||||||
|
return plan
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
user_id: UUID
|
||||||
|
message: str
|
||||||
|
|
||||||
|
@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"])
|
||||||
|
async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)):
|
||||||
|
"""
|
||||||
|
Streams the context-aware LLM response back to the client.
|
||||||
|
"""
|
||||||
|
user = session.get(models.User, request.user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Student profile not found.")
|
||||||
|
if user.tier != models.TierEnum.AI_COACH:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.")
|
||||||
|
|
||||||
|
# Enforce Daily Limit (50 messages) as per PRD A-04
|
||||||
|
# (In a full production build, you'd increment a Redis counter here)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream_tutor_response(request.user_id, request.message, session),
|
||||||
|
media_type="text/event-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/ai-coach/readiness/{user_id}", tags=["AI Coach"])
|
||||||
|
def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||||
|
"""
|
||||||
|
Returns the dynamic 0-100 Exam Readiness Score and College Prediction.
|
||||||
|
"""
|
||||||
|
readiness_data = calculate_readiness_score(user_id, session)
|
||||||
|
if not readiness_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Could not calculate readiness score.")
|
||||||
|
|
||||||
|
return readiness_data
|
||||||
|
|
||||||
|
@app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"])
|
||||||
|
def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
|
||||||
|
link = generate_referral_link(user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"referral_link": link,
|
||||||
|
"total_referrals": 0, # In production, query a referral tracking table
|
||||||
|
"rewards_earned_days": 0,
|
||||||
|
"share_message": f"I'm on a {user.streak_count}-day study streak on PragatiVeer! Use my link to get 7 days of the AI Coach for free: {link}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- USER CRUD & TEST LISTING ---
|
||||||
|
@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
||||||
|
def get_user_profile(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
target_exam: Optional[models.ExamEnum] = None
|
||||||
|
student_class: Optional[models.ClassEnum] = None
|
||||||
|
|
||||||
|
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
||||||
|
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)):
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if payload.name is not None:
|
||||||
|
user.name = payload.name
|
||||||
|
if payload.target_exam is not None:
|
||||||
|
user.target_exam = payload.target_exam
|
||||||
|
if payload.student_class is not None:
|
||||||
|
# Note: mapping alias 'class' in User class to 'student_class'
|
||||||
|
user.student_class = payload.student_class
|
||||||
|
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
||||||
|
def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)):
|
||||||
|
query = select(models.Test)
|
||||||
|
if exam_pattern is not None:
|
||||||
|
query = query.where(models.Test.exam_pattern == exam_pattern)
|
||||||
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
@app.get("/api/users/{user_id}/attempts", tags=["Users"])
|
||||||
|
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
|
||||||
|
return session.exec(query).all()
|
||||||
|
|
||||||
168
backend/models.py
Normal file
168
backend/models.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
from datetime import datetime, date
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from sqlmodel import Field, SQLModel, Relationship, JSON
|
||||||
|
|
||||||
|
# --- ENUMS ---
|
||||||
|
class ClassEnum(str, Enum):
|
||||||
|
ELEVENTH = "11"
|
||||||
|
TWELFTH = "12"
|
||||||
|
DROPPER = "Dropper"
|
||||||
|
|
||||||
|
class ExamEnum(str, Enum):
|
||||||
|
JEE_MAIN = "JEE_Main"
|
||||||
|
JEE_ADVANCED = "JEE_Advanced"
|
||||||
|
NEET = "NEET"
|
||||||
|
|
||||||
|
class MediumEnum(str, Enum):
|
||||||
|
ENGLISH = "English"
|
||||||
|
HINDI = "Hindi"
|
||||||
|
BILINGUAL = "Bilingual"
|
||||||
|
|
||||||
|
class TierEnum(str, Enum):
|
||||||
|
FREE = "Free"
|
||||||
|
PRO = "Pro"
|
||||||
|
AI_COACH = "Al_Coach"
|
||||||
|
|
||||||
|
class QuestionTypeEnum(str, Enum):
|
||||||
|
MCQ_SINGLE = "MCQ_Single"
|
||||||
|
MCQ_MULTIPLE = "MCQ_Multiple"
|
||||||
|
NUMERICAL = "Numerical"
|
||||||
|
|
||||||
|
class SourceTypeEnum(str, Enum):
|
||||||
|
DPS = "DPS"
|
||||||
|
TEST = "Test"
|
||||||
|
MICROTEST = "MicroTest"
|
||||||
|
|
||||||
|
class ErrorTypeEnum(str, Enum):
|
||||||
|
CONCEPTUAL = "Conceptual"
|
||||||
|
CALCULATION = "Calculation"
|
||||||
|
READING = "Reading"
|
||||||
|
TIME_PRESSURE = "Time_Pressure"
|
||||||
|
|
||||||
|
# --- CORE DB MODELS ---
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
email: Optional[str] = Field(default=None, unique=True)
|
||||||
|
phone: Optional[str] = Field(default=None, unique=True)
|
||||||
|
password_hash: str
|
||||||
|
name: str
|
||||||
|
student_class: ClassEnum = Field(alias="class")
|
||||||
|
target_exam: ExamEnum
|
||||||
|
medium: MediumEnum
|
||||||
|
school: Optional[str] = None
|
||||||
|
city: str
|
||||||
|
state: str
|
||||||
|
tier: TierEnum = Field(default=TierEnum.FREE)
|
||||||
|
trial_end: Optional[datetime] = None
|
||||||
|
subscription_end: Optional[datetime] = None
|
||||||
|
streak_count: int = Field(default=0)
|
||||||
|
longest_streak: int = Field(default=0)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
last_active: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
class Parent(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
email: str
|
||||||
|
phone: str
|
||||||
|
password_hash: str
|
||||||
|
name: str
|
||||||
|
language: str = Field(default="English") # e.g., Hindi, English
|
||||||
|
linked_students: List[str] = Field(default=[], sa_type=JSON)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
class Question(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
subject: str # Physics, Chemistry, Maths, Biology
|
||||||
|
topic: str
|
||||||
|
subtopic: str
|
||||||
|
difficulty: str # Easy, Medium, Hard
|
||||||
|
type: QuestionTypeEnum
|
||||||
|
question_text: str
|
||||||
|
question_text_hi: Optional[str] = None
|
||||||
|
options: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON)
|
||||||
|
options_hi: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON)
|
||||||
|
correct_answer: Dict[str, Any] = Field(sa_type=JSON)
|
||||||
|
solution_text: str
|
||||||
|
solution_text_hi: Optional[str] = None
|
||||||
|
language: MediumEnum
|
||||||
|
year: Optional[int] = None
|
||||||
|
exam: Optional[ExamEnum] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
created_by: UUID
|
||||||
|
verified_by: Optional[UUID] = None
|
||||||
|
|
||||||
|
class DPS(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
date: date
|
||||||
|
title: str
|
||||||
|
questions: List[str] = Field(default=[], sa_type=JSON) # Array of question UUID strings
|
||||||
|
difficulty: str
|
||||||
|
time_limit: int
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
created_by: UUID
|
||||||
|
|
||||||
|
class Test(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
title: str
|
||||||
|
type: str # Weekly, Mock, PYQ
|
||||||
|
exam_pattern: ExamEnum
|
||||||
|
sections: Dict[str, Any] = Field(sa_type=JSON) # Array of {name, questions[], time_limit}
|
||||||
|
total_time: int
|
||||||
|
difficulty: str
|
||||||
|
scheduled_date: Optional[datetime] = None
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
created_by: UUID
|
||||||
|
|
||||||
|
class Attempt(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
user_id: UUID = Field(foreign_key="user.id")
|
||||||
|
source_type: SourceTypeEnum
|
||||||
|
source_id: UUID
|
||||||
|
started_at: datetime
|
||||||
|
submitted_at: Optional[datetime] = None
|
||||||
|
time_taken: Optional[int] = None # in seconds
|
||||||
|
answers: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON) # Array of {question_id, selected_answer, is_correct, time_spent}
|
||||||
|
score: int = Field(default=0)
|
||||||
|
total_questions: int
|
||||||
|
accuracy: float = Field(default=0.0)
|
||||||
|
status: str = Field(default="In_Progress") # In_Progress, Submitted, Timed_Out
|
||||||
|
|
||||||
|
class Mistake(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
user_id: UUID = Field(foreign_key="user.id")
|
||||||
|
question_id: UUID = Field(foreign_key="question.id")
|
||||||
|
attempt_id: UUID = Field(foreign_key="attempt.id")
|
||||||
|
error_type: ErrorTypeEnum
|
||||||
|
topic: str
|
||||||
|
subtopic: str
|
||||||
|
next_review: datetime
|
||||||
|
review_count: int = Field(default=0)
|
||||||
|
consecutive_correct: int = Field(default=0)
|
||||||
|
is_mastered: bool = Field(default=False)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
class StudyPlan(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
user_id: UUID = Field(foreign_key="user.id")
|
||||||
|
date: date
|
||||||
|
tasks: Dict[str, Any] = Field(sa_type=JSON) # Array of {type, description, duration_minutes, status}
|
||||||
|
is_completed: bool = Field(default=False)
|
||||||
|
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
feedback: Optional[str] = None
|
||||||
|
|
||||||
|
class Subscription(SQLModel, table=True):
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
user_id: UUID = Field(foreign_key="user.id")
|
||||||
|
tier: TierEnum
|
||||||
|
billing_cycle: str # Monthly, Quarterly, Annual
|
||||||
|
start_date: datetime
|
||||||
|
end_date: datetime
|
||||||
|
amount: int # Stored in Paise (INR)
|
||||||
|
payment_id: str # Razorpay order ID / payment ID
|
||||||
|
status: str # Active, Expired, Cancelled, Paused
|
||||||
|
auto_renew: bool = Field(default=True)
|
||||||
|
coupon_code: Optional[str] = None
|
||||||
76
backend/predictor.py
Normal file
76
backend/predictor.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from sqlmodel import Session, select
|
||||||
|
from uuid import UUID
|
||||||
|
import models
|
||||||
|
|
||||||
|
def calculate_readiness_score(user_id: UUID, session: Session) -> dict:
|
||||||
|
"""
|
||||||
|
Calculates the Exam Readiness Score (0-100) based on PRD A-05 parameters.
|
||||||
|
Weights: Accuracy (40%), Speed (20%), Syllabus Coverage (20%),
|
||||||
|
Consistency (10%), Mistake Recovery (10%).
|
||||||
|
"""
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
attempts = session.exec(
|
||||||
|
select(models.Attempt).where(models.Attempt.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not attempts:
|
||||||
|
return {"score": 0, "status": "Not enough data"}
|
||||||
|
|
||||||
|
# 1. Accuracy (40% Weight)
|
||||||
|
avg_accuracy = sum(a.accuracy for a in attempts) / len(attempts)
|
||||||
|
w_accuracy = (avg_accuracy / 100) * 40
|
||||||
|
|
||||||
|
# 2. Speed (20% Weight) - Assuming benchmark is 120s per question
|
||||||
|
time_per_q = [ (a.time_taken / a.total_questions) for a in attempts if a.total_questions > 0 and a.time_taken ]
|
||||||
|
avg_time = sum(time_per_q) / len(time_per_q) if time_per_q else 120
|
||||||
|
speed_factor = min(1.0, 120 / max(avg_time, 1)) # Cap at 1.0
|
||||||
|
w_speed = speed_factor * 20
|
||||||
|
|
||||||
|
# 3. Consistency (10% Weight)
|
||||||
|
streak = min(user.streak_count, 30) # Max consistency score hit at 30 days
|
||||||
|
w_consistency = (streak / 30) * 10
|
||||||
|
|
||||||
|
# 4. Mistake Recovery (10% Weight)
|
||||||
|
mistakes = session.exec(select(models.Mistake).where(models.Mistake.user_id == user_id)).all()
|
||||||
|
mastered_count = sum(1 for m in mistakes if m.is_mastered)
|
||||||
|
total_mistakes = max(len(mistakes), 1)
|
||||||
|
w_recovery = (mastered_count / total_mistakes) * 10
|
||||||
|
|
||||||
|
# 5. Syllabus Coverage (20% Weight)
|
||||||
|
# For local sandbox, we will estimate based on total questions attempted
|
||||||
|
total_q_attempted = sum(a.total_questions for a in attempts)
|
||||||
|
coverage_factor = min(total_q_attempted / 500, 1.0) # Assume 500 questions = decent baseline coverage
|
||||||
|
w_coverage = coverage_factor * 20
|
||||||
|
|
||||||
|
# Total Calculation
|
||||||
|
total_score = round(w_accuracy + w_speed + w_consistency + w_recovery + w_coverage)
|
||||||
|
|
||||||
|
# Calculate College Prediction based on PRD A-07
|
||||||
|
prediction = "Keep Practicing"
|
||||||
|
target_type = "State Level"
|
||||||
|
|
||||||
|
if total_score >= 85:
|
||||||
|
prediction = "IIT Bombay / IIT Delhi within reach!"
|
||||||
|
target_type = "Reach"
|
||||||
|
elif total_score >= 70:
|
||||||
|
prediction = "NIT Trichy / NIT Surathkal on track."
|
||||||
|
target_type = "Target"
|
||||||
|
elif total_score >= 50:
|
||||||
|
prediction = "Top GFTIs / State Engineering Colleges."
|
||||||
|
target_type = "Safety"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": total_score,
|
||||||
|
"breakdown": {
|
||||||
|
"accuracy": round(w_accuracy, 1),
|
||||||
|
"speed": round(w_speed, 1),
|
||||||
|
"consistency": round(w_consistency, 1),
|
||||||
|
"recovery": round(w_recovery, 1),
|
||||||
|
"coverage": round(w_coverage, 1)
|
||||||
|
},
|
||||||
|
"college_prediction": prediction,
|
||||||
|
"prediction_category": target_type
|
||||||
|
}
|
||||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.110.0
|
||||||
|
uvicorn[standard]==0.28.0
|
||||||
|
sqlmodel==0.0.16
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
redis==5.0.3
|
||||||
|
pydantic==2.6.4
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
bcrypt==4.1.2
|
||||||
|
pyjwt==2.8.0
|
||||||
|
celery==5.3.6
|
||||||
|
openai==1.14.0
|
||||||
|
httpx==0.27.2
|
||||||
317
backend/seed.py
Normal file
317
backend/seed.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
from sqlmodel import Session, create_engine, select
|
||||||
|
from uuid import uuid4, UUID
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from database import engine, init_db
|
||||||
|
import models
|
||||||
|
|
||||||
|
def seed_local_database():
|
||||||
|
# Ensure tables are created
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Prevent double seeding in local testing environment
|
||||||
|
if session.exec(select(models.Question)).first() is not None:
|
||||||
|
print("Local environment database already contains data components. Dropping all and reseeding...")
|
||||||
|
import sqlmodel
|
||||||
|
sqlmodel.SQLModel.metadata.drop_all(engine)
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
print("Seeding baseline content entities...")
|
||||||
|
|
||||||
|
# 0. Create the baseline student user (seeded target: JEE_MAIN)
|
||||||
|
student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b")
|
||||||
|
student_user = models.User(
|
||||||
|
id=student_id,
|
||||||
|
phone="9999999999",
|
||||||
|
email="student@pragativeer.com",
|
||||||
|
password_hash="mock_hash",
|
||||||
|
name="Rahul Sharma",
|
||||||
|
student_class=models.ClassEnum.TWELFTH,
|
||||||
|
medium=models.MediumEnum.ENGLISH,
|
||||||
|
target_exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
city="New Delhi",
|
||||||
|
state="Delhi",
|
||||||
|
tier=models.TierEnum.AI_COACH,
|
||||||
|
streak_count=5,
|
||||||
|
last_active=datetime.utcnow()
|
||||||
|
)
|
||||||
|
session.add(student_user)
|
||||||
|
|
||||||
|
admin_id = uuid4()
|
||||||
|
|
||||||
|
jee_test_id = UUID("b6faa295-3bf5-4de2-a587-d72b889629c4")
|
||||||
|
neet_test_id = UUID("e7c80521-825b-4c07-889a-ee646c05d762")
|
||||||
|
|
||||||
|
def generate_dummy_questions(subject: str, count: int, exam: models.ExamEnum, admin_id: UUID, start_idx: int = 1):
|
||||||
|
qs = []
|
||||||
|
for i in range(start_idx, count + start_idx):
|
||||||
|
q = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject=subject,
|
||||||
|
topic=f"Generic Topic {i}",
|
||||||
|
subtopic=f"Subtopic {i}",
|
||||||
|
difficulty="Medium",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text=f"This is a simulated {exam.value} {subject} question #{i} for mock exam scaling.",
|
||||||
|
question_text_hi=f"यह एक अनुरूपित {exam.value} {subject} प्रश्न #{i} है।",
|
||||||
|
options={"A": "Option A", "B": "Option B", "C": "Option C", "D": "Option D"},
|
||||||
|
options_hi={"A": "विकल्प A", "B": "विकल्प B", "C": "विकल्प C", "D": "विकल्प D"},
|
||||||
|
correct_answer={"correct": "A"},
|
||||||
|
solution_text="Simulated solution for the mock test.",
|
||||||
|
solution_text_hi="मॉक टेस्ट के लिए अनुरूपित समाधान।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2024,
|
||||||
|
exam=exam,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
qs.append(q)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
# --- JEE QUESTIONS ---
|
||||||
|
|
||||||
|
# Physics 1
|
||||||
|
jq1 = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject="Physics",
|
||||||
|
topic="Electrostatics",
|
||||||
|
subtopic="Coulomb's Law",
|
||||||
|
difficulty="Medium",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text="Two point charges placed at a distance $r$ in air exert a force $F$ on each other. At what distance will they exert the same force if placed in a medium of dielectric constant $K=4$?",
|
||||||
|
question_text_hi="वायु में $r$ दूरी पर स्थित दो बिंदु आवेश एक दूसरे पर $F$ बल लगाते हैं। यदि उन्हें $K=4$ परावैद्युतांक वाले माध्यम में रखा जाए, तो वे किस दूरी पर समान बल लगाएंगे?",
|
||||||
|
options={"A": "$r$", "B": "$r/2$", "C": "$2r$", "D": "$r/4$"},
|
||||||
|
options_hi={"A": "$r$", "B": "$r/2$", "C": "$2r$", "D": "$r/4$"},
|
||||||
|
correct_answer={"correct": "B"},
|
||||||
|
solution_text="Force in medium $F' = \\frac{F}{K}$. To maintain same force, $r' = \\frac{r}{\\sqrt{K}} = \\frac{r}{\\sqrt{4}} = \\frac{r}{2}$.",
|
||||||
|
solution_text_hi="माध्यम में बल $F' = \\frac{F}{K}$। समान बल बनाए रखने के लिए, $r' = \\frac{r}{\\sqrt{K}} = \\frac{r}{\\sqrt{4}} = \\frac{r}{2}$।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2024,
|
||||||
|
exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Physics 2
|
||||||
|
jq2 = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject="Physics",
|
||||||
|
topic="Mechanics",
|
||||||
|
subtopic="Rotational Motion",
|
||||||
|
difficulty="Hard",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text="A solid cylinder of mass $M$ and radius $R$ is rolling without slipping down an inclined plane of angle $\\theta$. What is the minimum coefficient of static friction $\\mu_s$ required to prevent slipping?",
|
||||||
|
question_text_hi="द्रव्यमान $M$ और त्रिज्या $R$ का एक ठोस बेलन कोण $\\theta$ के एक झुके हुए तल पर बिना फिसले लुढ़क रहा है। फिसलने से रोकने के लिए आवश्यक न्यूनतम स्थैतिक घर्षण गुणांक $\\mu_s$ क्या है?",
|
||||||
|
options={"A": "$\\frac{1}{3} \\tan \\theta$", "B": "$\\frac{2}{3} \\tan \\theta$", "C": "$\\frac{1}{2} \\tan \\theta$", "D": "$\\tan \\theta$"},
|
||||||
|
options_hi={"A": "$\\frac{1}{3} \\tan \\theta$", "B": "$\\frac{2}{3} \\tan \\theta$", "C": "$\\frac{1}{2} \\tan \\theta$", "D": "$\\tan \\theta$"},
|
||||||
|
correct_answer={"correct": "A"},
|
||||||
|
solution_text="$a = \\frac{g \\sin \\theta}{1 + \\frac{k^2}{R^2}}$. For solid cylinder $k^2 = \\frac{R^2}{2}$. So $a = \\frac{2}{3} g \\sin \\theta$. Friction $f = Mg \\sin \\theta - Ma = \\frac{1}{3} Mg \\sin \\theta$. Since $f \\le \\mu_s Mg \\cos \\theta$, $\\mu_s \\ge \\frac{1}{3} \\tan \\theta$.",
|
||||||
|
solution_text_hi="$a = \\frac{g \\sin \\theta}{1 + \\frac{k^2}{R^2}}$। ठोस बेलन के लिए $k^2 = \\frac{R^2}{2}$। इसलिए $a = \\frac{2}{3} g \\sin \\theta$। घर्षण $f = Mg \\sin \\theta - Ma = \\frac{1}{3} Mg \\sin \\theta$। चूंकि $f \\le \\mu_s Mg \\cos \\theta$, $\\mu_s \\ge \\frac{1}{3} \\tan \\theta$ है।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2023,
|
||||||
|
exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chemistry 1
|
||||||
|
jq3 = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject="Chemistry",
|
||||||
|
topic="Physical Chemistry",
|
||||||
|
subtopic="Chemical Kinetics",
|
||||||
|
difficulty="Medium",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text="For a first-order reaction, the time required for $99\\%$ completion is $x$ times the time required for $90\\%$ completion. The value of $x$ is:",
|
||||||
|
question_text_hi="एक प्रथम-कोटि की अभिक्रिया के लिए, $99\\%$ पूर्ण होने में लगा समय $90\\%$ पूर्ण होने में लगे समय का $x$ गुना है। $x$ का मान है:",
|
||||||
|
options={"A": "$2$", "B": "$10$", "C": "$3$", "D": "$4$"},
|
||||||
|
options_hi={"A": "$2$", "B": "$10$", "C": "$3$", "D": "$4$"},
|
||||||
|
correct_answer={"correct": "A"},
|
||||||
|
solution_text="$t = \\frac{2.303}{k} \\log \\frac{100}{100-x}$. $t_{99} = \\frac{2.303}{k} \\log 100 = 2 \\times \\frac{2.303}{k}$. $t_{90} = \\frac{2.303}{k} \\log 10 = 1 \\times \\frac{2.303}{k}$. Therefore $x = 2$.",
|
||||||
|
solution_text_hi="$t = \\frac{2.303}{k} \\log \\frac{100}{100-x}$। $t_{99} = \\frac{2.303}{k} \\log 100 = 2 \\times \\frac{2.303}{k}$। $t_{90} = \\frac{2.303}{k} \\log 10 = 1 \\times \\frac{2.303}{k}$। इसलिए $x = 2$ है।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2024,
|
||||||
|
exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Maths 1
|
||||||
|
jq4 = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject="Mathematics",
|
||||||
|
topic="Calculus",
|
||||||
|
subtopic="Definite Integration",
|
||||||
|
difficulty="Hard",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text="The value of the integral $\\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$ is:",
|
||||||
|
question_text_hi="समाकल $\\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$ का मान है:",
|
||||||
|
options={"A": "$\\pi/2$", "B": "$\\pi/4$", "C": "$0$", "D": "$\\pi/8$"},
|
||||||
|
options_hi={"A": "$\\pi/2$", "B": "$\\pi/4$", "C": "$0$", "D": "$\\pi/8$"},
|
||||||
|
correct_answer={"correct": "B"},
|
||||||
|
solution_text="Let $I = \\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$. Using $\\int_0^a f(x)dx = \\int_0^a f(a-x)dx$, we get $I = \\int_{0}^{\\pi/2} \\frac{\\cos^3 x}{\\cos^3 x + \\sin^3 x} dx$. Adding both, $2I = \\int_{0}^{\\pi/2} 1 dx = \\pi/2$. Thus $I = \\pi/4$.",
|
||||||
|
solution_text_hi="माना $I = \\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$। गुणधर्म $\\int_0^a f(x)dx = \\int_0^a f(a-x)dx$ का उपयोग करने पर, $I = \\int_{0}^{\\pi/2} \\frac{\\cos^3 x}{\\cos^3 x + \\sin^3 x} dx$। दोनों को जोड़ने पर, $2I = \\int_{0}^{\\pi/2} 1 dx = \\pi/2$। इसलिए $I = \\pi/4$ है।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2023,
|
||||||
|
exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
|
||||||
|
jee_physics_dummies = generate_dummy_questions("Physics", 28, models.ExamEnum.JEE_MAIN, admin_id, start_idx=3)
|
||||||
|
jee_chem_dummies = generate_dummy_questions("Chemistry", 29, models.ExamEnum.JEE_MAIN, admin_id, start_idx=2)
|
||||||
|
jee_math_dummies = generate_dummy_questions("Mathematics", 29, models.ExamEnum.JEE_MAIN, admin_id, start_idx=2)
|
||||||
|
|
||||||
|
session.add_all([jq1, jq2, jq3, jq4])
|
||||||
|
session.add_all(jee_physics_dummies)
|
||||||
|
session.add_all(jee_chem_dummies)
|
||||||
|
session.add_all(jee_math_dummies)
|
||||||
|
|
||||||
|
# --- NEET QUESTIONS ---
|
||||||
|
|
||||||
|
# Biology 1
|
||||||
|
nq1 = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject="Botany",
|
||||||
|
topic="Genetics",
|
||||||
|
subtopic="Translation",
|
||||||
|
difficulty="Easy",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text="Which of the following codons codes for Methionine and also acts as an initiator codon?",
|
||||||
|
question_text_hi="निम्नलिखित में से कौन सा कोडॉन मेथियोनिन के लिए कोड करता है और एक प्रारंभक कोडॉन के रूप में भी कार्य करता है?",
|
||||||
|
options={"A": "AUG", "B": "GUG", "C": "UAA", "D": "UAG"},
|
||||||
|
options_hi={"A": "AUG", "B": "GUG", "C": "UAA", "D": "UAG"},
|
||||||
|
correct_answer={"correct": "A"},
|
||||||
|
solution_text="AUG is the starting codon (initiator codon) in eukaryotes and prokaryotes and also codes for the amino acid methionine.",
|
||||||
|
solution_text_hi="AUG यूकेरियोट्स और प्रोकैरियोट्स में प्रारंभिक कोडॉन (प्रारंभक कोडॉन) है और अमीनो एसिड मेथियोनिन के लिए भी कोड करता है।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2024,
|
||||||
|
exam=models.ExamEnum.NEET,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Physics 1 (NEET)
|
||||||
|
nq2 = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject="Physics",
|
||||||
|
topic="Kinematics",
|
||||||
|
subtopic="Motion under Gravity",
|
||||||
|
difficulty="Medium",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text="A ball is thrown vertically upward with a velocity of $20\\text{ m/s}$ from the top of a tower. If the height of the tower is $25\\text{ m}$, the time taken by the ball to reach the ground is (take $g = 10\\text{ m/s}^2$):",
|
||||||
|
question_text_hi="एक मीनार के शीर्ष से एक गेंद को $20\\text{ मी/से}$ के वेग से लंबवत ऊपर की ओर फेंका जाता है। यदि मीनार की ऊंचाई $25\\text{ मीटर}$ है, तो गेंद को जमीन पर पहुंचने में लगा समय है ($g = 10\\text{ मी/से}^2$ लें):",
|
||||||
|
options={"A": "$5\\text{ s}$", "B": "$3\\text{ s}$", "C": "$4\\text{ s}$", "D": "$2\\text{ s}$"},
|
||||||
|
options_hi={"A": "$5\\text{ सेकंड}$", "B": "$3\\text{ सेकंड}$", "C": "$4\\text{ सेकंड}$", "D": "$2\\text{ सेकंड}$"},
|
||||||
|
correct_answer={"correct": "A"},
|
||||||
|
solution_text="Using $s = ut + \\frac{1}{2}at^2$. Taking upward direction positive: $-25 = 20t - \\frac{1}{2}(10)t^2 \\implies 5t^2 - 20t - 25 = 0 \\implies t^2 - 4t - 5 = 0 \\implies (t-5)(t+1) = 0$. Since time cannot be negative, $t = 5\\text{ s}$.",
|
||||||
|
solution_text_hi="$s = ut + \\frac{1}{2}at^2$ का उपयोग करने पर। ऊपर की दिशा को धनात्मक मानने पर: $-25 = 20t - \\frac{1}{2}(10)t^2 \\implies 5t^2 - 20t - 25 = 0 \\implies t^2 - 4t - 5 = 0 \\implies (t-5)(t+1) = 0$। चूंकि समय ऋणात्मक नहीं हो सकता, $t = 5$ सेकंड है।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2023,
|
||||||
|
exam=models.ExamEnum.NEET,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chemistry 1 (NEET)
|
||||||
|
nq3 = models.Question(
|
||||||
|
id=uuid4(),
|
||||||
|
subject="Chemistry",
|
||||||
|
topic="Periodic Table",
|
||||||
|
subtopic="Ionic Radii",
|
||||||
|
difficulty="Medium",
|
||||||
|
type=models.QuestionTypeEnum.MCQ_SINGLE,
|
||||||
|
question_text="Which of the following represents the correct order of increasing ionic radii for isoelectronic species?",
|
||||||
|
question_text_hi="समइलेक्ट्रॉनिक प्रजातियों के लिए बढ़ती हुई आयनिक त्रिज्या का सही क्रम निम्नलिखित में से कौन सा दर्शाता है?",
|
||||||
|
options={"A": "$\\text{O}^{2-} < \\text{F}^- < \\text{Na}^+$", "B": "$\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$", "C": "$\\text{F}^- < \\text{Na}^+ < \\text{O}^{2-}$", "D": "$\\text{O}^{2-} < \\text{Na}^+ < \\text{F}^-$"},
|
||||||
|
options_hi={"A": "$\\text{O}^{2-} < \\text{F}^- < \\text{Na}^+$", "B": "$\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$", "C": "$\\text{F}^- < \\text{Na}^+ < \\text{O}^{2-}$", "D": "$\\text{O}^{2-} < \\text{Na}^+ < \\text{F}^-$"},
|
||||||
|
correct_answer={"correct": "B"},
|
||||||
|
solution_text="For isoelectronic species, ionic radii decrease with increase in atomic number (or nuclear charge). Nuclear charge: $\\text{Na}^+ (11) > \\text{F}^- (9) > \\text{O}^{2-} (8)$. Therefore, the correct order is $\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$.",
|
||||||
|
solution_text_hi="समइलेक्ट्रॉनिक प्रजातियों के लिए, परमाणु संख्या (या नाभिकीय आवेश) में वृद्धि के साथ आयनिक त्रिज्या कम होती जाती है। नाभिकीय आवेश: $\\text{Na}^+ (11) > \\text{F}^- (9) > \\text{O}^{2-} (8)$। इसलिए, सही क्रम $\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$ है।",
|
||||||
|
language=models.MediumEnum.BILINGUAL,
|
||||||
|
year=2024,
|
||||||
|
exam=models.ExamEnum.NEET,
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
|
||||||
|
neet_physics_dummies = generate_dummy_questions("Physics", 49, models.ExamEnum.NEET, admin_id, start_idx=2)
|
||||||
|
neet_chem_dummies = generate_dummy_questions("Chemistry", 49, models.ExamEnum.NEET, admin_id, start_idx=2)
|
||||||
|
neet_botany_dummies = generate_dummy_questions("Botany", 49, models.ExamEnum.NEET, admin_id, start_idx=2)
|
||||||
|
neet_zoology_dummies = generate_dummy_questions("Zoology", 50, models.ExamEnum.NEET, admin_id, start_idx=1)
|
||||||
|
|
||||||
|
session.add_all([nq1, nq2, nq3])
|
||||||
|
session.add_all(neet_physics_dummies)
|
||||||
|
session.add_all(neet_chem_dummies)
|
||||||
|
session.add_all(neet_botany_dummies)
|
||||||
|
session.add_all(neet_zoology_dummies)
|
||||||
|
|
||||||
|
# --- TESTS CREATION ---
|
||||||
|
|
||||||
|
# 1. JEE Mock Test
|
||||||
|
jee_test = models.Test(
|
||||||
|
id=jee_test_id,
|
||||||
|
title="JEE Main Full Mock Test #1 (2025)",
|
||||||
|
type="Mock",
|
||||||
|
exam_pattern=models.ExamEnum.JEE_MAIN,
|
||||||
|
sections={
|
||||||
|
"Physics": {
|
||||||
|
"name": "Physics",
|
||||||
|
"questions": [str(jq1.id), str(jq2.id)] + [str(q.id) for q in jee_physics_dummies],
|
||||||
|
"time_limit": 60
|
||||||
|
},
|
||||||
|
"Chemistry": {
|
||||||
|
"name": "Chemistry",
|
||||||
|
"questions": [str(jq3.id)] + [str(q.id) for q in jee_chem_dummies],
|
||||||
|
"time_limit": 60
|
||||||
|
},
|
||||||
|
"Mathematics": {
|
||||||
|
"name": "Mathematics",
|
||||||
|
"questions": [str(jq4.id)] + [str(q.id) for q in jee_math_dummies],
|
||||||
|
"time_limit": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total_time=180,
|
||||||
|
difficulty="Mixed",
|
||||||
|
scheduled_date=datetime.utcnow(),
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
session.add(jee_test)
|
||||||
|
|
||||||
|
# 2. NEET Mock Test
|
||||||
|
neet_test = models.Test(
|
||||||
|
id=neet_test_id,
|
||||||
|
title="NEET UG Full Mock Test #1 (2025)",
|
||||||
|
type="Mock",
|
||||||
|
exam_pattern=models.ExamEnum.NEET,
|
||||||
|
sections={
|
||||||
|
"Physics": {
|
||||||
|
"name": "Physics",
|
||||||
|
"questions": [str(nq2.id)] + [str(q.id) for q in neet_physics_dummies],
|
||||||
|
"time_limit": 45
|
||||||
|
},
|
||||||
|
"Chemistry": {
|
||||||
|
"name": "Chemistry",
|
||||||
|
"questions": [str(nq3.id)] + [str(q.id) for q in neet_chem_dummies],
|
||||||
|
"time_limit": 45
|
||||||
|
},
|
||||||
|
"Botany": {
|
||||||
|
"name": "Botany",
|
||||||
|
"questions": [str(nq1.id)] + [str(q.id) for q in neet_botany_dummies],
|
||||||
|
"time_limit": 45
|
||||||
|
},
|
||||||
|
"Zoology": {
|
||||||
|
"name": "Zoology",
|
||||||
|
"questions": [str(q.id) for q in neet_zoology_dummies],
|
||||||
|
"time_limit": 45
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total_time=180,
|
||||||
|
difficulty="Mixed",
|
||||||
|
scheduled_date=datetime.utcnow(),
|
||||||
|
created_by=admin_id
|
||||||
|
)
|
||||||
|
session.add(neet_test)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
print("\n=== LOCAL SEED COMPLETED SUCCESSFULLY ===")
|
||||||
|
print(f"JEE Test ID: {jee_test.id}")
|
||||||
|
print(f"NEET Test ID: {neet_test.id}\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_local_database()
|
||||||
84
backend/worker.py
Normal file
84
backend/worker.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
import json
|
||||||
|
|
||||||
|
from database import engine
|
||||||
|
import models
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||||
|
|
||||||
|
# Initialize Celery connected to our local Redis broker
|
||||||
|
celery_app = Celery(
|
||||||
|
"pragativeer_tasks",
|
||||||
|
broker=REDIS_URL,
|
||||||
|
backend=REDIS_URL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure timezone to IST for accurate 5:00 AM scheduling
|
||||||
|
celery_app.conf.update(
|
||||||
|
timezone='Asia/Kolkata',
|
||||||
|
enable_utc=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@celery_app.task(name="generate_daily_study_plans", bind=True, default_retry_delay=60, max_retries=3)
|
||||||
|
def generate_daily_study_plans(self):
|
||||||
|
"""
|
||||||
|
Runs daily to evaluate students' weak areas and generate
|
||||||
|
a personalized AI study plan for the day.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Fetch all active users on the AI Coach tier
|
||||||
|
ai_students = session.exec(
|
||||||
|
select(models.User).where(models.User.tier == models.TierEnum.AI_COACH)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
plans_created = 0
|
||||||
|
|
||||||
|
for student in ai_students:
|
||||||
|
# 1. Fetch pending spaced repetition mistakes for today
|
||||||
|
pending_mistakes = session.exec(
|
||||||
|
select(models.Mistake)
|
||||||
|
.where(models.Mistake.user_id == student.id)
|
||||||
|
.where(models.Mistake.is_mastered == False)
|
||||||
|
.where(models.Mistake.next_review <= datetime.utcnow())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 2. Extract weak topics (for revision recommendation)
|
||||||
|
topics = [m.topic for m in pending_mistakes]
|
||||||
|
most_weak_topic = max(set(topics), key=topics.count) if topics else "Mixed Syllabus"
|
||||||
|
|
||||||
|
# 3. Construct the time-budgeted dynamic task list
|
||||||
|
tasks = [
|
||||||
|
{"type": "Revision", "description": f"Revise {most_weak_topic}", "duration_minutes": 15, "status": "Pending"},
|
||||||
|
{"type": "Mistake_Review", "description": "Daily Mistake Review Session", "duration_minutes": 10, "status": "Pending"},
|
||||||
|
{"type": "DPS", "description": f"Attempt {most_weak_topic} Practice Sheet", "duration_minutes": 20, "status": "Pending"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4. Save to Database
|
||||||
|
new_plan = models.StudyPlan(
|
||||||
|
user_id=student.id,
|
||||||
|
date=today,
|
||||||
|
tasks=tasks,
|
||||||
|
is_completed=False
|
||||||
|
)
|
||||||
|
session.add(new_plan)
|
||||||
|
plans_created += 1
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return f"Successfully generated {plans_created} daily study plans."
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Database error during study plan generation: {exc}")
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
|
||||||
|
# Schedule the tasks using Celery Beat
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
'generate-study-plans-every-morning': {
|
||||||
|
'task': 'generate_daily_study_plans',
|
||||||
|
'schedule': crontab(hour=5, minute=0), # 5:00 AM IST
|
||||||
|
},
|
||||||
|
}
|
||||||
116
docker-compose.yml
Normal file
116
docker-compose.yml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: pragativeer_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: pragativeer_admin
|
||||||
|
POSTGRES_PASSWORD: local_secure_password_2026
|
||||||
|
POSTGRES_DB: pragativeer_prod
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- pragativeer_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pragativeer_admin -d pragativeer_prod"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: pragativeer_cache
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
networks:
|
||||||
|
- pragativeer_network
|
||||||
|
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: pragativeer_backend
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- ENV=development
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- pragativeer_network
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: pragativeer_worker
|
||||||
|
command: celery -A worker.celery_app worker --loglevel=info
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- pragativeer_network
|
||||||
|
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: pragativeer_beat
|
||||||
|
command: celery -A worker.celery_app beat --loglevel=info
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- pragativeer_network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: pragativeer_frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- pragativeer_network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pragativeer_network:
|
||||||
|
driver: bridge
|
||||||
1592
package-lock.json
generated
1592
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -11,13 +11,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.41.0",
|
"framer-motion": "^12.41.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
|
"idb-keyval": "^6.2.6",
|
||||||
|
"katex": "^0.17.0",
|
||||||
"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-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-katex": "^3.1.0",
|
"react-katex": "^3.1.0",
|
||||||
"recharts": "^3.9.0",
|
"react-markdown": "^10.1.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"recharts": "^3.9.2",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
BIN
pragativeer-prd (4).pdf
Normal file
BIN
pragativeer-prd (4).pdf
Normal file
Binary file not shown.
@@ -4,11 +4,11 @@ import { Search, Download, MoreVertical } from "lucide-react"
|
|||||||
|
|
||||||
export default function StudentsPage() {
|
export default function StudentsPage() {
|
||||||
const students = [
|
const students = [
|
||||||
{ id: "STU-001", name: "Rahul Sharma", email: "rahul@example.com", tier: "AI Pro", target: "NEET", joined: "Oct 12, 2025", tests: 42 },
|
{ id: "STU-001", name: "Rahul Sharma", email: "rahul@example.com", tier: "AI Pro", target: "NEET", joined: "Oct 12, 2025", tests: 42, streak: 45, lastLogin: "Today, 08:30 AM" },
|
||||||
{ id: "STU-002", name: "Priya Patel", email: "priya@example.com", tier: "Basic", target: "JEE Main", joined: "Nov 05, 2025", tests: 14 },
|
{ id: "STU-002", name: "Priya Patel", email: "priya@example.com", tier: "Basic", target: "JEE Main", joined: "Nov 05, 2025", tests: 14, streak: 2, lastLogin: "Yesterday, 14:15 PM" },
|
||||||
{ id: "STU-003", name: "Amit Kumar", email: "amit@example.com", tier: "AI Pro", target: "JEE Adv", joined: "Dec 01, 2025", tests: 89 },
|
{ id: "STU-003", name: "Amit Kumar", email: "amit@example.com", tier: "AI Pro", target: "JEE Adv", joined: "Dec 01, 2025", tests: 89, streak: 0, lastLogin: "3 Days Ago" },
|
||||||
{ id: "STU-004", name: "Sneha Reddy", email: "sneha@example.com", tier: "AI Pro", target: "NEET", joined: "Dec 15, 2025", tests: 34 },
|
{ id: "STU-004", name: "Sneha Reddy", email: "sneha@example.com", tier: "AI Pro", target: "NEET", joined: "Dec 15, 2025", tests: 34, streak: 12, lastLogin: "Today, 11:45 AM" },
|
||||||
{ id: "STU-005", name: "Vikram Singh", email: "vikram@example.com", tier: "Basic", target: "NEET", joined: "Jan 10, 2026", tests: 5 },
|
{ id: "STU-005", name: "Vikram Singh", email: "vikram@example.com", tier: "Basic", target: "NEET", joined: "Jan 10, 2026", tests: 5, streak: 0, lastLogin: "1 Week Ago" },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,8 +41,9 @@ export default function StudentsPage() {
|
|||||||
<th className="px-6 py-4 font-semibold">Plan Tier</th>
|
<th className="px-6 py-4 font-semibold">Plan Tier</th>
|
||||||
<th className="px-6 py-4 font-semibold">Target Exam</th>
|
<th className="px-6 py-4 font-semibold">Target Exam</th>
|
||||||
<th className="px-6 py-4 font-semibold">Tests Taken</th>
|
<th className="px-6 py-4 font-semibold">Tests Taken</th>
|
||||||
<th className="px-6 py-4 font-semibold">Joined Date</th>
|
<th className="px-6 py-4 font-semibold text-center">Streak</th>
|
||||||
<th className="px-6 py-4 font-semibold text-right"></th>
|
<th className="px-6 py-4 font-semibold">Last Login</th>
|
||||||
|
<th className="px-6 py-4 font-semibold text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -61,8 +62,16 @@ export default function StudentsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 font-medium">{s.target}</td>
|
<td className="px-6 py-4 font-medium">{s.target}</td>
|
||||||
<td className="px-6 py-4 font-mono">{s.tests}</td>
|
<td className="px-6 py-4 font-mono">{s.tests}</td>
|
||||||
<td className="px-6 py-4 text-muted-foreground">{s.joined}</td>
|
<td className="px-6 py-4 text-center">
|
||||||
<td className="px-6 py-4 text-right">
|
<span className={`font-bold ${s.streak > 0 ? 'text-orange-500' : 'text-muted-foreground'}`}>{s.streak}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-muted-foreground">{s.lastLogin}</td>
|
||||||
|
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
|
||||||
|
{s.streak === 0 && (
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-[10px] border-red-500/50 text-red-500 hover:bg-red-500/10" onClick={() => window.alert(`Alert sent to ${s.name} regarding broken streak.`)}>
|
||||||
|
Send Alert
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<button className="p-1.5 hover:text-foreground hover:bg-secondary rounded"><MoreVertical size={16} /></button>
|
<button className="p-1.5 hover:text-foreground hover:bg-secondary rounded"><MoreVertical size={16} /></button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
67
src/app/api/og/progress/route.tsx
Normal file
67
src/app/api/og/progress/route.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
|
||||||
|
// Extract dynamic data from the URL query parameters
|
||||||
|
const streak = searchParams.get('streak') || '0';
|
||||||
|
const accuracy = searchParams.get('accuracy') || '0';
|
||||||
|
const name = searchParams.get('name') || 'Student';
|
||||||
|
|
||||||
|
// Renders a high-end, minimalist graphic using pure Tailwind and React
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#0a0a0a', // Deep premium dark mode
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
padding: '40px',
|
||||||
|
border: '8px solid #333',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', fontSize: 60, fontWeight: 800, marginBottom: 20, letterSpacing: '-0.05em' }}>
|
||||||
|
PragatiVeer
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 32, color: '#a3a3a3', marginBottom: 60 }}>
|
||||||
|
{name}'s JEE Preparation Progress
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-around' }}>
|
||||||
|
{/* Streak Metric */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 80, fontWeight: 900, color: '#f59e0b' }}>{streak}🔥</span>
|
||||||
|
<span style={{ fontSize: 24, color: '#d4d4d4', marginTop: 10 }}>Day Streak</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accuracy Metric */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 80, fontWeight: 900, color: '#10b981' }}>{accuracy}%</span>
|
||||||
|
<span style={{ fontSize: 24, color: '#d4d4d4', marginTop: 10 }}>Top Accuracy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', position: 'absolute', bottom: 40, fontSize: 20, color: '#525252' }}>
|
||||||
|
Join me on pragativeer.com/invite
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response(`Failed to generate image`, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,139 +1,571 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, use, useRef } from "react"
|
||||||
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 { Clock, Flag, ChevronLeft, ChevronRight, Pause, MoreVertical } from "lucide-react"
|
import { Clock, Flag, ChevronLeft, ChevronRight, Pause as PauseIcon, MoreVertical, Calculator as CalculatorIcon, FlaskConical, Edit3, XCircle, Play, Shield, AlertTriangle } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { useCBTStore, QuestionStatus } from "@/store/cbtStore"
|
||||||
|
import { useExamTimer } from "@/hooks/useExamTimer"
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
import remarkMath from "remark-math"
|
||||||
|
import rehypeKatex from "rehype-katex"
|
||||||
|
import "katex/dist/katex.min.css"
|
||||||
|
import { Calculator } from "@/components/CBT/Calculator"
|
||||||
|
import { Scratchpad } from "@/components/CBT/Scratchpad"
|
||||||
|
import { PeriodicTable } from "@/components/CBT/PeriodicTable"
|
||||||
|
import { ExamResult } from "@/components/CBT/ExamResult"
|
||||||
|
|
||||||
|
// Dummy data for testing the CBT client engine
|
||||||
|
const DUMMY_QUESTIONS = Array.from({ length: 20 }).map((_, i) => ({
|
||||||
|
id: `q_${i+1}`,
|
||||||
|
number: i + 1,
|
||||||
|
subject: "Physics",
|
||||||
|
text_en: `A solid cylinder of mass M and radius R is rolling without slipping down an inclined plane of angle θ. Question ${i+1}. What is the minimum coefficient of static friction μ_s required to prevent slipping?`,
|
||||||
|
text_hi: `द्रव्यमान M और त्रिज्या R का एक ठोस बेलन कोण θ के एक आनत तल पर बिना फिसले लुढ़क रहा है। प्रश्न ${i+1}...`,
|
||||||
|
options: ['(1/3) tan θ', '(2/3) tan θ', '(1/2) tan θ', 'tan θ']
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function CBTExamPage({ params }: { params: Promise<{ examId: string }> }) {
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const examId = resolvedParams.examId;
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [showCalc, setShowCalc] = useState(false);
|
||||||
|
const [showScratchpad, setShowScratchpad] = useState(false);
|
||||||
|
const [showPeriodicTable, setShowPeriodicTable] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Anti-cheat state
|
||||||
|
const [testStarted, setTestStarted] = useState(false);
|
||||||
|
const [violationCount, setViolationCount] = useState(0);
|
||||||
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
|
const hasShownWarning = useRef(false);
|
||||||
|
const hasAutoSubmitted = useRef(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
initExam,
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
currentQuestionIndex,
|
||||||
|
selectAnswer,
|
||||||
|
clearResponse,
|
||||||
|
markForReview,
|
||||||
|
goToQuestion,
|
||||||
|
addTimeSpent,
|
||||||
|
submitExam,
|
||||||
|
getAttemptPayload,
|
||||||
|
submitAttemptToServer,
|
||||||
|
resetExamState,
|
||||||
|
status
|
||||||
|
} = useCBTStore();
|
||||||
|
|
||||||
|
const { formattedTime, isTimedOut } = useExamTimer();
|
||||||
|
const [questionStartTime, setQuestionStartTime] = useState<number>(Date.now());
|
||||||
|
|
||||||
|
// Initialize Exam on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchExamData() {
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
const testRes = await fetch(`${apiUrl}/api/tests/${examId}?user_id=b0e17378-864d-439e-8806-708dc48a891b`);
|
||||||
|
if (!testRes.ok) throw new Error("Failed to fetch test structure");
|
||||||
|
const testData = await testRes.json();
|
||||||
|
|
||||||
|
// Extract all question IDs from sections
|
||||||
|
const questionIds: string[] = [];
|
||||||
|
Object.values(testData.sections).forEach((section: any) => {
|
||||||
|
questionIds.push(...section.questions);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch full question objects
|
||||||
|
const fetchedQuestions = await Promise.all(
|
||||||
|
questionIds.map(async (id, idx) => {
|
||||||
|
const qRes = await fetch(`${apiUrl}/api/questions/${id}`);
|
||||||
|
const qData = await qRes.json();
|
||||||
|
return {
|
||||||
|
id: qData.id,
|
||||||
|
number: idx + 1,
|
||||||
|
subject: qData.subject,
|
||||||
|
text_en: qData.question_text,
|
||||||
|
text_hi: qData.question_text_hi || qData.question_text,
|
||||||
|
options: Object.values(qData.options || {}),
|
||||||
|
options_hi: Object.values(qData.options_hi || qData.options || {}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
initExam(examId, fetchedQuestions, testData.total_time * 60);
|
||||||
|
setMounted(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load exam from network, falling back to dummy data:", err);
|
||||||
|
initExam(examId, DUMMY_QUESTIONS, 10800);
|
||||||
|
setMounted(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchExamData();
|
||||||
|
}, [examId, initExam]);
|
||||||
|
|
||||||
|
// Track time spent per question
|
||||||
|
useEffect(() => {
|
||||||
|
if (testStarted) {
|
||||||
|
setQuestionStartTime(Date.now());
|
||||||
|
}
|
||||||
|
}, [currentQuestionIndex, testStarted]);
|
||||||
|
|
||||||
|
// Anti-Cheat: Tab switching listener
|
||||||
|
useEffect(() => {
|
||||||
|
if (!testStarted || status === 'SUBMITTED' || status === 'TIMED_OUT') return;
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
setViolationCount((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
}, [testStarted, status]);
|
||||||
|
|
||||||
|
// Handle side-effects of violation count changing
|
||||||
|
useEffect(() => {
|
||||||
|
if (violationCount === 1 && !hasShownWarning.current) {
|
||||||
|
hasShownWarning.current = true;
|
||||||
|
setShowWarning(true);
|
||||||
|
} else if (violationCount >= 2 && status !== 'SUBMITTED' && status !== 'TIMED_OUT' && !hasAutoSubmitted.current) {
|
||||||
|
hasAutoSubmitted.current = true;
|
||||||
|
submitExam('SUBMITTED');
|
||||||
|
submitAttemptToServer('b0e17378-864d-439e-8806-708dc48a891b')
|
||||||
|
.then(() => {
|
||||||
|
window.alert("Test Auto-Submitted due to multiple tab switching violations.");
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Auto-submit fetch error:", err);
|
||||||
|
window.alert("Test Auto-Submitted due to multiple tab switching violations.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [violationCount, status, submitExam, submitAttemptToServer]);
|
||||||
|
|
||||||
|
const startTest = async () => {
|
||||||
|
try {
|
||||||
|
if (document.documentElement.requestFullscreen) {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Fullscreen API failed", e);
|
||||||
|
}
|
||||||
|
setTestStarted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (!currentQuestion) return;
|
||||||
|
const timeSpent = Math.floor((Date.now() - questionStartTime) / 1000);
|
||||||
|
addTimeSpent(currentQuestion.id, timeSpent);
|
||||||
|
if (currentQuestionIndex < questions.length - 1) {
|
||||||
|
goToQuestion(currentQuestionIndex + 1);
|
||||||
|
} else {
|
||||||
|
goToQuestion(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (!currentQuestion) return;
|
||||||
|
const timeSpent = Math.floor((Date.now() - questionStartTime) / 1000);
|
||||||
|
addTimeSpent(currentQuestion.id, timeSpent);
|
||||||
|
if (currentQuestionIndex > 0) {
|
||||||
|
goToQuestion(currentQuestionIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGridClick = (index: number) => {
|
||||||
|
if (!currentQuestion) return;
|
||||||
|
const timeSpent = Math.floor((Date.now() - questionStartTime) / 1000);
|
||||||
|
addTimeSpent(currentQuestion.id, timeSpent);
|
||||||
|
goToQuestion(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setShowSubmitConfirm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSubmit = async () => {
|
||||||
|
setShowSubmitConfirm(false);
|
||||||
|
submitExam('SUBMITTED');
|
||||||
|
const payload = getAttemptPayload('b0e17378-864d-439e-8806-708dc48a891b');
|
||||||
|
console.log("EXAM SUBMITTED. PAYLOAD:", JSON.stringify(payload, null, 2));
|
||||||
|
await submitAttemptToServer('b0e17378-864d-439e-8806-708dc48a891b');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted || questions.length === 0) return <div className="p-8">Loading Exam Engine...</div>;
|
||||||
|
|
||||||
|
if (!testStarted && status !== 'SUBMITTED' && status !== 'TIMED_OUT') {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col items-center justify-center bg-background p-8 text-center animate-in fade-in duration-500">
|
||||||
|
<div className="max-w-2xl bg-secondary/10 border border-border p-8 rounded-2xl shadow-2xl">
|
||||||
|
<h1 className="text-3xl font-bold mb-4 flex items-center justify-center gap-3">
|
||||||
|
<Shield className="text-red-500" /> Secure Exam Environment
|
||||||
|
</h1>
|
||||||
|
<div className="text-left space-y-4 text-muted-foreground mb-8">
|
||||||
|
<p><strong>Target:</strong> {questions.some(q => q.subject.toLowerCase() === 'biology') ? 'NEET' : 'JEE'} Pattern Test</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-2">
|
||||||
|
<li>The test will open in <strong>Full Screen Mode</strong>.</li>
|
||||||
|
<li><strong>Do not switch tabs or exit full screen.</strong></li>
|
||||||
|
<li>First violation will result in a strict warning.</li>
|
||||||
|
<li>Second violation will <strong>automatically submit</strong> your exam to prevent cheating.</li>
|
||||||
|
<li>Ensure your internet connection is stable before starting.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" className="w-full text-lg font-bold h-14 shadow-[0_0_20px_-5px_var(--color-primary)]" onClick={startTest}>
|
||||||
|
I Understand, Start Test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'SUBMITTED' || status === 'TIMED_OUT') {
|
||||||
|
const payload = getAttemptPayload('b0e17378-864d-439e-8806-708dc48a891b');
|
||||||
|
if (!payload) return <div className="p-8 text-center mt-20">Loading results...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExamResult
|
||||||
|
payload={payload}
|
||||||
|
onRetake={() => {
|
||||||
|
resetExamState();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentQuestion = questions[currentQuestionIndex];
|
||||||
|
const currentState = answers[currentQuestion.id];
|
||||||
|
|
||||||
|
// Derive palette stats
|
||||||
|
const paletteStats = Object.values(answers).reduce(
|
||||||
|
(acc, val) => {
|
||||||
|
acc[val.status]++;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ UNVISITED: 0, NOT_ANSWERED: 0, ANSWERED: 0, MARKED: 0, ANSWERED_MARKED: 0 } as Record<QuestionStatus, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group questions by subject for the side panel
|
||||||
|
const questionsBySubject = questions.reduce((acc, q, index) => {
|
||||||
|
if (!acc[q.subject]) {
|
||||||
|
acc[q.subject] = [];
|
||||||
|
}
|
||||||
|
acc[q.subject].push({ ...q, originalIndex: index });
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any[]>);
|
||||||
|
|
||||||
export default function CBTExamPage({ params }: { params: { examId: string } }) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
<header className="border-b border-border bg-background px-6 py-4 flex items-center justify-between sticky top-0 z-10">
|
{/* Warning Modal */}
|
||||||
|
{showWarning && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-background border border-red-500 shadow-[0_0_50px_-10px_rgba(239,68,68,0.5)] rounded-2xl w-[400px] overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="p-6 text-center space-y-4">
|
||||||
|
<AlertTriangle size={64} className="text-red-500 mx-auto animate-pulse" />
|
||||||
|
<h3 className="text-2xl font-bold text-red-500">Warning!</h3>
|
||||||
|
<p className="text-foreground font-medium">You switched tabs or left the secure window.</p>
|
||||||
|
<p className="text-sm text-muted-foreground">This is your final warning. Doing this again will automatically submit your exam and your score will be evaluated as is.</p>
|
||||||
|
<Button
|
||||||
|
className="w-full mt-4 bg-red-500 hover:bg-red-600 text-white font-bold"
|
||||||
|
onClick={() => {
|
||||||
|
setShowWarning(false);
|
||||||
|
startTest(); // Try to re-enter fullscreen
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Return to Exam
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="border-b border-border bg-background px-6 py-4 flex items-center justify-between sticky top-0 z-20 shadow-sm shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative h-14 w-56 shrink-0">
|
<div className="relative h-10 w-40 shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src="/PragatiVeer_Logo/SVG_Logos/Rectangular_light_background.svg"
|
src="/PragatiVeer_Logo/SVG_Logos/Rectangular_light_background.svg"
|
||||||
alt="PragatiVeer"
|
alt="PragatiVeer" fill className="object-contain object-left scale-125 origin-left dark:hidden" priority
|
||||||
fill
|
|
||||||
className="object-contain object-left scale-135 origin-left dark:hidden"
|
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
src="/PragatiVeer_Logo/SVG_Logos/Rectangular_dark_background.svg"
|
src="/PragatiVeer_Logo/SVG_Logos/Rectangular_dark_background.svg"
|
||||||
alt="PragatiVeer"
|
alt="PragatiVeer" fill className="object-contain object-left scale-125 origin-left hidden dark:block" priority
|
||||||
fill
|
|
||||||
className="object-contain object-left scale-135 origin-left hidden dark:block"
|
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-6 w-px bg-border hidden md:block"></div>
|
<div className="h-6 w-px bg-border hidden md:block"></div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<h1 className="font-semibold text-sm">AITS Full Syllabus Test 4</h1>
|
<h1 className="font-semibold text-sm">AITS Full Syllabus Test</h1>
|
||||||
<p className="text-xs text-muted-foreground font-medium mt-0.5">Physics • Section A</p>
|
<p className="text-xs text-muted-foreground font-medium mt-0.5">{currentQuestion.subject}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 md:gap-6">
|
<div className="flex items-center gap-3 md:gap-6">
|
||||||
<div className="flex items-center gap-2 bg-secondary/50 px-3 py-1.5 md:px-4 md:py-2 rounded-xl font-mono text-base md:text-lg tracking-wider font-medium text-foreground">
|
<div className="hidden lg:flex items-center gap-2 mr-4">
|
||||||
<Clock size={18} className="text-muted-foreground" />
|
<Button variant="ghost" size="sm" onClick={() => setShowCalc(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:text-foreground"><CalculatorIcon size={14}/> Calc</Button>
|
||||||
02:45:12
|
<Button variant="ghost" size="sm" onClick={() => setShowPeriodicTable(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:text-foreground"><FlaskConical size={14}/> Periodic Table</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowScratchpad(true)} className="gap-2 text-xs font-semibold text-muted-foreground hover:text-foreground"><Edit3 size={14}/> Scratchpad</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="gap-2 rounded-xl hidden md:flex">
|
|
||||||
<Pause size={14} /> Pause
|
<div className={`flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 rounded-xl font-mono text-base md:text-lg tracking-wider font-medium border border-border ${isTimedOut ? 'bg-red-500/10 text-red-500' : 'bg-secondary/50 text-foreground'}`}>
|
||||||
|
<Clock size={18} className="text-muted-foreground" />
|
||||||
|
<span className={isTimedOut ? "font-bold animate-pulse text-red-500" : "font-bold text-primary"}>{formattedTime}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsPaused(!isPaused)} className="gap-2 rounded-xl hidden md:flex border-border">
|
||||||
|
{isPaused ? <><Play size={14} /> Resume</> : <><PauseIcon size={14} /> Pause</>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className="rounded-xl">Submit Exam</Button>
|
<Button size="sm" onClick={handleSubmit} className="rounded-xl shadow-[0_0_15px_-3px_var(--color-primary)]">Submit Exam</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden relative">
|
<div className="flex flex-1 overflow-hidden relative">
|
||||||
{/* Main Question Area */}
|
{/* Main Question Area - Configured for Bilingual Split Pane */}
|
||||||
<main className="flex-1 overflow-y-auto p-6 lg:p-12 pb-24 lg:pb-12">
|
<main className="flex-1 overflow-y-auto p-0 flex flex-col xl:flex-row pb-24 lg:pb-0">
|
||||||
<div className="max-w-3xl mx-auto space-y-8">
|
<div className="flex-1 p-6 lg:p-10 border-b xl:border-b-0 xl:border-r border-border flex flex-col">
|
||||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
<div className="max-w-2xl mx-auto w-full space-y-8 flex-1">
|
||||||
<Badge variant="secondary" className="px-3 py-1 text-sm bg-secondary text-secondary-foreground font-medium rounded-lg">Question 12</Badge>
|
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-secondary/30 px-2 py-1 rounded-md">+4 / -1</span>
|
<Badge variant="secondary" className="px-3 py-1 text-sm bg-secondary text-secondary-foreground font-medium rounded-lg">Question {currentQuestion.number}</Badge>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground rounded-lg">
|
<span className="text-[10px] uppercase font-bold text-muted-foreground bg-secondary/30 px-2 py-0.5 rounded-md border border-border">English</span>
|
||||||
<Flag size={16} />
|
</div>
|
||||||
</Button>
|
<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>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => markForReview(currentQuestion.id)}
|
||||||
|
className={`h-8 w-8 rounded-lg transition-colors ${currentState?.status.includes('MARKED') ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground hover:text-amber-500'}`}
|
||||||
|
>
|
||||||
|
<Flag size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-relaxed text-foreground font-medium">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
|
{currentQuestion.text_en}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-8">
|
||||||
|
{currentQuestion.options.map((option: string, idx: number) => {
|
||||||
|
const letter = String.fromCharCode(65 + idx);
|
||||||
|
const isSelected = currentState?.selected_answer === letter;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => selectAnswer(currentQuestion.id, letter)}
|
||||||
|
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center gap-4 group focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
|
||||||
|
${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`flex-shrink-0 size-8 rounded-lg border flex items-center justify-center font-medium transition-colors
|
||||||
|
${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'}
|
||||||
|
`}>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-semibold flex-1 ${isSelected ? 'text-primary' : ''}`}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
|
{option}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-loose text-foreground">
|
{/* Optional Bilingual Hindi Pane */}
|
||||||
<p>
|
<div className="flex-1 p-6 lg:p-10 hidden lg:flex bg-secondary/5 border-b xl:border-b-0">
|
||||||
A solid cylinder of mass <span className="font-mono text-primary bg-secondary/50 px-1.5 py-0.5 rounded-md text-base">M</span> and radius <span className="font-mono text-primary bg-secondary/50 px-1.5 py-0.5 rounded-md text-base">R</span> is rolling without slipping down an inclined plane of angle <span className="font-mono text-primary bg-secondary/50 px-1.5 py-0.5 rounded-md text-base">θ</span>.
|
<div className="max-w-2xl mx-auto space-y-8 w-full">
|
||||||
What is the minimum coefficient of static friction <span className="font-mono text-primary bg-secondary/50 px-1.5 py-0.5 rounded-md text-base">μ_s</span> required to prevent slipping?
|
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||||
</p>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Badge variant="outline" className="px-3 py-1 text-sm text-muted-foreground font-medium rounded-lg">प्रश्न {currentQuestion.number}</Badge>
|
||||||
|
<span className="text-[10px] uppercase font-bold text-muted-foreground bg-secondary/30 px-2 py-0.5 rounded-md border border-border">हिंदी</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none text-lg leading-relaxed text-foreground font-medium">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
|
{currentQuestion.text_hi}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 mt-8">
|
<div className="space-y-3 mt-8">
|
||||||
{['(1/3) tan θ', '(2/3) tan θ', '(1/2) tan θ', 'tan θ'].map((option, idx) => (
|
{currentQuestion.options_hi?.map((option: string, idx: number) => {
|
||||||
<button
|
const letter = String.fromCharCode(65 + idx);
|
||||||
key={idx}
|
const isSelected = currentState?.selected_answer === letter;
|
||||||
className="w-full text-left p-4 rounded-xl border border-border bg-background hover:border-primary/50 hover:bg-secondary/20 transition-all duration-300 flex items-center gap-4 group focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
return (
|
||||||
>
|
<button
|
||||||
<div className="flex-shrink-0 size-8 rounded-lg border border-border flex items-center justify-center font-medium text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5 transition-colors">
|
key={idx}
|
||||||
{String.fromCharCode(65 + idx)}
|
onClick={() => selectAnswer(currentQuestion.id, letter)}
|
||||||
</div>
|
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center gap-4 group focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
|
||||||
<span className="text-base font-medium">{option}</span>
|
${isSelected ? 'border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]' : 'border-border bg-background hover:border-primary/50 hover:bg-secondary/20'}
|
||||||
</button>
|
`}
|
||||||
))}
|
>
|
||||||
|
<div className={`flex-shrink-0 size-8 rounded-lg border flex items-center justify-center font-medium transition-colors
|
||||||
|
${isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary group-hover:bg-primary/5'}
|
||||||
|
`}>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-semibold flex-1 ${isSelected ? 'text-primary' : ''}`}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
|
{option}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Side Panel for Question Grid */}
|
{/* Side Panel for Question Grid */}
|
||||||
<aside className="w-80 border-l border-border bg-secondary/10 flex flex-col hidden lg:flex">
|
<aside className="w-80 border-l border-border bg-secondary/10 flex flex-col hidden lg:flex shrink-0">
|
||||||
<div className="p-5 border-b border-border flex items-center justify-between bg-background">
|
<div className="p-5 border-b border-border flex items-center justify-between bg-background">
|
||||||
<h3 className="font-semibold text-sm tracking-tight uppercase text-muted-foreground">Question Palette</h3>
|
<h3 className="font-bold text-sm tracking-tight uppercase text-muted-foreground">Question Palette</h3>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg"><MoreVertical size={16}/></Button>
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg"><MoreVertical size={16}/></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
<div className="mb-8 space-y-3 bg-background border border-border p-4 rounded-xl">
|
<div className="mb-6 grid grid-cols-2 gap-2 bg-background border border-border p-3 rounded-xl">
|
||||||
<div className="flex items-center justify-between text-xs font-medium text-muted-foreground">
|
<div className="flex items-center gap-2 text-[11px] font-bold text-muted-foreground">
|
||||||
<div className="flex items-center gap-2"><div className="size-3.5 rounded-full bg-primary shadow-sm"></div> Answered <span className="text-foreground ml-1">(11)</span></div>
|
<div className="size-4 rounded border border-transparent bg-green-500 shadow-sm text-white flex items-center justify-center">{paletteStats.ANSWERED}</div> <span className="truncate">Answered</span>
|
||||||
<div className="flex items-center gap-2"><div className="size-3.5 rounded-full border-2 border-border bg-background"></div> Not Visited <span className="text-foreground ml-1">(28)</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs font-medium text-muted-foreground">
|
<div className="flex items-center gap-2 text-[11px] font-bold text-muted-foreground">
|
||||||
<div className="flex items-center gap-2"><div className="size-3.5 rounded-full bg-destructive shadow-sm"></div> Not Answered <span className="text-foreground ml-1">(4)</span></div>
|
<div className="size-4 rounded border border-transparent bg-red-500 shadow-sm text-white flex items-center justify-center">{paletteStats.NOT_ANSWERED}</div> <span className="truncate">Not Answered</span>
|
||||||
<div className="flex items-center gap-2"><div className="size-3.5 rounded-full bg-amber-500 shadow-sm"></div> Marked <span className="text-foreground ml-1">(2)</span></div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-bold text-muted-foreground">
|
||||||
|
<div className="size-4 rounded border border-border bg-background shadow-sm text-foreground flex items-center justify-center">{paletteStats.UNVISITED}</div> <span className="truncate">Not Visited</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-bold text-muted-foreground">
|
||||||
|
<div className="size-4 rounded-full border border-transparent bg-amber-500 shadow-sm text-white flex items-center justify-center">{paletteStats.MARKED}</div> <span className="truncate">Marked</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-bold text-muted-foreground col-span-2 mt-1">
|
||||||
|
<div className="size-4 rounded-full border border-transparent bg-amber-500 shadow-sm text-white flex items-center justify-center relative">
|
||||||
|
{paletteStats.ANSWERED_MARKED}<div className="absolute -bottom-0.5 -right-0.5 size-1.5 rounded-full bg-green-500 border border-white"></div>
|
||||||
|
</div>
|
||||||
|
<span className="truncate">Answered & Marked</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
{Object.entries(questionsBySubject).map(([subject, subQuestions]: [string, any]) => (
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-4 flex items-center gap-2">
|
<div key={subject}>
|
||||||
<span className="h-px bg-border flex-1"></span> Physics <span className="h-px bg-border flex-1"></span>
|
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4 flex items-center gap-2">
|
||||||
</h4>
|
<span className="h-px bg-border flex-1"></span> {subject} <span className="h-px bg-border flex-1"></span>
|
||||||
<div className="grid grid-cols-5 gap-2.5">
|
</h4>
|
||||||
{Array.from({length: 20}).map((_, i) => (
|
<div className="grid grid-cols-5 gap-2.5">
|
||||||
<button
|
{subQuestions.map((q: any) => {
|
||||||
key={i}
|
const i = q.originalIndex;
|
||||||
className={`size-11 rounded-lg flex items-center justify-center text-sm font-medium transition-all duration-200 border focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
|
const qState = answers[q.id];
|
||||||
${i === 11 ? 'ring-2 ring-primary ring-offset-2 ring-offset-background border-transparent bg-background shadow-sm' : ''}
|
const isActive = i === currentQuestionIndex;
|
||||||
${i < 5 ? 'bg-primary border-primary text-primary-foreground shadow-sm' : ''}
|
|
||||||
${i >= 5 && i < 7 ? 'bg-destructive border-destructive text-destructive-foreground shadow-sm' : ''}
|
let btnClass = "bg-background border-border text-muted-foreground hover:border-primary/50 hover:text-foreground";
|
||||||
${i === 7 ? 'bg-amber-500 border-amber-500 text-amber-50-foreground shadow-sm' : ''}
|
let shapeClass = "rounded"; // Default shape
|
||||||
${i > 11 ? 'bg-background border-border text-muted-foreground hover:border-primary/50 hover:text-foreground' : ''}
|
|
||||||
`}
|
if (qState) {
|
||||||
>
|
if (qState.status === 'ANSWERED') {
|
||||||
{i + 1}
|
btnClass = "bg-green-500 border-green-500 text-white shadow-sm";
|
||||||
</button>
|
} else if (qState.status === 'NOT_ANSWERED') {
|
||||||
))}
|
btnClass = "bg-red-500 border-red-500 text-white shadow-sm";
|
||||||
|
} else if (qState.status === 'MARKED') {
|
||||||
|
btnClass = "bg-amber-500 border-amber-500 text-white shadow-sm";
|
||||||
|
shapeClass = "rounded-full";
|
||||||
|
} else if (qState.status === 'ANSWERED_MARKED') {
|
||||||
|
btnClass = "bg-amber-500 border-amber-500 text-white shadow-sm relative";
|
||||||
|
shapeClass = "rounded-full";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
btnClass = "ring-2 ring-primary ring-offset-2 ring-offset-background border-transparent bg-background shadow-sm text-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => handleGridClick(i)}
|
||||||
|
className={`size-11 flex items-center justify-center text-sm font-bold transition-all duration-200 border focus:outline-none focus:ring-2 focus:ring-primary ${btnClass} ${shapeClass}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
{qState?.status === 'ANSWERED_MARKED' && <div className="absolute -bottom-1 -right-1 size-3 rounded-full bg-green-500 border-2 border-background"></div>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 border-t border-border bg-background grid grid-cols-2 gap-3">
|
<div className="p-4 border-t border-border bg-background flex flex-col gap-3">
|
||||||
<Button variant="outline" className="rounded-xl w-full gap-1.5 h-12"><ChevronLeft size={16}/> Previous</Button>
|
<div className="flex gap-2">
|
||||||
<Button className="rounded-xl w-full gap-1.5 h-12 shadow-sm">Save & Next <ChevronRight size={16}/></Button>
|
<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={() => clearResponse(currentQuestion.id)} className="flex-1 text-xs font-bold h-9 bg-secondary/50 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"><XCircle size={14} className="mr-1.5"/> Clear</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handlePrev} className="flex-1 gap-1 h-12 rounded-xl font-bold"><ChevronLeft size={16}/> Back</Button>
|
||||||
|
<Button onClick={handleNext} className="flex-[2] gap-1 h-12 rounded-xl font-bold shadow-[0_0_15px_-5px_var(--color-primary)]">Save & Next <ChevronRight size={16}/></Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile bottom nav */}
|
{/* Mobile bottom nav */}
|
||||||
<div className="lg:hidden absolute bottom-0 left-0 right-0 p-4 border-t border-border bg-background/95 backdrop-blur-sm grid grid-cols-2 gap-3">
|
<div className="lg:hidden absolute bottom-0 left-0 right-0 p-4 border-t border-border bg-background/95 backdrop-blur-sm grid grid-cols-2 gap-3 z-30">
|
||||||
<Button variant="outline" className="rounded-xl w-full gap-1.5 h-12"><ChevronLeft size={16}/> Previous</Button>
|
<Button variant="outline" onClick={handlePrev} className="rounded-xl w-full gap-1.5 h-12 font-bold"><ChevronLeft size={16}/> Previous</Button>
|
||||||
<Button className="rounded-xl w-full gap-1.5 h-12 shadow-sm">Save & Next <ChevronRight size={16}/></Button>
|
<Button onClick={handleNext} className="rounded-xl w-full gap-1.5 h-12 font-bold shadow-sm">Save & Next <ChevronRight size={16}/></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modals and Overlays */}
|
||||||
|
{isPaused && (
|
||||||
|
<div className="absolute inset-0 z-40 bg-background/95 backdrop-blur-md flex flex-col items-center justify-center p-8">
|
||||||
|
<PauseIcon size={64} className="text-muted-foreground mb-6" />
|
||||||
|
<h2 className="text-3xl font-bold mb-4">Exam Paused</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md text-center mb-8">
|
||||||
|
The exam timer is currently running in the background. Pausing only hides the questions to prevent cheating while taking a break.
|
||||||
|
</p>
|
||||||
|
<Button size="lg" onClick={() => setIsPaused(false)} className="rounded-xl h-14 px-10 text-lg font-bold gap-2">
|
||||||
|
<Play size={20} /> Resume Exam
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSubmitConfirm && (
|
||||||
|
<div className="absolute inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background border border-border shadow-2xl rounded-2xl p-6 max-w-md w-full animate-in fade-in zoom-in duration-200">
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Submit Exam?</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">Are you sure you want to submit your exam? You will not be able to change your answers after submission.</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-8 p-4 bg-secondary/20 rounded-xl border border-border">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-500">{paletteStats.ANSWERED + paletteStats.ANSWERED_MARKED}</div>
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase">Answered</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-red-500">{paletteStats.NOT_ANSWERED + paletteStats.UNVISITED}</div>
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase">Unanswered</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center col-span-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<div className="text-2xl font-bold text-amber-500">{paletteStats.MARKED + paletteStats.ANSWERED_MARKED}</div>
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase">Marked for Review</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" className="flex-1 rounded-xl h-12 font-bold" onClick={() => setShowSubmitConfirm(false)}>Cancel</Button>
|
||||||
|
<Button className="flex-1 rounded-xl h-12 font-bold shadow-[0_0_15px_-3px_var(--color-primary)]" onClick={confirmSubmit}>Yes, Submit</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Calculator isOpen={showCalc} onClose={() => setShowCalc(false)} />
|
||||||
|
<Scratchpad isOpen={showScratchpad} onClose={() => setShowScratchpad(false)} />
|
||||||
|
<PeriodicTable isOpen={showPeriodicTable} onClose={() => setShowPeriodicTable(false)} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
170
src/app/dashboard/ai-coach/page.tsx
Normal file
170
src/app/dashboard/ai-coach/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
||||||
|
import { StudyPlanCard } from "@/components/AI/StudyPlanCard"
|
||||||
|
import { ReadinessGauge } from "@/components/AI/ReadinessGauge"
|
||||||
|
import { CollegePredictorCard } from "@/components/AI/CollegePredictorCard"
|
||||||
|
import { TutorChat } from "@/components/AI/TutorChat"
|
||||||
|
import { ReferralCard } from "@/components/AI/ReferralCard"
|
||||||
|
import { ProgressShare } from "@/components/AI/ProgressShare"
|
||||||
|
import { MistakeReviewCard, MistakePayload } from "@/components/AI/MistakeReviewCard"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
|
// Hardcode mock UUID for demo fetching
|
||||||
|
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
|
||||||
|
export default function AICoachDashboard() {
|
||||||
|
const [mistakes, setMistakes] = useState<MistakePayload[]>([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchMistakes = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/mistakes/today?user_id=${USER_ID}`)
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
|
const data = await res.json()
|
||||||
|
setMistakes(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
// If backend fails, fallback to empty array
|
||||||
|
setMistakes([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMistakes()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const submitReview = async (mistakeId: string, selectedAnswer: string) => {
|
||||||
|
const res = await fetch("http://localhost:8000/api/mistakes/review", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mistake_id: mistakeId, selected_answer: selectedAnswer })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to submit review")
|
||||||
|
return await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentIndex < mistakes.length - 1) {
|
||||||
|
setCurrentIndex(prev => prev + 1)
|
||||||
|
} else {
|
||||||
|
// Session finished
|
||||||
|
setCurrentIndex(mistakes.length) // Push out of bounds to trigger complete state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl mx-auto space-y-10 animate-in fade-in duration-500">
|
||||||
|
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||||
|
<BrainCircuit className="text-primary" size={32}/> AI Coach
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 font-medium">Your personalized SM-2 spaced repetition and analytics engine.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Summary Metrics */}
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="border-border shadow-sm">
|
||||||
|
<CardContent className="p-5 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-secondary rounded-xl text-primary"><Calculator size={20}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Physics Errors</p>
|
||||||
|
<h3 className="text-2xl font-bold">{mistakes.filter(m => m.question.subject === 'Physics').length}</h3>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-border shadow-sm">
|
||||||
|
<CardContent className="p-5 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-secondary rounded-xl text-emerald-500"><BookOpen size={20}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Chemistry Errors</p>
|
||||||
|
<h3 className="text-2xl font-bold">{mistakes.filter(m => m.question.subject === 'Chemistry').length}</h3>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-border shadow-sm">
|
||||||
|
<CardContent className="p-5 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-secondary rounded-xl text-blue-500"><Target size={20}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Maths Errors</p>
|
||||||
|
<h3 className="text-2xl font-bold">{mistakes.filter(m => m.question.subject === 'Maths').length}</h3>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Predictive Intelligence */}
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
<ReadinessGauge />
|
||||||
|
<CollegePredictorCard />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Mistake Review Workspace */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">Daily Mistake Review Queue</h2>
|
||||||
|
<span className="text-sm font-bold text-muted-foreground bg-secondary px-3 py-1 rounded-full">
|
||||||
|
{currentIndex < mistakes.length ? `${currentIndex + 1} / ${mistakes.length}` : "Finished"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card className="p-12 flex items-center justify-center border-dashed">
|
||||||
|
<RefreshCw className="animate-spin text-muted-foreground mr-3" /> Loading queue...
|
||||||
|
</Card>
|
||||||
|
) : mistakes.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center border-dashed bg-secondary/20">
|
||||||
|
<CheckCircle2 size={48} className="mx-auto text-green-500 mb-4 opacity-50" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">You're all caught up!</h3>
|
||||||
|
<p className="text-muted-foreground">No mistakes scheduled for review today. Go take a test!</p>
|
||||||
|
</Card>
|
||||||
|
) : currentIndex < mistakes.length ? (
|
||||||
|
<MistakeReviewCard
|
||||||
|
data={mistakes[currentIndex]}
|
||||||
|
onNext={handleNext}
|
||||||
|
onSubmitReview={submitReview}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card className="p-12 text-center border-dashed bg-primary/5 border-primary/20">
|
||||||
|
<BrainCircuit size={48} className="mx-auto text-primary mb-4 opacity-80" />
|
||||||
|
<h3 className="text-xl font-bold mb-2 text-primary">Session Complete</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">Great job clearing your error queue! The SM-2 engine has scheduled your next reviews.</p>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outline" className="rounded-xl">Check for New Errors</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Heatmap & Timeline */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8 pt-8 border-t border-border">
|
||||||
|
<section>
|
||||||
|
<StudyPlanCard />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MistakeHeatmap />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tutor Chat Workspace */}
|
||||||
|
<section className="pt-8 border-t border-border">
|
||||||
|
<TutorChat />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Growth & Sharing Hub */}
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-2 gap-8 pt-8 border-t border-border mt-8">
|
||||||
|
<ReferralCard />
|
||||||
|
<ProgressShare />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { LineChart, Activity, BrainCircuit } from "lucide-react"
|
import { LineChart as LineChartIcon, Activity, BrainCircuit } from "lucide-react"
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from 'recharts'
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
|
const trendData = [
|
||||||
|
{ name: 'Week 1', score: 120 },
|
||||||
|
{ name: 'Week 2', score: 135 },
|
||||||
|
{ name: 'Week 3', score: 130 },
|
||||||
|
{ name: 'Week 4', score: 145 },
|
||||||
|
{ name: 'Week 5', score: 160 },
|
||||||
|
{ name: 'Week 6', score: 185 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// For heatmap-like visualization (using a barchart with different colors)
|
||||||
|
const heatmapData = [
|
||||||
|
{ topic: 'Kinematics', accuracy: 95 },
|
||||||
|
{ topic: 'Optics', accuracy: 72 },
|
||||||
|
{ topic: 'Thermodynamics', accuracy: 45 },
|
||||||
|
{ topic: 'Calculus', accuracy: 38 },
|
||||||
|
{ topic: 'Organic Chem', accuracy: 88 },
|
||||||
|
{ topic: 'Inorganic', accuracy: 65 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const getColor = (accuracy: number) => {
|
||||||
|
if (accuracy >= 80) return '#10b981' // emerald-500
|
||||||
|
if (accuracy >= 60) return '#f59e0b' // amber-500
|
||||||
|
return '#ef4444' // red-500
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-6xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-6xl mx-auto">
|
||||||
<div>
|
<div>
|
||||||
@@ -12,16 +40,22 @@ export default function AnalyticsPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<Card className="col-span-1 lg:col-span-2 border-border shadow-sm bg-background">
|
<Card className="col-span-1 lg:col-span-2 border-border shadow-sm bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><LineChart className="text-[#00F0FF]" size={20} /> Projected Score Trajectory</CardTitle>
|
<CardTitle className="flex items-center gap-2"><LineChartIcon className="text-[#00F0FF]" size={20} /> Projected Score Trajectory</CardTitle>
|
||||||
<CardDescription>Your mock scores over the last 6 months projected towards the final exam.</CardDescription>
|
<CardDescription>Your mock scores over the last 6 months projected towards the final exam.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-64 flex items-center justify-center border-t border-border mt-4">
|
<CardContent className="h-[350px] mt-4">
|
||||||
{/* Placeholder for an actual chart library like Recharts */}
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<div className="text-center text-muted-foreground">
|
<LineChart data={trendData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
||||||
<Activity size={48} className="mx-auto mb-4 opacity-20" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" vertical={false} />
|
||||||
<p className="font-medium">Data gathering in progress...</p>
|
<XAxis dataKey="name" stroke="#888" tick={{ fill: '#888' }} />
|
||||||
<p className="text-sm mt-1">Complete 2 more CBTs to unlock predictive graphing.</p>
|
<YAxis stroke="#888" tick={{ fill: '#888' }} domain={[0, 300]} />
|
||||||
</div>
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1f2937', borderColor: '#374151', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: '#00F0FF' }}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="score" stroke="#00F0FF" strokeWidth={3} dot={{ r: 4, fill: '#00F0FF' }} activeDot={{ r: 6 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -39,21 +73,25 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
<Card className="border-border shadow-sm">
|
<Card className="border-border shadow-sm">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-lg">Subject Mastery</CardTitle>
|
<CardTitle className="text-lg">Topic Heatmap (Accuracy)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pt-2">
|
<CardContent className="h-[250px] mt-2">
|
||||||
<div>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<div className="flex justify-between text-sm mb-1 font-medium"><span>Biology</span> <span className="text-emerald-500">89%</span></div>
|
<BarChart data={heatmapData} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden"><div className="h-full bg-emerald-500 w-[89%]"></div></div>
|
<XAxis type="number" domain={[0, 100]} hide />
|
||||||
</div>
|
<YAxis dataKey="topic" type="category" stroke="#888" width={100} tick={{ fontSize: 12, fill: '#bbb' }} axisLine={false} tickLine={false} />
|
||||||
<div>
|
<Tooltip
|
||||||
<div className="flex justify-between text-sm mb-1 font-medium"><span>Chemistry</span> <span className="text-amber-500">68%</span></div>
|
cursor={{fill: 'transparent'}}
|
||||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden"><div className="h-full bg-amber-500 w-[68%]"></div></div>
|
contentStyle={{ backgroundColor: '#1f2937', borderColor: '#374151', borderRadius: '8px', color: '#fff' }}
|
||||||
</div>
|
formatter={(value: number) => [`${value}% Accuracy`, 'Accuracy']}
|
||||||
<div>
|
/>
|
||||||
<div className="flex justify-between text-sm mb-1 font-medium"><span>Physics</span> <span className="text-destructive">42%</span></div>
|
<Bar dataKey="accuracy" radius={[0, 4, 4, 0]} barSize={12}>
|
||||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden"><div className="h-full bg-destructive w-[42%]"></div></div>
|
{heatmapData.map((entry, index) => (
|
||||||
</div>
|
<Cell key={`cell-${index}`} fill={getColor(entry.accuracy)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
168
src/app/dashboard/cbt/history/[attemptId]/page.tsx
Normal file
168
src/app/dashboard/cbt/history/[attemptId]/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, use } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ArrowLeft, CheckCircle2, XCircle, Clock, Award } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AttemptHistoryPage({ params }: { params: Promise<{ attemptId: string }> }) {
|
||||||
|
const { attemptId } = use(params);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [attempt, setAttempt] = useState<any>(null);
|
||||||
|
const [test, setTest] = useState<any>(null);
|
||||||
|
const [questions, setQuestions] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const userId = "b0e17378-864d-439e-8806-708dc48a891b";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchHistoryData() {
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
// 1. Fetch all attempts to find this specific one
|
||||||
|
const attemptsRes = await fetch(`${apiUrl}/api/users/${userId}/attempts`);
|
||||||
|
const attemptsData = await attemptsRes.json();
|
||||||
|
const foundAttempt = attemptsData.find((a: any) => a.id === attemptId);
|
||||||
|
|
||||||
|
if (!foundAttempt) {
|
||||||
|
throw new Error("Attempt not found");
|
||||||
|
}
|
||||||
|
setAttempt(foundAttempt);
|
||||||
|
|
||||||
|
// 2. Fetch the test details
|
||||||
|
const testRes = await fetch(`${apiUrl}/api/tests/${foundAttempt.source_id}?user_id=${userId}`);
|
||||||
|
const testData = await testRes.json();
|
||||||
|
setTest(testData);
|
||||||
|
|
||||||
|
// 3. Fetch all questions for this attempt to build the answer key
|
||||||
|
const qs: Record<string, any> = {};
|
||||||
|
await Promise.all(
|
||||||
|
foundAttempt.answers.map(async (ans: any) => {
|
||||||
|
const qRes = await fetch(`${apiUrl}/api/questions/${ans.question_id}`);
|
||||||
|
if (qRes.ok) {
|
||||||
|
const qData = await qRes.json();
|
||||||
|
qs[ans.question_id] = qData;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setQuestions(qs);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load attempt history:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchHistoryData();
|
||||||
|
}, [attemptId, userId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempt) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
Attempt not found. <Link href="/dashboard/cbt" className="text-primary underline">Go back</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreCalc = (attempt.score * 4) - ((attempt.total_questions - attempt.score) * 1);
|
||||||
|
const maxScore = attempt.total_questions * 4;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<Link href="/dashboard/cbt" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors">
|
||||||
|
<ArrowLeft size={16} className="mr-2" /> Back to Tests
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-8 mb-10">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{test?.title || 'Practice Test'} Solutions</h1>
|
||||||
|
<p className="text-muted-foreground">Review your performance, detailed solutions, and identify your weak spots.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Card className="bg-secondary/30 border-border min-w-32">
|
||||||
|
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||||
|
<Award className="text-[#00F0FF] mb-2" size={24} />
|
||||||
|
<div className="text-sm text-muted-foreground font-bold uppercase">Score</div>
|
||||||
|
<div className="text-2xl font-bold">{scoreCalc} <span className="text-sm text-muted-foreground">/ {maxScore}</span></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-secondary/30 border-border min-w-32">
|
||||||
|
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||||
|
<Clock className="text-primary mb-2" size={24} />
|
||||||
|
<div className="text-sm text-muted-foreground font-bold uppercase">Time Taken</div>
|
||||||
|
<div className="text-2xl font-bold">{Math.floor(attempt.time_taken / 60)}m {attempt.time_taken % 60}s</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-bold border-b border-border pb-2">Answer Key & Explanations</h2>
|
||||||
|
|
||||||
|
{attempt.answers.map((ans: any, idx: number) => {
|
||||||
|
const q = questions[ans.question_id];
|
||||||
|
if (!q) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={ans.question_id} className={`border-l-4 ${ans.is_correct ? 'border-l-green-500' : 'border-l-red-500'}`}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-secondary flex items-center justify-center font-bold">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-lg leading-relaxed">{q.text}</h3>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Badge variant="outline" className="text-xs">{q.topic}</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">{q.difficulty}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ans.is_correct ? (
|
||||||
|
<Badge className="bg-green-500/20 text-green-500 border-green-500/30 flex items-center gap-1"><CheckCircle2 size={14}/> Correct</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500/20 text-red-500 border-red-500/30 flex items-center gap-1"><XCircle size={14}/> Incorrect</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6 ml-12">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase">Your Answer</p>
|
||||||
|
<div className={`p-3 rounded-lg border ${ans.is_correct ? 'bg-green-500/10 border-green-500/30 text-green-500' : 'bg-red-500/10 border-red-500/30 text-red-500'}`}>
|
||||||
|
{q.options[ans.selected_answer] ? `${ans.selected_answer}) ${q.options[ans.selected_answer]}` : 'Not Attempted'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase">Correct Answer</p>
|
||||||
|
<div className="p-3 rounded-lg border bg-green-500/10 border-green-500/30 text-green-500">
|
||||||
|
{q.correct_answer?.correct}) {q.options[q.correct_answer?.correct]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{q.correct_answer?.explanation && (
|
||||||
|
<div className="mt-6 ml-12 p-4 bg-secondary/30 rounded-xl border border-border">
|
||||||
|
<p className="text-sm font-bold mb-2">Explanation:</p>
|
||||||
|
<p className="text-muted-foreground">{q.correct_answer.explanation}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,95 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
"use client";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { useEffect, useState } from "react";
|
||||||
import { Activity, Clock, PlayCircle, Search } from "lucide-react"
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Activity, Clock, PlayCircle, Search } from "lucide-react";
|
||||||
|
|
||||||
|
interface TestData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
exam_pattern: string;
|
||||||
|
total_time: number;
|
||||||
|
difficulty: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CBTPage() {
|
export default function CBTPage() {
|
||||||
const upcomingExams = [
|
const [exams, setExams] = useState<TestData[]>([]);
|
||||||
{ title: "AITS Full Syllabus Test 4", date: "Tomorrow, 10:00 AM", duration: "180 Min", marks: 720 },
|
const [loading, setLoading] = useState(true);
|
||||||
{ title: "Part Test 3: Mechanics", date: "Oct 24, 2026", duration: "180 Min", marks: 720 },
|
const [search, setSearch] = useState("");
|
||||||
{ title: "Weekly Revision Test", date: "Oct 28, 2026", duration: "90 Min", marks: 360 },
|
const [targetExam, setTargetExam] = useState("");
|
||||||
]
|
const [attempts, setAttempts] = useState<any[]>([]);
|
||||||
|
|
||||||
const pastExams = [
|
const userId = "b0e17378-864d-439e-8806-708dc48a891b";
|
||||||
{ title: "AITS Full Syllabus Test 3", date: "Oct 15, 2026", score: 645, total: 720, percentile: "99.4" },
|
|
||||||
{ title: "Part Test 2: Electromagnetism", date: "Oct 08, 2026", score: 680, total: 720, percentile: "99.8" },
|
useEffect(() => {
|
||||||
]
|
async function loadCBTExams() {
|
||||||
|
try {
|
||||||
|
// 1. Fetch user target exam
|
||||||
|
const userRes = await fetch(`http://localhost:8000/api/users/${userId}`);
|
||||||
|
if (!userRes.ok) throw new Error("Failed to load user target exam");
|
||||||
|
const userData = await userRes.json();
|
||||||
|
const pattern = userData.target_exam;
|
||||||
|
setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main");
|
||||||
|
|
||||||
|
// 2. Fetch tests filtered by exam
|
||||||
|
const testRes = await fetch(`http://localhost:8000/api/tests?exam_pattern=${pattern}`);
|
||||||
|
if (!testRes.ok) throw new Error("Failed to fetch tests");
|
||||||
|
const testsData = await testRes.json();
|
||||||
|
setExams(testsData);
|
||||||
|
|
||||||
|
// 3. Fetch user attempts
|
||||||
|
const attemptsRes = await fetch(`http://localhost:8000/api/users/${userId}/attempts`);
|
||||||
|
if (attemptsRes.ok) {
|
||||||
|
const attemptsData = await attemptsRes.json();
|
||||||
|
setAttempts(attemptsData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading exams:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadCBTExams();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredExams = exams.filter(exam =>
|
||||||
|
exam.title.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the highest score for each test if attempted multiple times
|
||||||
|
const attemptsByTest = attempts.reduce((acc, curr) => {
|
||||||
|
if (!acc[curr.source_id] || acc[curr.source_id].score < curr.score) {
|
||||||
|
acc[curr.source_id] = curr;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-6xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-6xl mx-auto">
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Computer Based Tests (CBT)</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Computer Based Tests (CBT)</h1>
|
||||||
<p className="text-muted-foreground mt-1 font-medium">Real exam environment simulations to conquer NEET/JEE.</p>
|
<p className="text-muted-foreground mt-1 font-medium">
|
||||||
|
Real exam environment simulations to conquer {targetExam || "NEET/JEE"}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-64">
|
<div className="relative w-64">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||||
<input type="text" placeholder="Search exams..." className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[#00F0FF] outline-none" />
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search exams..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[#00F0FF] outline-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,23 +97,49 @@ export default function CBTPage() {
|
|||||||
<h2 className="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
<h2 className="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||||
<Clock size={20} className="text-[#00F0FF]" /> Upcoming & Active Exams
|
<Clock size={20} className="text-[#00F0FF]" /> Upcoming & Active Exams
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{upcomingExams.map((exam, i) => (
|
{loading ? (
|
||||||
<Card key={i} className="hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group">
|
<div className="py-12 text-center text-muted-foreground">Loading test catalog...</div>
|
||||||
<CardContent className="p-6">
|
) : filteredExams.length === 0 ? (
|
||||||
<Badge variant="outline" className="mb-4 bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">Mock Test</Badge>
|
<div className="py-12 text-center text-muted-foreground bg-secondary/10 border border-dashed border-border rounded-2xl">
|
||||||
<h3 className="font-bold text-lg leading-tight mb-2 group-hover:text-[#00F0FF] transition-colors">{exam.title}</h3>
|
No active exams found for {targetExam || "your target exam"}. Try switching it in settings!
|
||||||
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
</div>
|
||||||
<div className="font-medium text-foreground">{exam.date}</div>
|
) : (
|
||||||
<div>{exam.duration} • {exam.marks} Marks</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
</div>
|
{filteredExams.map((exam) => {
|
||||||
<Button className="w-full gap-2 rounded-xl bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background transition-colors font-bold">
|
const attempt = attemptsByTest[exam.id];
|
||||||
<PlayCircle size={16} /> Start Exam
|
const isAttempted = !!attempt;
|
||||||
</Button>
|
|
||||||
</CardContent>
|
return (
|
||||||
</Card>
|
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''}`}>
|
||||||
))}
|
<CardContent className="p-6">
|
||||||
</div>
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">
|
||||||
|
{exam.type} Test
|
||||||
|
</Badge>
|
||||||
|
{isAttempted && (
|
||||||
|
<Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
|
Attempted
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg leading-tight mb-2 group-hover:text-[#00F0FF] transition-colors">
|
||||||
|
{exam.title}
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
||||||
|
<div className="font-medium text-foreground">Available Now</div>
|
||||||
|
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/cbt/${exam.id}`}>
|
||||||
|
<Button className={`w-full gap-2 rounded-xl transition-colors font-bold ${isAttempted ? 'bg-secondary border border-border text-foreground' : 'bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background'}`}>
|
||||||
|
<PlayCircle size={16} /> {isAttempted ? 'Retake Exam' : 'Start Exam'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 pt-8">
|
<div className="space-y-6 pt-8">
|
||||||
@@ -56,29 +147,44 @@ export default function CBTPage() {
|
|||||||
<Activity size={20} className="text-muted-foreground" /> Past Attempts
|
<Activity size={20} className="text-muted-foreground" /> Past Attempts
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pastExams.map((exam, i) => (
|
{attempts.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground bg-secondary/10 border border-dashed border-border rounded-2xl">
|
||||||
|
You haven't attempted any CBT exams yet.
|
||||||
|
</div>
|
||||||
|
) : attempts.map((attempt, i) => {
|
||||||
|
const exam = exams.find(e => e.id === attempt.source_id);
|
||||||
|
const scoreCalc = (attempt.score * 4) - ((attempt.total_questions - attempt.score) * 1); // rough calc for display
|
||||||
|
|
||||||
|
return (
|
||||||
<Card key={i} className="bg-secondary/20">
|
<Card key={i} className="bg-secondary/20">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="p-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<div>
|
<div className="w-full md:w-auto">
|
||||||
<h3 className="font-bold">{exam.title}</h3>
|
<h3 className="font-bold">{exam?.title || 'Unknown Test'}</h3>
|
||||||
<p className="text-sm text-muted-foreground font-medium">{exam.date}</p>
|
<p className="text-sm text-muted-foreground font-medium">{formatDate(attempt.submitted_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-4 md:gap-8 w-full md:w-auto justify-between md:justify-end">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm text-muted-foreground font-bold uppercase tracking-wider">Score</div>
|
<div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Score</div>
|
||||||
<div className="font-bold text-lg"><span className="text-foreground">{exam.score}</span><span className="text-muted-foreground">/{exam.total}</span></div>
|
<div className="font-bold text-base md:text-lg">
|
||||||
|
<span className="text-foreground">{scoreCalc}</span>
|
||||||
|
<span className="text-muted-foreground">/{attempt.total_questions * 4}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm text-muted-foreground font-bold uppercase tracking-wider">Percentile</div>
|
<div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Accuracy</div>
|
||||||
<div className="font-bold text-lg text-[#00F0FF]">{exam.percentile}</div>
|
<div className="font-bold text-base md:text-lg text-[#00F0FF]">{Math.round(attempt.accuracy)}%</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="rounded-xl border-border hover:bg-secondary">Review Analysis</Button>
|
<Link href={`/dashboard/cbt/history/${attempt.id}`}>
|
||||||
|
<Button variant="outline" className="rounded-xl border-border hover:bg-secondary">
|
||||||
|
View Solutions
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
195
src/app/dashboard/community/page.tsx
Normal file
195
src/app/dashboard/community/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||||
|
import { MessageSquare, ThumbsUp, ChevronUp, Search, Image as ImageIcon, CheckCircle } from "lucide-react"
|
||||||
|
|
||||||
|
interface Doubt {
|
||||||
|
id: string
|
||||||
|
author: string
|
||||||
|
avatar: string
|
||||||
|
timeAgo: string
|
||||||
|
subject: string
|
||||||
|
topic: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
upvotes: number
|
||||||
|
replies: number
|
||||||
|
isSolved: boolean
|
||||||
|
hasModeratorReply: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommunityPage() {
|
||||||
|
const [activeFilter, setActiveFilter] = useState<'all' | 'my-doubts' | 'solved' | 'unsolved'>('all')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
const mockDoubts: Doubt[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
author: "Rahul Verma",
|
||||||
|
avatar: "RV",
|
||||||
|
timeAgo: "2 hours ago",
|
||||||
|
subject: "Physics",
|
||||||
|
topic: "Rotational Mechanics",
|
||||||
|
title: "Why does friction act forwards in a rolling wheel?",
|
||||||
|
content: "I'm confused about the direction of friction when a car accelerates forward. My book says friction pushes the wheel forward, but shouldn't it oppose motion?",
|
||||||
|
upvotes: 42,
|
||||||
|
replies: 5,
|
||||||
|
isSolved: true,
|
||||||
|
hasModeratorReply: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
author: "Priya Sharma",
|
||||||
|
avatar: "PS",
|
||||||
|
timeAgo: "5 hours ago",
|
||||||
|
subject: "Chemistry",
|
||||||
|
topic: "Chemical Kinetics",
|
||||||
|
title: "How to quickly identify a pseudo first-order reaction?",
|
||||||
|
content: "Are there any tricks to spot these in MCQs? I always end up wasting 2 minutes trying to derive the rate law.",
|
||||||
|
upvotes: 18,
|
||||||
|
replies: 2,
|
||||||
|
isSolved: false,
|
||||||
|
hasModeratorReply: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
author: "Demo Student",
|
||||||
|
avatar: "DS",
|
||||||
|
timeAgo: "1 day ago",
|
||||||
|
subject: "Mathematics",
|
||||||
|
topic: "Definite Integration",
|
||||||
|
title: "Applying King's Rule when limits are -a to a",
|
||||||
|
content: "I keep messing up the signs when applying the property f(x) + f(-x). Can someone show a step-by-step for the modulus function?",
|
||||||
|
upvotes: 12,
|
||||||
|
replies: 8,
|
||||||
|
isSolved: true,
|
||||||
|
hasModeratorReply: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredDoubts = mockDoubts.filter(d => {
|
||||||
|
if (activeFilter === 'my-doubts' && d.author !== "Demo Student") return false
|
||||||
|
if (activeFilter === 'solved' && !d.isSolved) return false
|
||||||
|
if (activeFilter === 'unsolved' && d.isSolved) return false
|
||||||
|
if (searchQuery && !d.title.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-3">
|
||||||
|
<MessageSquare className="text-[#00F0FF]" size={32} />
|
||||||
|
Doubt Forum
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 font-medium">Ask questions, share knowledge, and learn together with the community.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="px-5 py-2.5 bg-[#00F0FF] text-black font-bold rounded-xl shadow-[0_0_15px_-3px_rgba(0,240,255,0.4)] hover:bg-[#00d0dd] transition-colors">
|
||||||
|
Ask a Doubt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Search size={18} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search doubts by keyword..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-card border border-border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]/50"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 bg-secondary/50 p-1 rounded-xl border border-border shrink-0 overflow-x-auto">
|
||||||
|
<FilterButton active={activeFilter === 'all'} onClick={() => setActiveFilter('all')}>All Doubts</FilterButton>
|
||||||
|
<FilterButton active={activeFilter === 'my-doubts'} onClick={() => setActiveFilter('my-doubts')}>My Doubts</FilterButton>
|
||||||
|
<FilterButton active={activeFilter === 'solved'} onClick={() => setActiveFilter('solved')}>Solved</FilterButton>
|
||||||
|
<FilterButton active={activeFilter === 'unsolved'} onClick={() => setActiveFilter('unsolved')}>Unsolved</FilterButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredDoubts.map((doubt) => (
|
||||||
|
<Card key={doubt.id} className="border-border shadow-sm hover:border-[#00F0FF]/50 transition-colors cursor-pointer">
|
||||||
|
<CardContent className="p-0 flex">
|
||||||
|
{/* Upvote Sidebar */}
|
||||||
|
<div className="w-16 bg-secondary/20 border-r border-border flex flex-col items-center py-4 gap-1">
|
||||||
|
<button className="p-1.5 text-muted-foreground hover:text-[#00F0FF] hover:bg-[#00F0FF]/10 rounded-lg transition-colors">
|
||||||
|
<ChevronUp size={24} />
|
||||||
|
</button>
|
||||||
|
<span className="font-bold text-foreground">{doubt.upvotes}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold px-2.5 py-1 bg-secondary text-foreground rounded-md">{doubt.subject}</span>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">• {doubt.topic}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{doubt.timeAgo}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-foreground mb-1">{doubt.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">{doubt.content}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-auto">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-6 w-6 rounded-full bg-secondary flex items-center justify-center text-[10px] font-bold">
|
||||||
|
{doubt.avatar}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-foreground">{doubt.author}</span>
|
||||||
|
{doubt.hasModeratorReply && (
|
||||||
|
<span className="ml-2 flex items-center gap-1 text-[10px] font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-sm border border-emerald-500/20">
|
||||||
|
<CheckCircle size={10} /> Expert Replied
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{doubt.isSolved && (
|
||||||
|
<span className="text-xs font-bold text-emerald-500 flex items-center gap-1">
|
||||||
|
<CheckCircle size={14} /> Solved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm font-medium">
|
||||||
|
<MessageSquare size={16} />
|
||||||
|
{doubt.replies} Replies
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{filteredDoubts.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<MessageSquare size={48} className="mx-auto mb-4 opacity-20" />
|
||||||
|
<p className="font-medium">No doubts found matching your criteria.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
active
|
||||||
|
? 'bg-background shadow-sm border border-border text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,29 +1,55 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { BrainCircuit, Play, CheckCircle2, ChevronRight } from "lucide-react"
|
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock } from "lucide-react"
|
||||||
|
|
||||||
export default function DPPPage() {
|
export default function DPPPage() {
|
||||||
const dpps = [
|
const dpps = [
|
||||||
{ title: "Rotational Dynamics: Top Overturning", subject: "Physics", count: 15, status: "Pending", priority: "High" },
|
{ title: "Top Overturning", subject: "Physics", topic: "Rotational Dynamics", difficulty: "Hard", count: 15, status: "Pending", priority: "High" },
|
||||||
{ title: "Chemical Kinetics: First Order Reactions", subject: "Chemistry", count: 20, status: "Pending", priority: "Medium" },
|
{ title: "First Order Reactions", subject: "Chemistry", topic: "Chemical Kinetics", difficulty: "Medium", count: 20, status: "Pending", priority: "Medium" },
|
||||||
{ title: "Genetics: Mendelian Inheritance", subject: "Biology", count: 25, status: "Completed", score: "23/25" },
|
{ title: "Mendelian Inheritance", subject: "Biology", topic: "Genetics", difficulty: "Easy", count: 25, status: "Completed", score: "23/25" },
|
||||||
{ title: "Calculus: Definite Integration", subject: "Mathematics", count: 15, status: "Completed", score: "14/15" },
|
{ title: "Definite Integration", subject: "Mathematics", topic: "Calculus", difficulty: "Mixed", count: 15, status: "Completed", score: "14/15" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const getDifficultyColor = (diff: string) => {
|
||||||
|
switch(diff) {
|
||||||
|
case 'Easy': return 'text-emerald-500 bg-emerald-500/10 border-emerald-500/20';
|
||||||
|
case 'Medium': return 'text-amber-500 bg-amber-500/10 border-amber-500/20';
|
||||||
|
case 'Hard': return 'text-destructive bg-destructive/10 border-destructive/20';
|
||||||
|
default: return 'text-primary bg-primary/10 border-primary/20';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto">
|
||||||
<div className="flex justify-between items-center p-8 bg-gradient-to-r from-secondary/50 to-background border border-border rounded-2xl shadow-sm">
|
<div className="flex flex-col md:flex-row md:items-center justify-between p-8 bg-gradient-to-r from-secondary/50 to-background border border-border rounded-2xl shadow-sm gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Daily Practice Problems (DPP)</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">Daily Practice Problems (DPP)</h1>
|
||||||
<p className="text-muted-foreground font-medium max-w-xl">
|
<p className="text-muted-foreground font-medium max-w-xl">
|
||||||
Hyper-personalized problem sets generated daily by our AI Evaluator to target your specific weak areas based on past exam performance.
|
Hyper-personalized problem sets generated daily by our AI Evaluator to target your specific weak areas based on past exam performance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" className="h-14 px-8 rounded-xl bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 font-bold shadow-[0_0_20px_-5px_#00F0FF]">
|
<Button size="lg" className="h-14 px-8 rounded-xl bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 font-bold shadow-[0_0_20px_-5px_#00F0FF] shrink-0">
|
||||||
<BrainCircuit className="mr-2" /> Generate Custom DPP
|
<BrainCircuit className="mr-2" /> Generate Custom DPP
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground mr-2"><Filter size={16} className="inline mr-1"/> Difficulty:</span>
|
||||||
|
{['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => (
|
||||||
|
<button key={diff} className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${diff === 'Mixed' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary/50'}`}>
|
||||||
|
{diff}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1 ml-2 text-xs text-muted-foreground bg-secondary/50 px-2 py-1 rounded-md">
|
||||||
|
<Lock size={12}/> Pro
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="gap-2 rounded-xl text-sm font-bold">
|
||||||
|
<Download size={16} /> Export as PDF <span className="ml-1 text-[10px] uppercase tracking-wider bg-primary/10 text-primary px-1.5 py-0.5 rounded-md">Pro</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{dpps.map((dpp, i) => (
|
{dpps.map((dpp, i) => (
|
||||||
<Card key={i} className={`group cursor-pointer transition-all hover:shadow-md ${dpp.status === 'Completed' ? 'opacity-70 hover:opacity-100 bg-secondary/10' : 'hover:border-[#00F0FF]/50 bg-background'}`}>
|
<Card key={i} className={`group cursor-pointer transition-all hover:shadow-md ${dpp.status === 'Completed' ? 'opacity-70 hover:opacity-100 bg-secondary/10' : 'hover:border-[#00F0FF]/50 bg-background'}`}>
|
||||||
@@ -33,14 +59,23 @@ export default function DPPPage() {
|
|||||||
{dpp.status === 'Completed' ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground group-hover:border-[#00F0FF] transition-colors flex items-center justify-center"></div>}
|
{dpp.status === 'Completed' ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground group-hover:border-[#00F0FF] transition-colors flex items-center justify-center"></div>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-bold uppercase tracking-wider text-[#00F0FF] mb-1">{dpp.subject}</div>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-bold text-lg leading-tight group-hover:text-[#00F0FF] transition-colors">{dpp.title}</h3>
|
<span className="text-xs font-bold uppercase tracking-wider text-[#00F0FF]">{dpp.subject}</span>
|
||||||
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{dpp.topic}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg leading-tight group-hover:text-[#00F0FF] transition-colors flex items-center gap-2">
|
||||||
|
{dpp.title}
|
||||||
|
<span className={`text-[10px] uppercase px-2 py-0.5 rounded-md border font-bold tracking-wider ${getDifficultyColor(dpp.difficulty)}`}>
|
||||||
|
{dpp.difficulty}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
||||||
{dpp.count} Questions • {dpp.status === 'Completed' ? `Score: ${dpp.score}` : `${dpp.priority} Priority`}
|
{dpp.count} Questions • {dpp.status === 'Completed' ? `Score: ${dpp.score}` : `${dpp.priority} Priority`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground group-hover:text-foreground transition-colors pl-4">
|
<div className="text-muted-foreground group-hover:text-foreground transition-colors pl-4 shrink-0">
|
||||||
{dpp.status === 'Completed' ? (
|
{dpp.status === 'Completed' ? (
|
||||||
<Button variant="ghost" size="sm" className="font-bold">Review</Button>
|
<Button variant="ghost" size="sm" className="font-bold">Review</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Tro
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
import { LanguageToggle } from "@/components/LanguageToggle"
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle"
|
||||||
|
import { TierBadge } from "@/components/TierBadge"
|
||||||
|
import { StreakCounter } from "@/components/StreakCounter"
|
||||||
|
import { AITutorChat } from "@/components/AI/AITutorChat"
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -50,6 +55,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
<NavItem href="/dashboard/dpp" icon={<FileText size={18} />} label="Daily Practice" active={pathname.startsWith("/dashboard/dpp")} />
|
<NavItem href="/dashboard/dpp" icon={<FileText size={18} />} label="Daily Practice" active={pathname.startsWith("/dashboard/dpp")} />
|
||||||
<NavItem href="/dashboard/materials" icon={<BookOpen size={18} />} label="Study Materials" active={pathname.startsWith("/dashboard/materials")} />
|
<NavItem href="/dashboard/materials" icon={<BookOpen size={18} />} label="Study Materials" active={pathname.startsWith("/dashboard/materials")} />
|
||||||
|
|
||||||
|
<div className="pt-4 pb-2">
|
||||||
|
<p className="px-3 text-xs font-bold uppercase tracking-widest text-muted-foreground">Community & Tracking</p>
|
||||||
|
</div>
|
||||||
|
<NavItem href="/dashboard/leaderboard" icon={<Trophy size={18} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} />
|
||||||
|
<NavItem href="/dashboard/progress" icon={<Activity size={18} />} label="Syllabus Tracker" active={pathname.startsWith("/dashboard/progress")} />
|
||||||
|
<NavItem href="/dashboard/community" icon={<BookOpen size={18} />} label="Doubt Forum" active={pathname.startsWith("/dashboard/community")} />
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,8 +69,24 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
<NavItem href="/dashboard/rank-predictor" icon={<Trophy size={18} />} label="Rank Predictor" active={pathname.startsWith("/dashboard/rank-predictor")} />
|
<NavItem href="/dashboard/rank-predictor" icon={<Trophy size={18} />} label="Rank Predictor" active={pathname.startsWith("/dashboard/rank-predictor")} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-border space-y-2">
|
<div className="p-4 border-t border-border space-y-2 bg-secondary/30">
|
||||||
<NavItem href="/dashboard/settings" icon={<Settings size={18} />} label="Settings" active={pathname.startsWith("/dashboard/settings")} />
|
<div className="flex justify-end mb-2 px-1">
|
||||||
|
<Link href="/dashboard/streaks" className="w-full text-right hover:opacity-80 transition-opacity">
|
||||||
|
<StreakCounter streak={12} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings"
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 border ${
|
||||||
|
pathname.startsWith("/dashboard/settings")
|
||||||
|
? "bg-background text-foreground font-medium border-border shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/80 hover:text-foreground border-transparent hover:border-border/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
<span className="text-sm">Settings</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Functional User Dropdown */}
|
{/* Functional User Dropdown */}
|
||||||
<div className="relative mt-2" ref={dropdownRef}>
|
<div className="relative mt-2" ref={dropdownRef}>
|
||||||
@@ -81,15 +109,15 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
className="w-full flex items-center justify-between p-2 rounded-xl hover:bg-secondary/50 transition-colors border border-transparent hover:border-border"
|
className="w-full flex items-center justify-between p-2 rounded-xl bg-background/50 hover:bg-background transition-colors border border-border/50 shadow-sm mt-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-8 rounded-full bg-[#00F0FF]/20 text-[#00F0FF] flex items-center justify-center font-bold text-sm shadow-[0_0_10px_-2px_#00F0FF]">
|
<div className="h-8 w-8 rounded-full bg-[#00F0FF]/20 text-[#00F0FF] flex items-center justify-center font-bold text-sm shadow-[0_0_10px_-2px_#00F0FF]">
|
||||||
DS
|
DS
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold leading-none">Demo Student</p>
|
<p className="text-sm font-bold leading-none truncate">Demo Student</p>
|
||||||
<p className="text-xs text-[#00F0FF] font-medium mt-1">AI Pro Plan</p>
|
<div className="mt-1.5"><TierBadge tier="AI Coach" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronUp size={16} className={`text-muted-foreground transition-transform ${dropdownOpen ? "rotate-180" : ""}`} />
|
<ChevronUp size={16} className={`text-muted-foreground transition-transform ${dropdownOpen ? "rotate-180" : ""}`} />
|
||||||
@@ -99,9 +127,18 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 overflow-y-auto">
|
<div className="flex-1 flex flex-col h-screen overflow-hidden relative">
|
||||||
{children}
|
<header className="absolute top-6 right-8 z-10 flex items-center gap-3 bg-background/80 backdrop-blur-sm p-1.5 rounded-2xl border border-border/50 shadow-sm">
|
||||||
</main>
|
<LanguageToggle />
|
||||||
|
<ThemeToggle />
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global AI Tutor Chat */}
|
||||||
|
<AITutorChat />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/app/dashboard/leaderboard/page.tsx
Normal file
142
src/app/dashboard/leaderboard/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Trophy, Medal, Star, Shield, Search } from "lucide-react"
|
||||||
|
|
||||||
|
type LeaderboardTab = 'all-india' | 'state' | 'school'
|
||||||
|
|
||||||
|
export default function LeaderboardPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<LeaderboardTab>('all-india')
|
||||||
|
const [isAnonymous, setIsAnonymous] = useState(false)
|
||||||
|
|
||||||
|
// Mock data representing F-07 requirements (Accuracy x Consistency)
|
||||||
|
const rankings = [
|
||||||
|
{ rank: 1, name: "Rahul Verma", score: 9850, accuracy: "92%", streak: 45, isCurrentUser: false },
|
||||||
|
{ rank: 2, name: "Priya Sharma", score: 9420, accuracy: "89%", streak: 42, isCurrentUser: false },
|
||||||
|
{ rank: 3, name: "Amit Kumar", score: 9100, accuracy: "87%", streak: 38, isCurrentUser: false },
|
||||||
|
{ rank: 4, name: "Sneha Patel", score: 8950, accuracy: "85%", streak: 30, isCurrentUser: false },
|
||||||
|
{ rank: 5, name: "Vikram Singh", score: 8800, accuracy: "84%", streak: 28, isCurrentUser: false },
|
||||||
|
{ rank: 6, name: "Demo Student", score: 8650, accuracy: "82%", streak: 25, isCurrentUser: true },
|
||||||
|
{ rank: 7, name: "Ananya Desai", score: 8500, accuracy: "80%", streak: 20, isCurrentUser: false },
|
||||||
|
{ rank: 8, name: "Karan Johar", score: 8450, accuracy: "79%", streak: 18, isCurrentUser: false },
|
||||||
|
{ rank: 9, name: "Neha Gupta", score: 8300, accuracy: "78%", streak: 15, isCurrentUser: false },
|
||||||
|
{ rank: 10, name: "Rohan Das", score: 8100, accuracy: "75%", streak: 12, isCurrentUser: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-3">
|
||||||
|
<Trophy className="text-yellow-500" size={32} />
|
||||||
|
Leaderboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 font-medium">Rankings are based on your Accuracy × Consistency (Streak Bonus).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 bg-secondary/50 p-1.5 rounded-xl border border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all-india')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === 'all-india' ? 'bg-background shadow-sm border border-border text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
All India
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('state')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === 'state' ? 'bg-background shadow-sm border border-border text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
State
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('school')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === 'school' ? 'bg-background shadow-sm border border-border text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
School
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="flex justify-between items-center bg-card border border-border rounded-xl p-4 shadow-sm sticky top-0 z-10 backdrop-blur-md bg-opacity-90">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground w-12 text-center">RANK</span>
|
||||||
|
<span className="text-sm font-bold text-muted-foreground flex-1 px-4">STUDENT</span>
|
||||||
|
<div className="flex gap-8 w-48 justify-end pr-4">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground text-center">SCORE</span>
|
||||||
|
<span className="text-sm font-bold text-muted-foreground text-center">STREAK</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rankings.map((student) => (
|
||||||
|
<div
|
||||||
|
key={student.rank}
|
||||||
|
className={`flex justify-between items-center p-4 rounded-xl border transition-all ${
|
||||||
|
student.isCurrentUser
|
||||||
|
? 'border-[#00F0FF] bg-[#00F0FF]/5 shadow-[0_0_15px_-3px_rgba(0,240,255,0.2)]'
|
||||||
|
: 'border-border bg-card hover:bg-secondary/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-12 flex justify-center">
|
||||||
|
{student.rank === 1 ? <Medal className="text-yellow-500" size={24} /> :
|
||||||
|
student.rank === 2 ? <Medal className="text-gray-400" size={24} /> :
|
||||||
|
student.rank === 3 ? <Medal className="text-amber-700" size={24} /> :
|
||||||
|
<span className={`font-bold text-lg ${student.isCurrentUser ? 'text-[#00F0FF]' : 'text-muted-foreground'}`}>#{student.rank}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 px-4 flex items-center gap-3">
|
||||||
|
<div className={`h-10 w-10 rounded-full flex items-center justify-center font-bold text-sm ${
|
||||||
|
student.isCurrentUser ? 'bg-[#00F0FF]/20 text-[#00F0FF]' : 'bg-secondary text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{student.name.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`font-bold ${student.isCurrentUser ? 'text-[#00F0FF]' : 'text-foreground'}`}>
|
||||||
|
{student.isCurrentUser ? (isAnonymous ? "Anonymous User" : student.name + " (You)") : student.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Shield size={12} /> Accuracy: {student.accuracy}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-8 w-48 justify-end pr-4 items-center">
|
||||||
|
<div className="text-center font-mono font-bold text-foreground">
|
||||||
|
{student.score.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-center flex items-center gap-1 font-bold text-orange-500">
|
||||||
|
<Star size={14} className="fill-orange-500" /> {student.streak}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border-[#00F0FF]/30 bg-gradient-to-br from-[#00F0FF]/10 to-transparent">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">Your Standing</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-4xl font-black text-[#00F0FF] mb-1">#6</div>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-4">Top 15% of users</p>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
You are 150 points away from breaking into the Top 5. Complete today's DPS to earn your streak bonus!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between border-t border-border pt-4">
|
||||||
|
<span className="text-sm font-medium text-foreground">Anonymous Mode</span>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" checked={isAnonymous} onChange={() => setIsAnonymous(!isAnonymous)} />
|
||||||
|
<div className="w-11 h-6 bg-secondary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#00F0FF]"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default function MaterialsPage() {
|
|||||||
{ name: "Physics Formula Sheets", items: 12, color: "text-blue-500" },
|
{ name: "Physics Formula Sheets", items: 12, color: "text-blue-500" },
|
||||||
{ name: "Organic Chem Mind Maps", items: 8, color: "text-emerald-500" },
|
{ name: "Organic Chem Mind Maps", items: 8, color: "text-emerald-500" },
|
||||||
{ name: "Biology NCERT Highlights", items: 22, color: "text-rose-500" },
|
{ name: "Biology NCERT Highlights", items: 22, color: "text-rose-500" },
|
||||||
{ name: "Past Year Papers (2015-2025)", items: 10, color: "text-amber-500" },
|
{ name: "Math Formulae", items: 15, color: "text-amber-500" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const recentFiles = [
|
const recentFiles = [
|
||||||
|
|||||||
@@ -1,76 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Activity, BrainCircuit, ArrowRight, Clock } from "lucide-react"
|
import { Activity, BrainCircuit, ArrowRight, Clock, Target, CheckCircle2, TrendingUp, Lock, Trophy, Share2 } from "lucide-react"
|
||||||
|
import * as htmlToImage from 'html-to-image'
|
||||||
|
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
||||||
|
import { AdaptiveStudyPlan } from "@/components/AI/AdaptiveStudyPlan"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (progressRef.current) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await htmlToImage.toPng(progressRef.current, { backgroundColor: '#09090b' })
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = 'pragativeer-progress.png'
|
||||||
|
link.href = dataUrl
|
||||||
|
link.click()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to export image", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 items-center justify-between">
|
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Welcome back, Student</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Welcome back, Demo Student</h1>
|
||||||
<p className="text-muted-foreground mt-1 font-medium">Here is your daily AI-curated study plan.</p>
|
<p className="text-muted-foreground mt-1 font-medium">Let's keep up the momentum for JEE Advanced 2027.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" className="gap-2 shadow-sm">
|
<Button size="lg" className="gap-2 shadow-sm bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 font-bold">
|
||||||
<BrainCircuit size={18} />
|
<BrainCircuit size={18} />
|
||||||
Generate AI Plan
|
Generate AI Plan
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* AI Predicted Weak Areas */}
|
{/* Free Tier Upgrade CTA Banner */}
|
||||||
|
<Card className="bg-primary/5 border-primary/20 relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-8 opacity-10">
|
||||||
|
<BrainCircuit size={120} />
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-6 md:p-8 flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Badge className="bg-primary text-primary-foreground font-bold tracking-widest uppercase mb-2">Upgrade to Pro</Badge>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Unlock Unlimited DPS & Mocks</h2>
|
||||||
|
<p className="text-muted-foreground max-w-xl font-medium">
|
||||||
|
You're currently on the Free Tier. Upgrade to Pro for ₹299/mo to access unlimited Daily Practice Sheets, weekly CBT tests, and advanced analytics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" className="shrink-0 rounded-xl shadow-[0_0_20px_-5px_var(--color-primary)] font-bold">
|
||||||
|
Upgrade Now
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Basic Performance Dashboard (F-05) */}
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-xl font-semibold tracking-tight">AI Focus Areas</h2>
|
<h2 className="text-xl font-semibold tracking-tight">Performance Overview</h2>
|
||||||
|
<Button onClick={handleExport} variant="outline" size="sm" className="gap-2 font-medium">
|
||||||
|
<Share2 size={16} /> Share Card
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div ref={progressRef} className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 -mx-4 bg-background rounded-2xl">
|
||||||
<Card className="border-t-4 border-t-destructive">
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||||
<CardTitle className="text-lg">Rotational Motion</CardTitle>
|
<div className="p-3 bg-secondary rounded-full mb-3 text-primary"><Target size={24} /></div>
|
||||||
<CardDescription>Physics</CardDescription>
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Today's Score</p>
|
||||||
</CardHeader>
|
<h3 className="text-2xl font-bold">42 / 60</h3>
|
||||||
<CardContent>
|
<p className="text-xs text-green-500 font-bold mt-2 flex items-center gap-1"><TrendingUp size={12}/> +5 from yesterday</p>
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Mastery</span>
|
|
||||||
<span className="font-medium text-destructive">42%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-secondary rounded-full h-1.5 mt-2 overflow-hidden">
|
|
||||||
<div className="bg-destructive h-1.5 rounded-full w-[42%]"></div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-t-4 border-t-amber-500">
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||||
<CardTitle className="text-lg">Chemical Kinetics</CardTitle>
|
<div className="p-3 bg-secondary rounded-full mb-3 text-amber-500"><Activity size={24} /></div>
|
||||||
<CardDescription>Chemistry</CardDescription>
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Overall Accuracy</p>
|
||||||
</CardHeader>
|
<h3 className="text-2xl font-bold">68%</h3>
|
||||||
<CardContent>
|
<p className="text-xs text-amber-500 font-bold mt-2">Needs Improvement</p>
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Mastery</span>
|
|
||||||
<span className="font-medium text-amber-500">68%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-secondary rounded-full h-1.5 mt-2 overflow-hidden">
|
|
||||||
<div className="bg-amber-500 h-1.5 rounded-full w-[68%]"></div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-t-4 border-t-emerald-500">
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||||
<CardTitle className="text-lg">Genetics</CardTitle>
|
<div className="p-3 bg-secondary rounded-full mb-3 text-[#00F0FF]"><CheckCircle2 size={24} /></div>
|
||||||
<CardDescription>Biology</CardDescription>
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Qs Attempted</p>
|
||||||
</CardHeader>
|
<h3 className="text-2xl font-bold">1,245</h3>
|
||||||
<CardContent>
|
<p className="text-xs text-muted-foreground font-medium mt-2">Lifetime total</p>
|
||||||
<div className="flex items-center justify-between text-sm">
|
</CardContent>
|
||||||
<span className="text-muted-foreground">Mastery</span>
|
</Card>
|
||||||
<span className="font-medium text-emerald-500">89%</span>
|
<Card className="relative overflow-hidden group">
|
||||||
</div>
|
<div className="absolute inset-0 bg-gradient-to-br from-background to-secondary/50 z-0"></div>
|
||||||
<div className="w-full bg-secondary rounded-full h-1.5 mt-2 overflow-hidden">
|
<CardContent className="p-5 flex flex-col items-center text-center relative z-10">
|
||||||
<div className="bg-emerald-500 h-1.5 rounded-full w-[89%]"></div>
|
<div className="p-3 bg-background border border-border shadow-sm rounded-full mb-3 text-foreground group-hover:scale-110 transition-transform"><Trophy size={24} /></div>
|
||||||
</div>
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">City Rank</p>
|
||||||
|
<h3 className="text-2xl font-bold">#42 <span className="text-base text-muted-foreground font-medium">/ 1,200</span></h3>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium mt-2 text-[#00F0FF]">Top 5% in Jaipur</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
<section>
|
||||||
|
<AdaptiveStudyPlan />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MistakeHeatmap />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* Dynamic DPP Modules */}
|
{/* Dynamic DPP Modules */}
|
||||||
<section>
|
<section>
|
||||||
@@ -96,19 +137,19 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
|
<Card className="relative opacity-60 cursor-not-allowed border-dashed">
|
||||||
<CardContent className="p-5 flex items-center justify-between">
|
<CardContent className="p-5 flex items-center justify-between">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="p-3 bg-secondary rounded-xl text-foreground">
|
<div className="p-3 bg-secondary rounded-xl text-muted-foreground">
|
||||||
<Activity size={20} />
|
<Lock size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-base group-hover:text-primary transition-colors">Biology: Human Physiology</h3>
|
<h3 className="font-medium text-base text-muted-foreground">Biology: Human Physiology</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">30 Questions • Revision</p>
|
<p className="text-sm text-muted-foreground mt-0.5 flex items-center gap-1"><Lock size={12}/> Pro Feature</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="gap-2 rounded-xl group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all">
|
<Button variant="outline" size="sm" disabled className="gap-2 rounded-xl">
|
||||||
Start <ArrowRight size={14} />
|
Locked
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -136,10 +177,10 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-5 bg-secondary/50 flex items-center justify-between transition-colors hover:bg-secondary/70">
|
<div className="p-5 bg-secondary/50 flex items-center justify-between transition-colors hover:bg-secondary/70">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-sm text-foreground">Part Test 3: Mechanics</h3>
|
<h3 className="font-medium text-sm text-foreground flex items-center gap-2"><Lock size={14} className="text-muted-foreground"/> Part Test 3: Mechanics</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 font-medium">Oct 24, 2026</p>
|
<p className="text-xs text-muted-foreground mt-0.5 font-medium ml-6">Oct 24, 2026</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="h-8 text-xs font-medium">Details</Button>
|
<Badge variant="secondary" className="text-xs">Pro Only</Badge>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
192
src/app/dashboard/progress/page.tsx
Normal file
192
src/app/dashboard/progress/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { ChevronDown, ChevronRight, CheckCircle2, Circle, Clock, Activity, Target } from "lucide-react"
|
||||||
|
|
||||||
|
type ChapterStatus = 'Mastered' | 'Practiced' | 'In Progress' | 'Not Started'
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
name: string
|
||||||
|
status: ChapterStatus
|
||||||
|
accuracy: number
|
||||||
|
questionsAttempted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chapter {
|
||||||
|
name: string
|
||||||
|
topics: Topic[]
|
||||||
|
isExpanded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subject {
|
||||||
|
name: string
|
||||||
|
chapters: Chapter[]
|
||||||
|
completionPct: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = (status: ChapterStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'Mastered': return <CheckCircle2 className="text-emerald-500" size={16} />
|
||||||
|
case 'Practiced': return <CheckCircle2 className="text-amber-500" size={16} />
|
||||||
|
case 'In Progress': return <Clock className="text-[#00F0FF]" size={16} />
|
||||||
|
case 'Not Started': return <Circle className="text-muted-foreground" size={16} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressTrackerPage() {
|
||||||
|
const [subjects, setSubjects] = useState<Subject[]>([
|
||||||
|
{
|
||||||
|
name: "Physics",
|
||||||
|
completionPct: 42,
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
name: "Kinematics",
|
||||||
|
isExpanded: true,
|
||||||
|
topics: [
|
||||||
|
{ name: "Motion in One Dimension", status: "Mastered", accuracy: 92, questionsAttempted: 150 },
|
||||||
|
{ name: "Projectile Motion", status: "Practiced", accuracy: 75, questionsAttempted: 85 },
|
||||||
|
{ name: "Relative Velocity", status: "In Progress", accuracy: 45, questionsAttempted: 20 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Laws of Motion",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "Newton's First Law", status: "Practiced", accuracy: 88, questionsAttempted: 40 },
|
||||||
|
{ name: "Friction", status: "In Progress", accuracy: 50, questionsAttempted: 35 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Thermodynamics",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "First Law", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
{ name: "Carnot Engine", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Chemistry",
|
||||||
|
completionPct: 68,
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
name: "Atomic Structure",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "Bohr Model", status: "Mastered", accuracy: 95, questionsAttempted: 110 },
|
||||||
|
{ name: "Quantum Numbers", status: "Mastered", accuracy: 88, questionsAttempted: 95 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mathematics",
|
||||||
|
completionPct: 22,
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
name: "Calculus",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "Limits", status: "In Progress", accuracy: 60, questionsAttempted: 45 },
|
||||||
|
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const toggleChapter = (subjectIndex: number, chapterIndex: number) => {
|
||||||
|
const newSubjects = [...subjects]
|
||||||
|
newSubjects[subjectIndex].chapters[chapterIndex].isExpanded = !newSubjects[subjectIndex].chapters[chapterIndex].isExpanded
|
||||||
|
setSubjects(newSubjects)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-3">
|
||||||
|
<Target className="text-[#00F0FF]" size={32} />
|
||||||
|
Syllabus Tracker
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 font-medium">Track your syllabus coverage and mastery per chapter to ensure no gaps.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
{subjects.map((sub, i) => (
|
||||||
|
<Card key={i} className="border-border shadow-sm">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<h3 className="font-bold text-lg mb-2">{sub.name}</h3>
|
||||||
|
<div className="text-3xl font-black text-[#00F0FF] mb-3">{sub.completionPct}%</div>
|
||||||
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-[#00F0FF]" style={{ width: `${sub.completionPct}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{subjects.map((subject, subIndex) => (
|
||||||
|
<Card key={subIndex} className="border-border shadow-sm overflow-hidden">
|
||||||
|
<CardHeader className="bg-secondary/30 pb-4 border-b border-border">
|
||||||
|
<CardTitle className="text-xl">{subject.name} Syllabus</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{subject.chapters.map((chapter, chapIndex) => (
|
||||||
|
<div key={chapIndex} className="flex flex-col">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleChapter(subIndex, chapIndex)}
|
||||||
|
className="flex items-center justify-between p-4 hover:bg-secondary/20 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{chapter.isExpanded ? <ChevronDown size={18} className="text-muted-foreground" /> : <ChevronRight size={18} className="text-muted-foreground" />}
|
||||||
|
<span className="font-bold">{chapter.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium bg-secondary px-3 py-1 rounded-full">
|
||||||
|
{chapter.topics.length} Topics
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{chapter.isExpanded && (
|
||||||
|
<div className="bg-background px-6 pb-4 pt-1 space-y-2">
|
||||||
|
<div className="flex justify-between items-center text-xs font-bold text-muted-foreground px-2 py-2">
|
||||||
|
<span className="w-1/2">TOPIC</span>
|
||||||
|
<span className="w-1/4 text-center">ACCURACY</span>
|
||||||
|
<span className="w-1/4 text-right">QUESTIONS</span>
|
||||||
|
</div>
|
||||||
|
{chapter.topics.map((topic, topIndex) => (
|
||||||
|
<div key={topIndex} className="flex justify-between items-center bg-secondary/10 p-3 rounded-lg border border-border/50">
|
||||||
|
<div className="w-1/2 flex items-center gap-3">
|
||||||
|
{getStatusIcon(topic.status)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{topic.name}</p>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">{topic.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/4 text-center">
|
||||||
|
{topic.accuracy > 0 ? (
|
||||||
|
<span className={`font-mono text-sm font-bold ${topic.accuracy > 80 ? 'text-emerald-500' : topic.accuracy > 60 ? 'text-amber-500' : 'text-red-500'}`}>
|
||||||
|
{topic.accuracy}%
|
||||||
|
</span>
|
||||||
|
) : <span className="text-muted-foreground text-sm">-</span>}
|
||||||
|
</div>
|
||||||
|
<div className="w-1/4 text-right">
|
||||||
|
<span className="font-mono text-sm">{topic.questionsAttempted > 0 ? topic.questionsAttempted : '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,69 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
"use client";
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const [targetExam, setTargetExam] = useState<"JEE_Main" | "NEET">("JEE_Main");
|
||||||
|
const [targetYear, setTargetYear] = useState("2026");
|
||||||
|
const [firstName, setFirstName] = useState("Rahul");
|
||||||
|
const [lastName, setLastName] = useState("Sharma");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const userId = "b0e17378-864d-439e-8806-708dc48a891b";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchUserData() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/users/${userId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setTargetExam(data.target_exam === "NEET" ? "NEET" : "JEE_Main");
|
||||||
|
if (data.name) {
|
||||||
|
const parts = data.name.split(" ");
|
||||||
|
setFirstName(parts[0] || "");
|
||||||
|
setLastName(parts.slice(1).join(" ") || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load user profile:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchUserData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/users/${userId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `${firstName} ${lastName}`.trim(),
|
||||||
|
target_exam: targetExam,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
alert("Settings saved successfully!");
|
||||||
|
} else {
|
||||||
|
alert("Failed to save settings: " + (await res.text()));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error saving settings:", err);
|
||||||
|
alert("Network error. Could not save settings.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-8">Loading settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-4xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-4xl mx-auto">
|
||||||
<div>
|
<div>
|
||||||
@@ -19,18 +81,30 @@ export default function SettingsPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold">First Name</label>
|
<label className="text-sm font-bold">First Name</label>
|
||||||
<input type="text" defaultValue="Demo" className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" />
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold">Last Name</label>
|
<label className="text-sm font-bold">Last Name</label>
|
||||||
<input type="text" defaultValue="Student" className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" />
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold">Email Address</label>
|
<label className="text-sm font-bold">Email Address</label>
|
||||||
<input type="email" defaultValue="demo@pragativeer.com" disabled className="w-full bg-secondary border border-border rounded-lg px-4 py-2 text-sm text-muted-foreground cursor-not-allowed" />
|
<input type="email" defaultValue="student@pragativeer.com" disabled className="w-full bg-secondary border border-border rounded-lg px-4 py-2 text-sm text-muted-foreground cursor-not-allowed" />
|
||||||
</div>
|
</div>
|
||||||
<Button className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2">Save Changes</Button>
|
<Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2">
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -43,26 +117,61 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-sm font-bold">Target Examination</label>
|
<label className="text-sm font-bold">Target Examination</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="flex items-center gap-2 cursor-pointer p-4 border border-[#00F0FF] bg-[#00F0FF]/5 rounded-xl flex-1">
|
<button
|
||||||
<input type="radio" name="target" defaultChecked className="accent-[#00F0FF] w-4 h-4" />
|
type="button"
|
||||||
|
onClick={() => setTargetExam("NEET")}
|
||||||
|
className={`flex items-center gap-2 cursor-pointer p-4 border rounded-xl flex-1 text-left transition-all ${
|
||||||
|
targetExam === "NEET"
|
||||||
|
? "border-[#00F0FF] bg-[#00F0FF]/5"
|
||||||
|
: "border-border bg-secondary/20 hover:bg-secondary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="target"
|
||||||
|
checked={targetExam === "NEET"}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="accent-[#00F0FF] w-4 h-4"
|
||||||
|
/>
|
||||||
<span className="font-bold">NEET (UG)</span>
|
<span className="font-bold">NEET (UG)</span>
|
||||||
</label>
|
</button>
|
||||||
<label className="flex items-center gap-2 cursor-pointer p-4 border border-border bg-secondary/20 hover:bg-secondary/50 rounded-xl flex-1 transition-colors">
|
<button
|
||||||
<input type="radio" name="target" className="accent-[#00F0FF] w-4 h-4" />
|
type="button"
|
||||||
|
onClick={() => setTargetExam("JEE_Main")}
|
||||||
|
className={`flex items-center gap-2 cursor-pointer p-4 border rounded-xl flex-1 text-left transition-all ${
|
||||||
|
targetExam === "JEE_Main"
|
||||||
|
? "border-[#00F0FF] bg-[#00F0FF]/5"
|
||||||
|
: "border-border bg-secondary/20 hover:bg-secondary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="target"
|
||||||
|
checked={targetExam === "JEE_Main"}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="accent-[#00F0FF] w-4 h-4"
|
||||||
|
/>
|
||||||
<span className="font-bold">JEE Main & Adv</span>
|
<span className="font-bold">JEE Main & Adv</span>
|
||||||
</label>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold">Target Year</label>
|
<label className="text-sm font-bold">Target Year</label>
|
||||||
<select className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF] appearance-none font-medium">
|
<select
|
||||||
|
value={targetYear}
|
||||||
|
onChange={(e) => setTargetYear(e.target.value)}
|
||||||
|
className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF] appearance-none font-medium"
|
||||||
|
>
|
||||||
<option>2026</option>
|
<option>2026</option>
|
||||||
<option>2027</option>
|
<option>2027</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2">
|
||||||
|
{saving ? "Saving..." : "Save Preferences"}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
99
src/app/dashboard/streaks/page.tsx
Normal file
99
src/app/dashboard/streaks/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Flame, Gift, Star, Trophy, Target, ArrowRight, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
|
export default function StreaksPage() {
|
||||||
|
const currentStreak = 12;
|
||||||
|
const bestStreak = 45;
|
||||||
|
|
||||||
|
const rewards = [
|
||||||
|
{ streak: 7, title: "Consistent Learner", reward: "1 Free Premium DPP", unlocked: true },
|
||||||
|
{ streak: 14, title: "Dedicated Scholar", reward: "AI Performance Analysis Report", unlocked: false },
|
||||||
|
{ streak: 30, title: "Exam Warrior", reward: "1 Full Length Mock Test", unlocked: false },
|
||||||
|
{ streak: 50, title: "PragatiVeer Champion", reward: "1-on-1 AI Tutor Session", unlocked: false },
|
||||||
|
{ streak: 100, title: "Unstoppable", reward: "Merch + 50% Off Next Renewal", unlocked: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-5xl mx-auto">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="size-16 rounded-2xl bg-orange-500/10 text-orange-500 flex items-center justify-center">
|
||||||
|
<Flame size={32} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Your Learning Streak</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 font-medium">Build daily habits to unlock premium rewards and boost your rank.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card className="bg-gradient-to-br from-orange-500/20 to-rose-500/5 border-orange-500/30">
|
||||||
|
<CardContent className="p-8 flex flex-col items-center justify-center text-center h-full">
|
||||||
|
<Flame size={64} className="text-orange-500 mb-4 animate-pulse" />
|
||||||
|
<h2 className="text-6xl font-black text-foreground mb-2">{currentStreak} <span className="text-2xl text-muted-foreground">Days</span></h2>
|
||||||
|
<p className="text-sm font-bold text-orange-500">CURRENT STREAK</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-4 max-w-xs">Complete a Daily Practice Problem (DPP) or CBT Exam every day to keep your streak alive.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-secondary/20 border-border">
|
||||||
|
<CardContent className="p-8 h-full flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<p className="text-sm font-bold text-muted-foreground">STREAK STATS</p>
|
||||||
|
<Target size={20} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Personal Best</p>
|
||||||
|
<p className="text-3xl font-bold text-foreground">{bestStreak} <span className="text-lg text-muted-foreground">Days</span></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Total Streak Points Earned</p>
|
||||||
|
<p className="text-3xl font-bold text-[#00F0FF]">1,450</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-border">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
<strong className="text-foreground">Pro Tip:</strong> Your streak directly multiplies your leaderboard score. A longer streak gives you a higher Consistency Bonus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2"><Gift className="text-[#00F0FF]" /> Streak Rewards</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{rewards.map((reward, i) => (
|
||||||
|
<div key={i} className={`p-5 rounded-2xl border flex items-center justify-between transition-colors ${reward.unlocked ? 'bg-primary/5 border-primary/30' : 'bg-secondary/10 border-border opacity-70'}`}>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className={`size-14 rounded-full flex items-center justify-center font-bold text-xl ${reward.unlocked ? 'bg-primary text-primary-foreground shadow-[0_0_15px_-5px_var(--color-primary)]' : 'bg-secondary text-muted-foreground'}`}>
|
||||||
|
{reward.streak}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-bold ${reward.unlocked ? 'text-foreground' : 'text-muted-foreground'}`}>{reward.title}</h3>
|
||||||
|
<p className={`text-sm ${reward.unlocked ? 'text-primary font-medium' : 'text-muted-foreground'}`}>{reward.reward}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
{reward.unlocked ? (
|
||||||
|
<div className="flex items-center gap-2 text-primary font-bold text-sm bg-primary/10 px-4 py-2 rounded-xl">
|
||||||
|
<CheckCircle2 size={18} /> Unlocked
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs font-bold text-muted-foreground bg-secondary px-4 py-2 rounded-xl">
|
||||||
|
{reward.streak - currentStreak} days to go
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -111,3 +111,19 @@ body {
|
|||||||
30% { transform: translateX(200%) skewX(-15deg); }
|
30% { transform: translateX(200%) skewX(-15deg); }
|
||||||
100% { transform: translateX(200%) skewX(-15deg); }
|
100% { transform: translateX(200%) skewX(-15deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function MarketingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button size="lg" variant="outline" className="rounded-full h-14 px-8 text-base border-border bg-background/50 backdrop-blur-sm w-full sm:w-auto hover:bg-secondary/50 transition-colors group">
|
<Button size="lg" variant="outline" className="rounded-full h-14 px-8 text-base border-border bg-background/50 backdrop-blur-sm w-full sm:w-auto hover:bg-secondary/50 hover:text-foreground transition-colors group">
|
||||||
<Play className="mr-2 group-hover:scale-110 transition-transform text-[#00F0FF]" size={18} fill="currentColor" /> Watch Demo
|
<Play className="mr-2 group-hover:scale-110 transition-transform text-[#00F0FF]" size={18} fill="currentColor" /> Watch Demo
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -413,16 +413,17 @@ export default function MarketingPage() {
|
|||||||
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-10 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">Basic</h3>
|
<h3 className="text-3xl font-bold mb-2">Pro</h3>
|
||||||
<p className="text-muted-foreground mb-8 text-lg">Perfect for self-paced revision.</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">₹999<span className="text-xl text-muted-foreground font-medium">/mo</span></div>
|
<div className="text-6xl font-bold tracking-tighter mb-10">₹299<span className="text-xl text-muted-foreground font-medium">/mo</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" /> 10 Full Syllabus CBTs</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" /> Standard Question Bank</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" /> Basic Analytics</li>
|
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-primary" /> Detailed Performance Analytics</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 Basic</Button>
|
<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>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -440,17 +441,17 @@ export default function MarketingPage() {
|
|||||||
<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>
|
<Badge className="bg-primary text-primary-foreground font-bold px-5 py-2 text-sm rounded-full shadow-[0_0_15px_-2px_var(--color-primary)]">MOST POPULAR</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 flex flex-col h-full">
|
<div className="relative z-10 flex flex-col h-full">
|
||||||
<h3 className="text-3xl font-bold mb-2">AI Pro</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 preparation system.</p>
|
||||||
<div className="text-6xl font-bold tracking-tighter mb-10 text-primary drop-shadow-[0_0_10px_var(--color-primary)]">₹2499<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)]">₹599<span className="text-xl text-foreground font-medium">/mo</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]" /> Unlimited Dynamic CBTs</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]" /> Adaptive AI DPPs</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]" /> Interactive AI Tutor (24/7)</li>
|
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-[#00F0FF]" /> 24/7 Streaming AI Tutor Chat</li>
|
||||||
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-[#00F0FF]" /> Predictive Rank Analytics</li>
|
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-[#00F0FF]" /> Predictive Rank & College Predictor</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button size="lg" className="w-full rounded-2xl h-16 font-bold text-lg bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_20px_-5px_var(--color-primary)] transition-all">Get AI Pro</Button>
|
<Button size="lg" className="w-full rounded-2xl h-16 font-bold text-lg bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_20px_-5px_var(--color-primary)] transition-all">Get AI Coach</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
217
src/app/parent/dashboard/page.tsx
Normal file
217
src/app/parent/dashboard/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||||
|
import { Activity, Clock, Flame, AlertTriangle, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
// Hardcoded mock UUIDs for this sprint
|
||||||
|
const PARENT_ID = "parent-123"
|
||||||
|
const STUDENT_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
|
||||||
|
const DICTIONARY = {
|
||||||
|
en: {
|
||||||
|
title: "Parent Monitoring Portal",
|
||||||
|
subtitle: "Real-time analytics for your child's learning journey.",
|
||||||
|
lang_toggle: "हिंदी",
|
||||||
|
time_spent: "Weekly Time Spent",
|
||||||
|
minutes: "Minutes",
|
||||||
|
streak: "Active Streak",
|
||||||
|
days: "Days",
|
||||||
|
tests_attempted: "Tests Attempted",
|
||||||
|
tests: "This week",
|
||||||
|
accuracy_trend: "7-Day Accuracy Trend",
|
||||||
|
areas_of_concern: "Areas of Concern",
|
||||||
|
attention_needed: "Attention Needed",
|
||||||
|
pending_errors: "pending errors",
|
||||||
|
loading: "Loading child data...",
|
||||||
|
no_concerns: "No current areas of concern. Great job!"
|
||||||
|
},
|
||||||
|
hi: {
|
||||||
|
title: "अभिभावक निगरानी पोर्टल",
|
||||||
|
subtitle: "आपके बच्चे की सीखने की यात्रा के लिए वास्तविक समय विश्लेषिकी।",
|
||||||
|
lang_toggle: "English",
|
||||||
|
time_spent: "साप्ताहिक समय व्यतीत",
|
||||||
|
minutes: "मिनट",
|
||||||
|
streak: "सक्रिय लकीर",
|
||||||
|
days: "दिन",
|
||||||
|
tests_attempted: "परीक्षण का प्रयास",
|
||||||
|
tests: "इस सप्ताह",
|
||||||
|
accuracy_trend: "7-दिवसीय सटीकता प्रवृत्ति",
|
||||||
|
areas_of_concern: "चिंता के विषय",
|
||||||
|
attention_needed: "ध्यान देने की आवश्यकता है",
|
||||||
|
pending_errors: "लंबित त्रुटियां",
|
||||||
|
loading: "बच्चे का डेटा लोड हो रहा है...",
|
||||||
|
no_concerns: "चिंता का कोई विषय नहीं है। बहुत बढ़िया!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParentDashboard() {
|
||||||
|
const [lang, setLang] = useState<'en' | 'hi'>('en')
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const t = DICTIONARY[lang]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDashboard() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/parent/${PARENT_ID}/dashboard/${STUDENT_ID}`)
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
|
const payload = await res.json()
|
||||||
|
setData(payload)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
// Fallback mock data if server fails
|
||||||
|
setData({
|
||||||
|
student_name: "Rahul",
|
||||||
|
streak_count: 5,
|
||||||
|
weekly_minutes_spent: 124.5,
|
||||||
|
total_tests_attempted_this_week: 3,
|
||||||
|
accuracy_trend: [
|
||||||
|
{ date: "Jul 01", accuracy: 40 },
|
||||||
|
{ date: "Jul 02", accuracy: 55 },
|
||||||
|
{ date: "Jul 03", accuracy: 50 },
|
||||||
|
{ date: "Jul 04", accuracy: 70 },
|
||||||
|
{ date: "Jul 05", accuracy: 65 },
|
||||||
|
{ date: "Jul 06", accuracy: 80 },
|
||||||
|
{ date: "Jul 07", accuracy: 85 }
|
||||||
|
],
|
||||||
|
areas_of_concern: [
|
||||||
|
{ topic: "Thermodynamics", pending_errors: 4 },
|
||||||
|
{ topic: "Integration", pending_errors: 2 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchDashboard()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<RefreshCw className="animate-spin text-muted-foreground mr-3" /> {t.loading}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl mx-auto space-y-10 animate-in fade-in duration-500">
|
||||||
|
|
||||||
|
{/* Header & Language Toggle */}
|
||||||
|
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-border pb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-primary">
|
||||||
|
{t.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 font-medium">{t.subtitle} — {data?.student_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Label htmlFor="lang-switch" className="font-bold text-muted-foreground">{lang === 'hi' ? 'English' : 'हिंदी'}</Label>
|
||||||
|
<Switch
|
||||||
|
id="lang-switch"
|
||||||
|
checked={lang === 'hi'}
|
||||||
|
onCheckedChange={(c) => setLang(c ? 'hi' : 'en')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Metric Blocks */}
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card className="border-border shadow-sm bg-secondary/10">
|
||||||
|
<CardContent className="p-6 flex items-center gap-5">
|
||||||
|
<div className="p-4 bg-primary/10 rounded-2xl text-primary"><Clock size={28}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider">{t.time_spent}</p>
|
||||||
|
<h3 className="text-3xl font-black mt-1">{data?.weekly_minutes_spent} <span className="text-sm font-semibold text-muted-foreground lowercase">{t.minutes}</span></h3>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-border shadow-sm bg-secondary/10">
|
||||||
|
<CardContent className="p-6 flex items-center gap-5">
|
||||||
|
<div className="p-4 bg-amber-500/10 rounded-2xl text-amber-500"><Flame size={28}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider">{t.streak}</p>
|
||||||
|
<h3 className="text-3xl font-black mt-1">{data?.streak_count} <span className="text-sm font-semibold text-muted-foreground lowercase">{t.days}</span></h3>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-border shadow-sm bg-secondary/10">
|
||||||
|
<CardContent className="p-6 flex items-center gap-5">
|
||||||
|
<div className="p-4 bg-emerald-500/10 rounded-2xl text-emerald-500"><Activity size={28}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider">{t.tests_attempted}</p>
|
||||||
|
<h3 className="text-3xl font-black mt-1">{data?.total_tests_attempted_this_week} <span className="text-sm font-semibold text-muted-foreground lowercase">{t.tests}</span></h3>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<Card className="lg:col-span-2 border-border shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{t.accuracy_trend}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data?.accuracy_trend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" opacity={0.2} />
|
||||||
|
<XAxis dataKey="date" stroke="#888" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis stroke="#888" fontSize={12} tickLine={false} axisLine={false} domain={[0, 100]} tickFormatter={(val) => `${val}%`} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'hsl(var(--background))', borderColor: 'hsl(var(--border))', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: 'hsl(var(--primary))' }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="accuracy"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth={4}
|
||||||
|
dot={{ r: 4, fill: 'hsl(var(--primary))' }}
|
||||||
|
activeDot={{ r: 8 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Areas of Concern */}
|
||||||
|
<Card className="border-border shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<AlertTriangle className="text-red-500" size={20} />
|
||||||
|
{t.areas_of_concern}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!data?.areas_of_concern || data.areas_of_concern.length === 0 ? (
|
||||||
|
<div className="text-center p-6 bg-secondary/20 rounded-xl">
|
||||||
|
<p className="text-muted-foreground font-medium">{t.no_concerns}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.areas_of_concern.map((concern: any, idx: number) => (
|
||||||
|
<div key={idx} className="p-4 rounded-xl border border-red-500/20 bg-red-500/5 relative overflow-hidden group">
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-red-500 rounded-l-xl"></div>
|
||||||
|
<h4 className="font-bold text-foreground mb-1">{concern.topic}</h4>
|
||||||
|
<p className="text-xs font-semibold text-red-500 uppercase tracking-wider">
|
||||||
|
{t.attention_needed} • {concern.pending_errors} {t.pending_errors}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,13 +2,24 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { ArrowLeft, Eye, EyeOff } from "lucide-react"
|
import { ArrowLeft, Eye, EyeOff, CheckCircle2 } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [authMethod, setAuthMethod] = useState<"email" | "phone">("email")
|
||||||
|
const [showOTP, setShowOTP] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleNextStep = () => {
|
||||||
|
if (step === 1) setStep(2)
|
||||||
|
else router.push("/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-6 selection:bg-primary/20">
|
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-6 selection:bg-primary/20">
|
||||||
<Link href="/" className="absolute top-8 left-8 text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors font-medium">
|
<Link href="/" className="absolute top-8 left-8 text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors font-medium">
|
||||||
@@ -29,85 +40,149 @@ export default function SignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex justify-between text-xs font-bold text-muted-foreground">
|
||||||
|
<span className={step >= 1 ? "text-primary" : ""}>Account Details</span>
|
||||||
|
<span className={step >= 2 ? "text-primary" : ""}>Personalize Profile</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-full transition-all duration-500 ease-in-out"
|
||||||
|
style={{ width: step === 1 ? '50%' : '100%' }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="border-border bg-secondary/10 backdrop-blur-xl shadow-[0_0_50px_-20px_var(--color-primary)] rounded-[2rem] overflow-hidden">
|
<Card className="border-border bg-secondary/10 backdrop-blur-xl shadow-[0_0_50px_-20px_var(--color-primary)] rounded-[2rem] overflow-hidden">
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<CardTitle className="text-2xl font-bold tracking-tight">Create an account</CardTitle>
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
<CardDescription className="text-muted-foreground">Start your AI-powered preparation journey today.</CardDescription>
|
{step === 1 ? "Create an account" : "Complete your profile"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-muted-foreground">
|
||||||
|
{step === 1 ? "Start your AI-powered preparation journey today." : "Help us personalize your study plan."}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 pt-4">
|
<CardContent className="space-y-6 pt-4">
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{step === 1 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4 animate-in fade-in zoom-in-95">
|
||||||
<label className="text-sm font-bold text-foreground">First Name</label>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<input
|
<div className="space-y-2">
|
||||||
type="text"
|
<label className="text-sm font-bold text-foreground">First Name</label>
|
||||||
placeholder="John"
|
<input type="text" placeholder="John" className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"/>
|
||||||
className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
</div>
|
||||||
/>
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-foreground">Last Name</label>
|
||||||
|
<input type="text" placeholder="Doe" className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex bg-secondary p-1 rounded-xl">
|
||||||
|
<button type="button" onClick={() => setAuthMethod("email")} className={`flex-1 text-sm font-bold py-2 rounded-lg transition-all ${authMethod === "email" ? "bg-background shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground"}`}>Email</button>
|
||||||
|
<button type="button" onClick={() => setAuthMethod("phone")} className={`flex-1 text-sm font-bold py-2 rounded-lg transition-all ${authMethod === "phone" ? "bg-background shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground"}`}>Phone</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-foreground">Last Name</label>
|
<label className="text-sm font-bold text-foreground">{authMethod === "email" ? "Email Address" : "Phone Number"}</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="text"
|
<input type={authMethod === "email" ? "email" : "tel"} placeholder={authMethod === "email" ? "student@example.com" : "+91 9876543210"} className="flex-1 bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"/>
|
||||||
placeholder="Doe"
|
{!showOTP && (
|
||||||
className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
<Button variant="secondary" onClick={() => setShowOTP(true)} className="h-[46px] rounded-xl font-bold">Send OTP</Button>
|
||||||
/>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showOTP && (
|
||||||
|
<div className="space-y-2 animate-in slide-in-from-top-2">
|
||||||
|
<label className="text-sm font-bold text-foreground flex justify-between">
|
||||||
|
<span>Verification Code</span>
|
||||||
|
<span className="text-primary text-xs">Resend in 30s</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1,2,3,4,5,6].map((i) => (
|
||||||
|
<input key={i} type="text" maxLength={1} className="w-12 h-12 text-center text-lg font-bold bg-background border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-foreground">Password (Optional if using OTP)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type={showPassword ? "text" : "password"} placeholder="••••••••" className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all pr-10"/>
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
<label className="text-sm font-bold text-foreground">Email</label>
|
<div className="space-y-4 animate-in fade-in slide-in-from-right-8">
|
||||||
<input
|
<div className="space-y-2">
|
||||||
type="email"
|
<label className="text-sm font-bold text-foreground">Target Exam</label>
|
||||||
placeholder="student@example.com"
|
<select className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all appearance-none cursor-pointer">
|
||||||
className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
<option value="jee_main">JEE Main</option>
|
||||||
/>
|
<option value="jee_adv">JEE Advanced (Main + Adv)</option>
|
||||||
|
<option value="neet">NEET UG</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-foreground">Current Class</label>
|
||||||
|
<select className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all appearance-none cursor-pointer">
|
||||||
|
<option value="11">Class 11</option>
|
||||||
|
<option value="12">Class 12</option>
|
||||||
|
<option value="dropper">Dropper / 12th Pass</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-foreground">Study Medium</label>
|
||||||
|
<select className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all appearance-none cursor-pointer">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="hi">Hindi (हिंदी)</option>
|
||||||
|
<option value="bi">Bilingual (English + Hindi)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-foreground">School / Coaching Name <span className="text-muted-foreground font-normal">(Optional)</span></label>
|
||||||
|
<input type="text" placeholder="e.g. DPS R.K. Puram" className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
)}
|
||||||
<label className="text-sm font-bold text-foreground">Password</label>
|
|
||||||
|
<Button onClick={handleNextStep} className="w-full rounded-xl h-12 font-bold text-base bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-[0_0_20px_-5px_var(--color-primary)]">
|
||||||
|
{step === 1 ? "Continue" : "Complete Setup"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<div className="absolute inset-0 flex items-center">
|
||||||
type={showPassword ? "text" : "password"}
|
<span className="w-full border-t border-border"></span>
|
||||||
placeholder="••••••••"
|
</div>
|
||||||
className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all pr-10"
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
/>
|
<span className="bg-background/80 px-2 text-muted-foreground font-bold tracking-widest backdrop-blur-md">Or continue with</span>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/dashboard" className="block">
|
<Button variant="outline" className="w-full rounded-xl h-12 font-bold bg-background border-border hover:bg-secondary/50 transition-all flex items-center justify-center gap-2">
|
||||||
<Button className="w-full rounded-xl h-12 font-bold text-base bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-[0_0_20px_-5px_var(--color-primary)]">
|
<svg viewBox="0 0 24 24" className="h-5 w-5"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>
|
||||||
Create Account
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</>
|
||||||
|
)}
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-border"></span>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background/80 px-2 text-muted-foreground font-bold tracking-widest backdrop-blur-md">Or continue with</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/dashboard" className="block">
|
|
||||||
<Button variant="outline" className="w-full rounded-xl h-12 font-bold bg-background border-border hover:bg-secondary/50 transition-all flex items-center justify-center gap-2">
|
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>
|
|
||||||
Google
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground font-medium">
|
{step === 1 ? (
|
||||||
Already have an account? <Link href="/login" className="font-bold text-foreground hover:text-primary transition-colors">Log in</Link>
|
<p className="text-center text-sm text-muted-foreground font-medium">
|
||||||
</p>
|
Already have an account? <Link href="/login" className="font-bold text-foreground hover:text-primary transition-colors">Log in</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setStep(1)} className="w-full text-center text-sm font-bold text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
226
src/app/upgrade/page.tsx
Normal file
226
src/app/upgrade/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Check, X, ShieldCheck, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
const WEBHOOK_SECRET = "local_webhook_secret"
|
||||||
|
|
||||||
|
export default function UpgradePage() {
|
||||||
|
const [loadingTier, setLoadingTier] = useState<string | null>(null)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [orderData, setOrderData] = useState<any>(null)
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const handleUpgrade = async (tier: string) => {
|
||||||
|
setLoadingTier(tier)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/billing/create-order?user_id=${USER_ID}&plan_tier=${tier}`, {
|
||||||
|
method: "POST"
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Order creation failed")
|
||||||
|
const order = await res.json()
|
||||||
|
setOrderData({ ...order, tier })
|
||||||
|
setShowModal(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert("Failed to create order")
|
||||||
|
} finally {
|
||||||
|
setLoadingTier(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateMockSignature = async (payloadStr: string) => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(WEBHOOK_SECRET),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payloadStr));
|
||||||
|
return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulatePayment = async () => {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
const payloadObj = {
|
||||||
|
event: "payment.captured",
|
||||||
|
payload: {
|
||||||
|
payment: {
|
||||||
|
entity: {
|
||||||
|
id: `pay_mock_${Date.now()}`,
|
||||||
|
amount: orderData.amount,
|
||||||
|
notes: {
|
||||||
|
user_id: USER_ID,
|
||||||
|
tier_requested: orderData.tier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadStr = JSON.stringify(payloadObj)
|
||||||
|
const signature = await generateMockSignature(payloadStr)
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:8000/api/billing/webhook/razorpay`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Razorpay-Signature": signature
|
||||||
|
},
|
||||||
|
body: payloadStr
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Webhook failed")
|
||||||
|
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = "/dashboard"
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert("Payment simulation failed")
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto animate-in fade-in duration-500 min-h-screen">
|
||||||
|
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight mb-4">Upgrade Your Preparation</h1>
|
||||||
|
<p className="text-xl text-muted-foreground">Unlock the full power of PragatiVeer to master JEE & NEET.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
|
||||||
|
{/* Free Tier */}
|
||||||
|
<Card className="border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Free</CardTitle>
|
||||||
|
<CardDescription>Basic access for everyone.</CardDescription>
|
||||||
|
<div className="mt-4 font-black text-4xl">₹0<span className="text-xl text-muted-foreground font-normal">/mo</span></div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Subject-wise diagnostics</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Basic progress tracking</div>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground opacity-50"><X size={18}/> Unlimited Tests</div>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground opacity-50"><X size={18}/> AI Spaced Repetition</div>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground opacity-50"><X size={18}/> Parent Analytics</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="outline" className="w-full" disabled>Current Plan</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pro Tier */}
|
||||||
|
<Card className="border-border border-primary shadow-lg shadow-primary/10 relative">
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary text-primary-foreground px-3 py-1 rounded-full text-xs font-bold tracking-wider uppercase">Most Popular</div>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl text-primary">Pro</CardTitle>
|
||||||
|
<CardDescription>For serious aspirants.</CardDescription>
|
||||||
|
<div className="mt-4 font-black text-4xl">₹299<span className="text-xl text-muted-foreground font-normal">/mo</span></div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Everything in Free</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Unlimited Sectional Tests</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Performance Analytics</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Parent Dashboard</div>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground opacity-50"><X size={18}/> AI Mistake Heatmap</div>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground opacity-50"><X size={18}/> Spaced Repetition (SM-2)</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleUpgrade("Pro")}
|
||||||
|
disabled={!!loadingTier}
|
||||||
|
>
|
||||||
|
{loadingTier === "Pro" ? <Loader2 className="animate-spin" /> : "Upgrade to Pro"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AI Coach Tier */}
|
||||||
|
<Card className="border-border bg-gradient-to-b from-primary/10 to-background border-primary">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl flex items-center gap-2">AI Coach <ShieldCheck size={24} className="text-primary"/></CardTitle>
|
||||||
|
<CardDescription>Personalized mastery loop.</CardDescription>
|
||||||
|
<div className="mt-4 font-black text-4xl">₹599<span className="text-xl text-muted-foreground font-normal">/mo</span></div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Everything in Pro</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Daily AI Error Review Queue</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> SM-2 Spaced Repetition</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> Mistake Heatmap Tracking</div>
|
||||||
|
<div className="flex items-center gap-3"><Check className="text-green-500" size={18}/> AI Chat Tutor Support</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
className="w-full font-bold"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => handleUpgrade("Al_Coach")}
|
||||||
|
disabled={!!loadingTier}
|
||||||
|
>
|
||||||
|
{loadingTier === "Al_Coach" ? <Loader2 className="animate-spin" /> : "Get AI Coach"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mock Razorpay Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<Card className="max-w-md w-full shadow-2xl border-primary">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-blue-600 rounded-lg flex items-center justify-center mb-4">
|
||||||
|
<span className="text-white font-black text-2xl">RZP</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle>Mock Checkout Window</CardTitle>
|
||||||
|
<CardDescription>Razorpay Test Sandbox Simulation</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 mt-4">
|
||||||
|
<div className="bg-secondary p-4 rounded-xl space-y-2 text-sm font-mono">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Order ID:</span>
|
||||||
|
<span className="font-bold">{orderData?.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Amount:</span>
|
||||||
|
<span className="font-bold">₹{(orderData?.amount / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Tier:</span>
|
||||||
|
<span className="font-bold">{orderData?.tier}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<div className="p-4 bg-green-500/10 text-green-500 rounded-xl text-center font-bold animate-in zoom-in">
|
||||||
|
Payment Captured! Redirecting...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Clicking confirm will securely sign and POST a payment.captured webhook to the backend.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex gap-3">
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => setShowModal(false)} disabled={processing || success}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={simulatePayment} disabled={processing || success}>
|
||||||
|
{processing ? <Loader2 className="animate-spin" /> : "Confirm Payment"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
src/components/AI/AITutorChat.tsx
Normal file
138
src/components/AI/AITutorChat.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Send, Bot, User, Minimize2, Maximize2, X } from "lucide-react"
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkMath from 'remark-math'
|
||||||
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import 'katex/dist/katex.min.css' // Import Katex CSS for math rendering
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_MESSAGES: Message[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
role: 'ai',
|
||||||
|
content: "Hi! I noticed you struggled with **Rotational Dynamics** in the last test. Let's look at a similar problem. \n\nA solid sphere rolls without slipping. The kinetic energy is $E = \\frac{1}{2}mv^2 + \\frac{1}{2}I\\omega^2$. \n\nWhat fraction of total kinetic energy is rotational?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
role: 'user',
|
||||||
|
content: "I think it is $\\frac{2}{7}$?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
role: 'ai',
|
||||||
|
content: "Excellent! Yes, because $I = \\frac{2}{5}mr^2$, the rotational kinetic energy is:\n\n$$ K_{rot} = \\frac{1}{2}(\\frac{2}{5}mr^2)(\\frac{v}{r})^2 = \\frac{1}{5}mv^2 $$\n\nTotal $K = \\frac{1}{2}mv^2 + \\frac{1}{5}mv^2 = \\frac{7}{10}mv^2$.\n\nSo, $\\frac{K_{rot}}{K_{total}} = \\frac{2/10}{7/10} = \\frac{2}{7}$. You nailed it!"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AITutorChat() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input };
|
||||||
|
setMessages([...messages, newMessage]);
|
||||||
|
setInput('');
|
||||||
|
|
||||||
|
// Mock AI response
|
||||||
|
setTimeout(() => {
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'ai',
|
||||||
|
content: "That's an interesting point! Let's explore the math behind it: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$"
|
||||||
|
}]);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-[0_0_20px_-5px_var(--color-primary)] flex items-center justify-center z-50 bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 transition-transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<Bot size={28} />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`fixed right-0 md:right-6 bottom-0 md:bottom-6 border-border bg-background/95 backdrop-blur-xl shadow-2xl z-50 flex flex-col overflow-hidden transition-all duration-300 ease-in-out
|
||||||
|
${isExpanded ? 'w-full md:w-[600px] h-full md:h-[80vh] rounded-none md:rounded-2xl' : 'w-full md:w-[400px] h-[600px] rounded-t-2xl md:rounded-2xl'}
|
||||||
|
`}>
|
||||||
|
<CardHeader className="p-4 border-b border-border bg-secondary/30 flex flex-row items-center justify-between space-y-0 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary/20 text-primary rounded-lg border border-primary/30">
|
||||||
|
<Bot size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-bold text-foreground">AI Coach</CardTitle>
|
||||||
|
<p className="text-xs font-medium text-green-500 flex items-center gap-1">
|
||||||
|
<span className="size-2 bg-green-500 rounded-full animate-pulse"></span> Online
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
{isExpanded ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={() => setIsOpen(false)}>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 p-4 overflow-y-auto space-y-4">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={`flex gap-3 max-w-[90%] ${msg.role === 'user' ? 'ml-auto flex-row-reverse' : 'mr-auto'}`}>
|
||||||
|
<div className={`shrink-0 h-8 w-8 rounded-full flex items-center justify-center border
|
||||||
|
${msg.role === 'user' ? 'bg-background text-foreground border-border' : 'bg-primary text-primary-foreground border-primary'}
|
||||||
|
`}>
|
||||||
|
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Markdown Container with Overflow Handling */}
|
||||||
|
<div className={`px-4 py-3 rounded-2xl overflow-x-auto
|
||||||
|
${msg.role === 'user' ? 'bg-secondary/50 border border-border text-foreground rounded-tr-sm' : 'bg-primary/10 border border-primary/20 text-foreground rounded-tl-sm'}
|
||||||
|
`}>
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none
|
||||||
|
prose-p:leading-relaxed prose-pre:bg-secondary prose-pre:border prose-pre:border-border
|
||||||
|
[&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:pb-2 [&_.katex-display]:scrollbar-thin
|
||||||
|
[&_.katex]:text-[1.1em]
|
||||||
|
">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
|
{msg.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="p-4 border-t border-border bg-background shrink-0">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }} className="flex w-full items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Ask your AI Coach..."
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
className="flex-1 bg-secondary/30 border-border focus-visible:ring-primary h-12 rounded-xl"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="icon" className="h-12 w-12 rounded-xl shrink-0 shadow-sm" disabled={!input.trim()}>
|
||||||
|
<Send size={18} className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/components/AI/AdaptiveStudyPlan.tsx
Normal file
103
src/components/AI/AdaptiveStudyPlan.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { CheckCircle2, Clock, SkipForward, BrainCircuit } from "lucide-react"
|
||||||
|
|
||||||
|
type PlanState = 'PENDING' | 'DONE' | 'SKIPPED';
|
||||||
|
|
||||||
|
interface PlanItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
duration: string;
|
||||||
|
type: string;
|
||||||
|
status: PlanState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_PLAN: PlanItem[] = [
|
||||||
|
{ id: '1', title: 'Revise Thermodynamics Concepts', duration: '45 min', type: 'Theory', status: 'PENDING' },
|
||||||
|
{ id: '2', title: 'Solve 15 Rotational Dynamics DPPs', duration: '30 min', type: 'Practice', status: 'PENDING' },
|
||||||
|
{ id: '3', title: 'Review Mistakes in Optics (T-3)', duration: '20 min', type: 'Review', status: 'PENDING' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdaptiveStudyPlan() {
|
||||||
|
const [items, setItems] = useState<PlanItem[]>(INITIAL_PLAN);
|
||||||
|
|
||||||
|
const updateStatus = (id: string, newStatus: PlanState) => {
|
||||||
|
setItems(items.map(item => item.id === id ? { ...item, status: newStatus } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalTime = items.reduce((acc, curr) => {
|
||||||
|
if (curr.status === 'DONE' || curr.status === 'PENDING') {
|
||||||
|
return acc + parseInt(curr.duration);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border bg-background shadow-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-4 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<BrainCircuit className="text-primary" /> Adaptive Study Plan
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 bg-secondary/50 px-3 py-1 rounded-full text-xs font-bold text-foreground">
|
||||||
|
<Clock size={14} className="text-primary" /> Today: {totalTime} min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Dynamically generated based on your weak areas.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 p-6">
|
||||||
|
<div className="relative space-y-6 before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-border before:to-transparent">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isDone = item.status === 'DONE';
|
||||||
|
const isSkipped = item.status === 'SKIPPED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
|
||||||
|
{/* Timeline Icon */}
|
||||||
|
<div className={`flex items-center justify-center w-10 h-10 rounded-full border-4 border-background shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 shadow-sm z-10 transition-colors
|
||||||
|
${isDone ? 'bg-green-500 text-white' : isSkipped ? 'bg-muted border-muted text-muted-foreground' : 'bg-primary/20 text-primary border-primary/30'}
|
||||||
|
`}>
|
||||||
|
{isDone ? <CheckCircle2 size={18} /> : isSkipped ? <SkipForward size={18} /> : <div className="size-2.5 rounded-full bg-primary animate-pulse" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 rounded-xl border border-border bg-background shadow-sm transition-all hover:shadow-md">
|
||||||
|
<div className={`flex items-center justify-between mb-1 ${isSkipped ? 'opacity-50' : ''}`}>
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider text-muted-foreground bg-secondary/50 px-2 py-0.5 rounded-md">
|
||||||
|
{item.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-primary">{item.duration}</span>
|
||||||
|
</div>
|
||||||
|
<h4 className={`font-bold text-sm md:text-base leading-tight mb-3 transition-colors ${isDone ? 'line-through text-muted-foreground' : isSkipped ? 'text-muted-foreground' : 'text-foreground'}`}>
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{item.status === 'PENDING' ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={() => updateStatus(item.id, 'DONE')} className="h-8 text-xs font-bold flex-1 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white border border-green-500/20">
|
||||||
|
Mark Done
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => updateStatus(item.id, 'SKIPPED')} className="h-8 text-xs font-bold px-3 hover:bg-destructive/10 hover:text-destructive">
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex items-center justify-center py-1 bg-secondary/50 rounded-lg text-xs font-bold text-muted-foreground">
|
||||||
|
{isDone ? 'Completed' : 'Rescheduled'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/components/AI/CollegePredictorCard.tsx
Normal file
84
src/components/AI/CollegePredictorCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { GraduationCap, MapPin, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
|
||||||
|
export function CollegePredictorCard() {
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchReadiness() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
|
const json = await res.json()
|
||||||
|
setData(json)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Using mock fallback data for College Predictor.")
|
||||||
|
setData({
|
||||||
|
college_prediction: "NIT Trichy / NIT Surathkal on track.",
|
||||||
|
prediction_category: "Target"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchReadiness()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm h-full flex items-center justify-center min-h-[150px]">
|
||||||
|
<RefreshCw className="animate-spin text-muted-foreground" />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prediction = data?.college_prediction || "Keep Practicing"
|
||||||
|
const category = data?.prediction_category || "State Level"
|
||||||
|
|
||||||
|
const getCategoryColor = (cat: string) => {
|
||||||
|
switch(cat.toUpperCase()) {
|
||||||
|
case "REACH": return "bg-purple-500/10 text-purple-600 border-purple-500/20"
|
||||||
|
case "TARGET": return "bg-blue-500/10 text-blue-600 border-blue-500/20"
|
||||||
|
case "SAFETY": return "bg-green-500/10 text-green-600 border-green-500/20"
|
||||||
|
default: return "bg-secondary text-muted-foreground border-transparent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm h-full flex flex-col bg-gradient-to-br from-background to-secondary/30 relative overflow-hidden">
|
||||||
|
<div className="absolute -right-12 -top-12 opacity-5 pointer-events-none">
|
||||||
|
<GraduationCap size={150} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-xl flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="text-primary" size={24} />
|
||||||
|
Admissions Radar
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border ${getCategoryColor(category)}`}>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>AI-projected admission trajectory.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 flex flex-col justify-center">
|
||||||
|
<div className="p-5 bg-background rounded-xl border border-border shadow-sm mt-4">
|
||||||
|
<p className="font-semibold text-lg leading-snug">
|
||||||
|
"{prediction}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-4 font-medium flex items-center gap-1 opacity-70">
|
||||||
|
<RefreshCw size={10} /> Prediction updates dynamically after every test.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/components/AI/MistakeHeatmap.tsx
Normal file
80
src/components/AI/MistakeHeatmap.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Flame, Info } from "lucide-react"
|
||||||
|
|
||||||
|
const HEATMAP_DATA = [
|
||||||
|
{ topic: "Rotational Dynamics", attempts: [80, 90, 40, 20, 10] }, // 10 is critical error rate
|
||||||
|
{ topic: "Electromagnetism", attempts: [30, 25, 20, 15, 10] },
|
||||||
|
{ topic: "Thermodynamics", attempts: [5, 10, 5, 2, 0] },
|
||||||
|
{ topic: "Optics", attempts: [60, 50, 45, 60, 55] },
|
||||||
|
{ topic: "Modern Physics", attempts: [10, 5, 2, 0, 0] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MistakeHeatmap() {
|
||||||
|
const getColor = (errorRate: number) => {
|
||||||
|
if (errorRate >= 50) return 'bg-red-500/80 border-red-500 shadow-[0_0_10px_-2px_rgba(239,68,68,0.5)]'; // Critical
|
||||||
|
if (errorRate >= 30) return 'bg-amber-500/60 border-amber-500'; // Warning
|
||||||
|
if (errorRate >= 10) return 'bg-yellow-500/30 border-yellow-500/50'; // Moderate
|
||||||
|
return 'bg-green-500/20 border-green-500/30'; // Good
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border bg-background shadow-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Flame className="text-orange-500" /> Mistake Heatmap
|
||||||
|
</CardTitle>
|
||||||
|
<Info size={16} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Topic-wise error rate across last 5 tests.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[400px]">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex mb-2">
|
||||||
|
<div className="w-40 shrink-0 text-xs font-bold text-muted-foreground uppercase tracking-wider">Topic</div>
|
||||||
|
<div className="flex-1 flex justify-between text-xs font-bold text-muted-foreground uppercase tracking-wider text-center">
|
||||||
|
<span>T-4</span>
|
||||||
|
<span>T-3</span>
|
||||||
|
<span>T-2</span>
|
||||||
|
<span>T-1</span>
|
||||||
|
<span>Latest</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data rows */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{HEATMAP_DATA.map((row, idx) => (
|
||||||
|
<div key={idx} className="flex items-center group">
|
||||||
|
<div className="w-40 shrink-0 text-sm font-medium truncate pr-2 group-hover:text-primary transition-colors">
|
||||||
|
{row.topic}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex justify-between gap-1">
|
||||||
|
{row.attempts.map((errRate, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex-1 h-8 rounded-md border transition-all duration-300 group-hover:scale-105 flex items-center justify-center cursor-help relative ${getColor(errRate)}`}
|
||||||
|
title={`Error Rate: ${errRate}%`}
|
||||||
|
>
|
||||||
|
<span className="opacity-0 group-hover:opacity-100 text-[10px] font-bold text-foreground absolute">{errRate}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-4 text-xs font-medium text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-green-500/20 border border-green-500/30"></div> {"< 10%"}</div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-yellow-500/30 border border-yellow-500/50"></div> {"10-30%"}</div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-amber-500/60 border border-amber-500"></div> {"30-50%"}</div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-red-500/80 border border-red-500"></div> {"> 50%"}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
src/components/AI/MistakeReviewCard.tsx
Normal file
169
src/components/AI/MistakeReviewCard.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { CheckCircle2, XCircle, BrainCircuit } from "lucide-react"
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkMath from 'remark-math'
|
||||||
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
|
export interface MistakePayload {
|
||||||
|
mistake: {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
question_id: string
|
||||||
|
error_type: string
|
||||||
|
topic: string
|
||||||
|
subtopic: string
|
||||||
|
review_count: number
|
||||||
|
consecutive_correct: number
|
||||||
|
is_mastered: boolean
|
||||||
|
}
|
||||||
|
question: {
|
||||||
|
id: string
|
||||||
|
subject: string
|
||||||
|
question_text: string
|
||||||
|
options: Record<string, string>
|
||||||
|
correct_answer: { correct: string }
|
||||||
|
solution_text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistakeReviewCardProps {
|
||||||
|
data: MistakePayload
|
||||||
|
onNext: () => void
|
||||||
|
onSubmitReview: (mistakeId: string, selectedAnswer: string) => Promise<{is_correct: boolean, correct_answer: string}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MistakeReviewCard({ data, onNext, onSubmitReview }: MistakeReviewCardProps) {
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const [result, setResult] = useState<'success' | 'error' | null>(null)
|
||||||
|
const [correctAnswer, setCorrectAnswer] = useState<string | null>(null)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const handleCheck = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await onSubmitReview(data.mistake.id, selected)
|
||||||
|
setResult(res.is_correct ? 'success' : 'error')
|
||||||
|
setCorrectAnswer(res.correct_answer)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setSelected(null)
|
||||||
|
setResult(null)
|
||||||
|
setCorrectAnswer(null)
|
||||||
|
onNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mistake, question } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm overflow-hidden flex flex-col">
|
||||||
|
<div className="p-4 border-b border-border bg-secondary/20 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="text-muted-foreground border-primary/20 bg-primary/5 text-primary">
|
||||||
|
Attempt {mistake.review_count + 1}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">{mistake.topic} • {mistake.subtopic}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<BrainCircuit size={14} className={mistake.consecutive_correct > 0 ? "text-amber-500" : ""} />
|
||||||
|
Consecutive Correct: <span className="font-bold text-foreground">{mistake.consecutive_correct}/3</span> before mastery
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none text-base md:text-lg mb-8 font-medium">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
|
{question.question_text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-auto">
|
||||||
|
{Object.entries(question.options).map(([letter, text]) => {
|
||||||
|
const isSelected = selected === letter
|
||||||
|
|
||||||
|
let btnClass = "border-border bg-background hover:border-primary/50 hover:bg-secondary/20"
|
||||||
|
let iconClass = "border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary"
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Post-check styling
|
||||||
|
if (letter === correctAnswer) {
|
||||||
|
btnClass = "border-green-500 bg-green-500/10 shadow-[0_0_15px_-5px_#22c55e]"
|
||||||
|
iconClass = "border-green-500 bg-green-500 text-white"
|
||||||
|
} else if (isSelected && letter !== correctAnswer) {
|
||||||
|
btnClass = "border-red-500 bg-red-500/10"
|
||||||
|
iconClass = "border-red-500 bg-red-500 text-white"
|
||||||
|
}
|
||||||
|
} else if (isSelected) {
|
||||||
|
btnClass = "border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]"
|
||||||
|
iconClass = "border-primary bg-primary text-primary-foreground"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={letter}
|
||||||
|
onClick={() => !result && setSelected(letter)}
|
||||||
|
disabled={!!result}
|
||||||
|
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center gap-4 group ${btnClass}`}
|
||||||
|
>
|
||||||
|
<div className={`flex-shrink-0 size-8 rounded-lg border flex items-center justify-center font-medium transition-colors ${iconClass}`}>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-medium flex-1">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>{text}</ReactMarkdown>
|
||||||
|
</span>
|
||||||
|
{result && letter === correctAnswer && <CheckCircle2 className="text-green-500 ml-2" size={20} />}
|
||||||
|
{result && isSelected && letter !== correctAnswer && <XCircle className="text-red-500 ml-2" size={20} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
{!result ? (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={!selected || isSubmitting}
|
||||||
|
className="rounded-xl px-8 font-bold"
|
||||||
|
>
|
||||||
|
Check Answer
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={handleNext}
|
||||||
|
className="rounded-xl px-8 font-bold bg-secondary text-foreground hover:bg-secondary/80 border border-border"
|
||||||
|
>
|
||||||
|
Continue to Next
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solution Block */}
|
||||||
|
{result && (
|
||||||
|
<div className="mt-6 p-4 rounded-xl border border-primary/20 bg-primary/5 animate-in slide-in-from-top-2 fade-in">
|
||||||
|
<h4 className="text-sm font-bold text-primary mb-2 flex items-center gap-2"><BrainCircuit size={16}/> AI Coach Explanation</h4>
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||||
|
{question.solution_text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/components/AI/ProgressShare.tsx
Normal file
64
src/components/AI/ProgressShare.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Download, Image as ImageIcon, Loader2 } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export function ProgressShare() {
|
||||||
|
const [downloading, setDownloading] = useState(false)
|
||||||
|
|
||||||
|
// Example dynamic params
|
||||||
|
const streak = "12"
|
||||||
|
const accuracy = "85"
|
||||||
|
const name = "Student"
|
||||||
|
const imageUrl = `/api/og/progress?streak=${streak}&accuracy=${accuracy}&name=${name}`
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
setDownloading(true)
|
||||||
|
const res = await fetch(imageUrl)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.style.display = "none"
|
||||||
|
a.href = url
|
||||||
|
a.download = "PragatiVeer_Progress.png"
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to download image", err)
|
||||||
|
} finally {
|
||||||
|
setDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm h-full flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<ImageIcon className="text-primary" size={24} />
|
||||||
|
Share Progress
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Auto-generated dynamic scorecard.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||||
|
<div className="w-full max-w-sm rounded-xl overflow-hidden border border-border shadow-sm bg-secondary/20 aspect-[1200/630] relative">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Progress Scorecard"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleDownload} variant="outline" className="w-full" disabled={downloading}>
|
||||||
|
{downloading ? <Loader2 size={18} className="mr-2 animate-spin" /> : <Download size={18} className="mr-2" />}
|
||||||
|
Download as PNG
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/components/AI/ReadinessGauge.tsx
Normal file
120
src/components/AI/ReadinessGauge.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Activity, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
|
||||||
|
export function ReadinessGauge() {
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchReadiness() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
|
const json = await res.json()
|
||||||
|
setData(json)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Using mock fallback data for Readiness Gauge.")
|
||||||
|
setData({
|
||||||
|
score: 72,
|
||||||
|
breakdown: {
|
||||||
|
accuracy: 30,
|
||||||
|
speed: 15,
|
||||||
|
consistency: 8,
|
||||||
|
recovery: 5,
|
||||||
|
coverage: 14
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchReadiness()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm h-full flex items-center justify-center min-h-[300px]">
|
||||||
|
<RefreshCw className="animate-spin text-muted-foreground" />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = data?.score || 0
|
||||||
|
const breakdown = data?.breakdown || {}
|
||||||
|
const circumference = 2 * Math.PI * 45 // r=45
|
||||||
|
const strokeDashoffset = circumference - (score / 100) * circumference
|
||||||
|
|
||||||
|
const getColor = (s: number) => {
|
||||||
|
if (s >= 75) return "text-green-500"
|
||||||
|
if (s >= 50) return "text-yellow-500"
|
||||||
|
return "text-red-500"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Activity className="text-primary" size={24} />
|
||||||
|
Exam Readiness
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Real-time AI composite score.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col items-center flex-1 justify-center p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* SVG Circular Gauge */}
|
||||||
|
<div className="relative w-32 h-32 flex items-center justify-center">
|
||||||
|
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||||
|
{/* Background Ring */}
|
||||||
|
<circle cx="50" cy="50" r="45" stroke="currentColor" strokeWidth="8" fill="transparent" className="text-secondary" />
|
||||||
|
{/* Progress Ring */}
|
||||||
|
<circle
|
||||||
|
cx="50" cy="50" r="45"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
fill="transparent"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={`transition-all duration-1000 ease-out ${getColor(score)}`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute flex flex-col items-center justify-center">
|
||||||
|
<span className="text-3xl font-black">{score}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-bold">/ 100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breakdown Bars */}
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
{[
|
||||||
|
{ label: "Accuracy", value: breakdown.accuracy, max: 40 },
|
||||||
|
{ label: "Speed", value: breakdown.speed, max: 20 },
|
||||||
|
{ label: "Coverage", value: breakdown.coverage, max: 20 },
|
||||||
|
{ label: "Consistency", value: breakdown.consistency, max: 10 },
|
||||||
|
{ label: "Recovery", value: breakdown.recovery, max: 10 },
|
||||||
|
].map((item, idx) => (
|
||||||
|
<div key={idx} className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs font-semibold">
|
||||||
|
<span className="text-muted-foreground">{item.label}</span>
|
||||||
|
<span>{item.value} <span className="opacity-50">/ {item.max}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-1000 ease-out"
|
||||||
|
style={{ width: `${(item.value / item.max) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
src/components/AI/ReferralCard.tsx
Normal file
72
src/components/AI/ReferralCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Link as LinkIcon, Share2, Check, Gift } from "lucide-react"
|
||||||
|
|
||||||
|
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
|
||||||
|
export function ReferralCard() {
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchReferral() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/growth/referral/${USER_ID}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setData(json)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Using mock fallback data for Referral Card.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchReferral()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const link = data?.referral_link || "https://pragativeer.com/join/demo"
|
||||||
|
const message = data?.share_message || "Join me on PragatiVeer! Use my link to get 7 days of the AI Coach for free."
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(link)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareToWhatsApp = () => {
|
||||||
|
const waUrl = `https://wa.me/?text=${encodeURIComponent(message)}`
|
||||||
|
window.open(waUrl, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm h-full flex flex-col relative overflow-hidden bg-gradient-to-br from-background to-primary/5">
|
||||||
|
<div className="absolute -right-6 -bottom-6 opacity-10 pointer-events-none">
|
||||||
|
<Gift size={120} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Gift className="text-primary" size={24} />
|
||||||
|
Invite & Earn
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Give 7 days of AI Coach, get 7 days free.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 flex flex-col justify-end gap-4 z-10">
|
||||||
|
<div className="bg-secondary/50 border border-border p-3 rounded-lg flex items-center justify-between">
|
||||||
|
<code className="text-sm font-semibold truncate mr-4">{link}</code>
|
||||||
|
<Button variant="secondary" size="sm" onClick={copyToClipboard} className="shrink-0 rounded-md">
|
||||||
|
{copied ? <Check size={16} className="text-green-500" /> : <LinkIcon size={16} />}
|
||||||
|
<span className="ml-2 hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={shareToWhatsApp} className="w-full bg-[#25D366] hover:bg-[#1ebd5a] text-white">
|
||||||
|
<Share2 size={18} className="mr-2" /> Share via WhatsApp
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
src/components/AI/StudyPlanCard.tsx
Normal file
131
src/components/AI/StudyPlanCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { CalendarClock, CheckCircle2, Circle, Clock, Loader2, PlayCircle, RefreshCw, XCircle } from "lucide-react"
|
||||||
|
|
||||||
|
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
|
||||||
|
export function StudyPlanCard() {
|
||||||
|
const [tasks, setTasks] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchPlan() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/ai-coach/study-plan/today?user_id=${USER_ID}`)
|
||||||
|
if (!res.ok) throw new Error("No plan")
|
||||||
|
const data = await res.json()
|
||||||
|
setTasks(data.tasks || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
// Mock fallback if celery hasn't seeded yet
|
||||||
|
setTasks([
|
||||||
|
{ type: "Revision", description: "Revise Thermodynamics", duration_minutes: 15, status: "Pending" },
|
||||||
|
{ type: "Mistake_Review", description: "Daily Mistake Review Session", duration_minutes: 10, status: "Pending" },
|
||||||
|
{ type: "DPS", description: "Attempt Thermodynamics Practice Sheet", duration_minutes: 20, status: "Pending" }
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPlan()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTask = (idx: number, newStatus: string) => {
|
||||||
|
setTasks(prev => {
|
||||||
|
const copy = [...prev]
|
||||||
|
copy[idx] = { ...copy[idx], status: newStatus }
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<CalendarClock className="text-primary" size={24} />
|
||||||
|
Today's Plan
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
|
||||||
|
{tasks.reduce((acc, curr) => acc + curr.duration_minutes, 0)} min total
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>AI-generated curriculum targeting your weak spots.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<RefreshCw className="animate-spin text-muted-foreground mr-3" /> Loading schedule...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tasks.map((task, idx) => {
|
||||||
|
const isDone = task.status === "Done"
|
||||||
|
const isSkipped = task.status === "Skipped"
|
||||||
|
const isPending = task.status === "Pending"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`flex items-center p-4 rounded-xl border transition-all duration-300 ${
|
||||||
|
isDone ? "bg-green-500/5 border-green-500/20" :
|
||||||
|
isSkipped ? "bg-secondary/50 border-transparent opacity-60" :
|
||||||
|
"bg-background border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col flex-1 mr-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Badge variant="secondary" className="text-[10px] uppercase tracking-wider">{task.type}</Badge>
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock size={12}/> {task.duration_minutes} min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={`font-semibold text-sm transition-all duration-300 ${
|
||||||
|
isDone || isSkipped ? "line-through text-muted-foreground" : "text-foreground"
|
||||||
|
}`}>
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isPending && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTask(idx, "Skipped")}
|
||||||
|
className="p-2 rounded-full hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Skip Task"
|
||||||
|
>
|
||||||
|
<XCircle size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTask(idx, "Done")}
|
||||||
|
className="p-2 rounded-full hover:bg-green-500/10 text-muted-foreground hover:text-green-500 transition-colors"
|
||||||
|
title="Complete Task"
|
||||||
|
>
|
||||||
|
<Circle size={20} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(isDone || isSkipped) && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTask(idx, "Pending")}
|
||||||
|
className={`flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full ${isDone ? "bg-green-500 text-white" : "bg-secondary text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{isDone ? <CheckCircle2 size={14}/> : <PlayCircle size={14}/>}
|
||||||
|
{isDone ? "Completed" : "Resume"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/components/AI/TutorChat.tsx
Normal file
143
src/components/AI/TutorChat.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Send, Bot, User, Loader2 } from "lucide-react"
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
import remarkMath from "remark-math"
|
||||||
|
import rehypeKatex from "rehype-katex"
|
||||||
|
import "katex/dist/katex.min.css"
|
||||||
|
|
||||||
|
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TutorChat() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
|
{ role: "assistant", content: "Hello! I am your PragatiVeer AI Coach. What topic would you like to review today?" }
|
||||||
|
])
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const handleSend = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
if (!input.trim() || streaming) return
|
||||||
|
|
||||||
|
const userMsg = input.trim()
|
||||||
|
setInput("")
|
||||||
|
setMessages(prev => [...prev, { role: "user", content: userMsg }])
|
||||||
|
|
||||||
|
// Add empty assistant message placeholder
|
||||||
|
setMessages(prev => [...prev, { role: "assistant", content: "" }])
|
||||||
|
setStreaming(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("http://localhost:8000/api/ai-coach/chat/stream", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ user_id: USER_ID, message: userMsg })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Backend streaming failed. Using mock fallback.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body) throw new Error("No readable stream")
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
setMessages(prev => {
|
||||||
|
const newMessages = [...prev]
|
||||||
|
newMessages[newMessages.length - 1].content += chunk
|
||||||
|
return newMessages
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Falling back to mock stream due to error:", err)
|
||||||
|
// Mock Fallback Streaming logic
|
||||||
|
const mockResponse = "It looks like my backend connection is restricted right now. However, I can still render math for you! Here is the quadratic formula:\n\n$$ x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} $$\n\nAnd inline math: $E = mc^2$."
|
||||||
|
const words = mockResponse.split(" ")
|
||||||
|
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50)) // Simulate network delay
|
||||||
|
setMessages(prev => {
|
||||||
|
const newMessages = [...prev]
|
||||||
|
newMessages[newMessages.length - 1].content += (i === 0 ? "" : " ") + words[i]
|
||||||
|
return newMessages
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setStreaming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border shadow-sm flex flex-col h-[600px]">
|
||||||
|
<CardHeader className="border-b bg-secondary/10 pb-4">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Bot className="text-primary" size={24} />
|
||||||
|
AI Tutor Chat
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.map((msg, idx) => (
|
||||||
|
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
|
<div className={`max-w-[80%] rounded-2xl p-4 flex gap-3 ${
|
||||||
|
msg.role === "user" ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-secondary rounded-tl-sm"
|
||||||
|
}`}>
|
||||||
|
{msg.role === "assistant" && <Bot size={20} className="mt-1 shrink-0" />}
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none overflow-hidden">
|
||||||
|
{msg.role === "assistant" ? (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex]}
|
||||||
|
>
|
||||||
|
{msg.content || "..."}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
msg.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<form onSubmit={handleSend} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Ask me to explain a concept..."
|
||||||
|
className="flex-1 bg-secondary/50 border-transparent focus-visible:ring-primary"
|
||||||
|
disabled={streaming}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="icon" disabled={!input.trim() || streaming}>
|
||||||
|
{streaming ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/components/CBT/Calculator.tsx
Normal file
111
src/components/CBT/Calculator.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Delete } from "lucide-react";
|
||||||
|
|
||||||
|
interface CalculatorProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Calculator({ isOpen, onClose }: CalculatorProps) {
|
||||||
|
const [display, setDisplay] = useState("");
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleInput = (val: string) => {
|
||||||
|
setDisplay((prev) => prev + val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCalculate = () => {
|
||||||
|
try {
|
||||||
|
// Map functions to JS Math
|
||||||
|
let mappedExp = display
|
||||||
|
.replace(/sin\(/g, 'Math.sin(')
|
||||||
|
.replace(/cos\(/g, 'Math.cos(')
|
||||||
|
.replace(/tan\(/g, 'Math.tan(')
|
||||||
|
.replace(/log\(/g, 'Math.log10(')
|
||||||
|
.replace(/ln\(/g, 'Math.log(')
|
||||||
|
.replace(/sqrt\(/g, 'Math.sqrt(')
|
||||||
|
.replace(/\^/g, '**');
|
||||||
|
|
||||||
|
const result = new Function('return ' + mappedExp)();
|
||||||
|
|
||||||
|
// Handle floating point precision issues simply
|
||||||
|
if (typeof result === 'number' && !Number.isInteger(result)) {
|
||||||
|
setDisplay(String(parseFloat(result.toFixed(6))));
|
||||||
|
} else {
|
||||||
|
setDisplay(String(result));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDisplay("Error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => setDisplay("");
|
||||||
|
const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[400px] overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
|
||||||
|
<h3 className="font-bold">Scientific Calculator</h3>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-background">
|
||||||
|
<div className="bg-secondary p-4 rounded-xl text-right text-3xl font-mono mb-4 overflow-x-auto min-h-16 flex items-center justify-end break-all shadow-inner">
|
||||||
|
{display || "0"}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{/* Scientific Row 1 */}
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('sin(')}>sin</Button>
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('cos(')}>cos</Button>
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('tan(')}>tan</Button>
|
||||||
|
<Button variant="outline" className="font-bold text-destructive hover:bg-destructive/10" onClick={handleClear}>AC</Button>
|
||||||
|
<Button variant="outline" onClick={handleBackspace}><Delete size={18} /></Button>
|
||||||
|
|
||||||
|
{/* Scientific Row 2 */}
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('log(')}>log</Button>
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('ln(')}>ln</Button>
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('sqrt(')}>√</Button>
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('(')}>(</Button>
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput(')')}>)</Button>
|
||||||
|
|
||||||
|
{/* Number Row 1 */}
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('^')}>x^y</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('7')}>7</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('8')}>8</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('9')}>9</Button>
|
||||||
|
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('/')}>÷</Button>
|
||||||
|
|
||||||
|
{/* Number Row 2 */}
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('3.14159')}>π</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('4')}>4</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('5')}>5</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('6')}>6</Button>
|
||||||
|
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('*')}>×</Button>
|
||||||
|
|
||||||
|
{/* Number Row 3 */}
|
||||||
|
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('2.71828')}>e</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('1')}>1</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('2')}>2</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('3')}>3</Button>
|
||||||
|
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('-')}>-</Button>
|
||||||
|
|
||||||
|
{/* Number Row 4 */}
|
||||||
|
<div className="col-span-1"></div>
|
||||||
|
<Button variant="outline" className="col-span-2 font-bold" onClick={() => handleInput('0')}>0</Button>
|
||||||
|
<Button variant="outline" className="font-bold" onClick={() => handleInput('.')}>.</Button>
|
||||||
|
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('+')}>+</Button>
|
||||||
|
|
||||||
|
{/* Equals */}
|
||||||
|
<Button className="col-span-5 font-bold shadow-[0_0_15px_-5px_var(--color-primary)] mt-1" onClick={handleCalculate}>=</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/CBT/ExamResult.tsx
Normal file
127
src/components/CBT/ExamResult.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { CheckCircle2, XCircle, AlertCircle, Clock, Award, ArrowRight, RotateCcw } from "lucide-react";
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface ExamAttemptPayload {
|
||||||
|
score: number;
|
||||||
|
total_questions: number;
|
||||||
|
accuracy: number;
|
||||||
|
time_taken: number;
|
||||||
|
answers: Array<{
|
||||||
|
question_id: string;
|
||||||
|
selected_answer: string | null;
|
||||||
|
is_correct: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExamResultProps {
|
||||||
|
payload: ExamAttemptPayload;
|
||||||
|
onRetake: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamResult({ payload, onRetake }: ExamResultProps) {
|
||||||
|
const answered = payload.answers.filter(a => a.selected_answer !== null).length;
|
||||||
|
const unanswered = payload.total_questions - answered;
|
||||||
|
|
||||||
|
// Calculate correct based on score formula (+4 for correct, -1 for wrong).
|
||||||
|
// Wait, payload score is just correct count according to seed script right now, let's assume it.
|
||||||
|
const correctCount = payload.answers.filter(a => a.is_correct).length;
|
||||||
|
const incorrectCount = answered - correctCount;
|
||||||
|
|
||||||
|
// Real score calculation: +4 correct, -1 incorrect
|
||||||
|
const finalScore = (correctCount * 4) - (incorrectCount * 1);
|
||||||
|
const maxPossibleScore = payload.total_questions * 4;
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col items-center py-12 px-4 sm:px-6 animate-in fade-in zoom-in duration-500">
|
||||||
|
<div className="max-w-4xl w-full space-y-8">
|
||||||
|
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="inline-flex items-center justify-center size-20 rounded-full bg-green-500/10 mb-4">
|
||||||
|
<Award className="size-10 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight">Exam Successfully Submitted</h1>
|
||||||
|
<p className="text-xl text-muted-foreground font-medium">Here is a quick snapshot of your performance.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-10">
|
||||||
|
<Card className="bg-secondary/20 border-border">
|
||||||
|
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Final Score</span>
|
||||||
|
<div className="text-4xl font-black text-primary">
|
||||||
|
{finalScore} <span className="text-lg text-muted-foreground font-medium">/ {maxPossibleScore}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-secondary/20 border-border">
|
||||||
|
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Accuracy</span>
|
||||||
|
<div className="text-4xl font-black text-blue-500">
|
||||||
|
{Math.round(payload.accuracy)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-secondary/20 border-border">
|
||||||
|
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Time Taken</span>
|
||||||
|
<div className="flex items-center gap-2 text-2xl font-black text-amber-500 mt-1">
|
||||||
|
<Clock className="size-6" /> {formatTime(payload.time_taken)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-secondary/20 border-border">
|
||||||
|
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Attempted</span>
|
||||||
|
<div className="text-4xl font-black text-purple-500">
|
||||||
|
{answered} <span className="text-lg text-muted-foreground font-medium">/ {payload.total_questions}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||||
|
<div className="bg-green-500/10 border border-green-500/20 rounded-2xl p-6 text-center">
|
||||||
|
<CheckCircle2 className="size-8 text-green-500 mx-auto mb-2" />
|
||||||
|
<div className="text-3xl font-black text-green-500">{correctCount}</div>
|
||||||
|
<div className="text-sm font-bold text-green-600/70 uppercase tracking-wide mt-1">Correct</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||||
|
<XCircle className="size-8 text-red-500 mx-auto mb-2" />
|
||||||
|
<div className="text-3xl font-black text-red-500">{incorrectCount}</div>
|
||||||
|
<div className="text-sm font-bold text-red-600/70 uppercase tracking-wide mt-1">Incorrect</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-secondary border border-border rounded-2xl p-6 text-center">
|
||||||
|
<AlertCircle className="size-8 text-muted-foreground mx-auto mb-2" />
|
||||||
|
<div className="text-3xl font-black text-foreground">{unanswered}</div>
|
||||||
|
<div className="text-sm font-bold text-muted-foreground uppercase tracking-wide mt-1">Unanswered</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-8">
|
||||||
|
<Button variant="outline" size="lg" className="rounded-xl h-14 px-8 font-bold text-lg gap-2" onClick={onRetake}>
|
||||||
|
<RotateCcw className="size-5" /> Retake Mock Test
|
||||||
|
</Button>
|
||||||
|
<Link href="/dashboard/cbt">
|
||||||
|
<Button size="lg" className="rounded-xl h-14 px-8 font-bold text-lg shadow-[0_0_20px_-5px_var(--color-primary)] gap-2 w-full sm:w-auto">
|
||||||
|
Dashboard <ArrowRight className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/CBT/PeriodicTable.tsx
Normal file
43
src/components/CBT/PeriodicTable.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X, ExternalLink } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
interface PeriodicTableProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md animate-in fade-in duration-300">
|
||||||
|
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col w-[90vw] max-w-5xl h-[85vh]">
|
||||||
|
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/80">
|
||||||
|
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm"><ExternalLink size={14} className="mr-2"/> Open Fullscreen</Button>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-2 p-2">
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-zinc-950 p-6 flex items-center justify-center relative overflow-auto">
|
||||||
|
{/* We will use a high quality remote SVG/image for the periodic table reference */}
|
||||||
|
<div className="relative w-full h-full min-w-[800px] min-h-[500px]">
|
||||||
|
{/* Using a reliable wikimedia SVG for Periodic Table as a reference */}
|
||||||
|
<Image
|
||||||
|
src="https://upload.wikimedia.org/wikipedia/commons/4/4d/Periodic_table_large.svg"
|
||||||
|
alt="Periodic Table"
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/components/CBT/Scratchpad.tsx
Normal file
177
src/components/CBT/Scratchpad.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Eraser, Pen } from "lucide-react";
|
||||||
|
|
||||||
|
interface ScratchpadProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Scratchpad({ isOpen, onClose }: ScratchpadProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [mode, setMode] = useState<"pen" | "eraser">("pen");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
// Initialize and load saved session memory
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const savedNotes = localStorage.getItem("cbt_scratchpad_notes");
|
||||||
|
if (savedNotes) setNotes(savedNotes);
|
||||||
|
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
canvas.width = 500;
|
||||||
|
canvas.height = 500;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.strokeStyle = "white";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
const savedDrawing = localStorage.getItem("cbt_scratchpad_drawing");
|
||||||
|
if (savedDrawing) {
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
};
|
||||||
|
img.src = savedDrawing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
setIsDrawing(true);
|
||||||
|
draw(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrawing = () => {
|
||||||
|
setIsDrawing(false);
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx?.beginPath();
|
||||||
|
localStorage.setItem("cbt_scratchpad_drawing", canvas.toDataURL());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
if (!isDrawing || !canvasRef.current) return;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let clientX, clientY;
|
||||||
|
if ('touches' in e) {
|
||||||
|
clientX = e.touches[0].clientX;
|
||||||
|
clientY = e.touches[0].clientY;
|
||||||
|
} else {
|
||||||
|
clientX = (e as React.MouseEvent).clientX;
|
||||||
|
clientY = (e as React.MouseEvent).clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCanvas = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext("2d");
|
||||||
|
if (ctx && canvas) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
localStorage.removeItem("cbt_scratchpad_drawing");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearNotes = () => {
|
||||||
|
setNotes("");
|
||||||
|
localStorage.removeItem("cbt_scratchpad_notes");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeSwitch = (newMode: "pen" | "eraser") => {
|
||||||
|
setMode(newMode);
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
if (newMode === "eraser") {
|
||||||
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
|
ctx.lineWidth = 20;
|
||||||
|
} else {
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotesChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setNotes(val);
|
||||||
|
localStorage.setItem("cbt_scratchpad_notes", val);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200 p-8">
|
||||||
|
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col w-full max-w-6xl h-full max-h-[800px]">
|
||||||
|
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
|
||||||
|
<h3 className="font-bold flex items-center gap-2"><Pen size={18}/> Split Workspace: Draw & Type</h3>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant={mode === "pen" ? "default" : "outline"} size="sm" onClick={() => handleModeSwitch("pen")}><Pen size={14} className="mr-2"/> Pen</Button>
|
||||||
|
<Button variant={mode === "eraser" ? "default" : "outline"} size="sm" onClick={() => handleModeSwitch("eraser")}><Eraser size={14} className="mr-2"/> Eraser</Button>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-4 bg-secondary p-2 rounded-lg">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex overflow-hidden bg-zinc-950">
|
||||||
|
{/* Drawing Column */}
|
||||||
|
<div className="flex-1 flex flex-col border-r border-zinc-800 relative">
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button variant="outline" size="sm" onClick={clearCanvas} className="bg-zinc-900 border-zinc-700 text-zinc-300">Clear Drawing</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4 flex items-center justify-center">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseDown={startDrawing}
|
||||||
|
onMouseUp={stopDrawing}
|
||||||
|
onMouseOut={stopDrawing}
|
||||||
|
onMouseMove={draw}
|
||||||
|
onTouchStart={startDrawing}
|
||||||
|
onTouchEnd={stopDrawing}
|
||||||
|
onTouchMove={draw}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-lg cursor-crosshair touch-none shadow-inner"
|
||||||
|
style={{ width: '500px', height: '500px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Typing Column */}
|
||||||
|
<div className="flex-1 flex flex-col relative">
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button variant="outline" size="sm" onClick={clearNotes} className="bg-zinc-900 border-zinc-700 text-zinc-300">Clear Notes</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={handleNotesChange}
|
||||||
|
placeholder="Type your rough notes, formulas, and calculations here..."
|
||||||
|
className="w-full h-full bg-zinc-900 border border-zinc-800 rounded-lg shadow-inner text-zinc-100 p-6 resize-none focus:outline-none focus:ring-1 focus:ring-primary text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/LanguageToggle.tsx
Normal file
37
src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Languages } from "lucide-react"
|
||||||
|
|
||||||
|
export function LanguageToggle() {
|
||||||
|
const [lang, setLang] = useState("EN")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group">
|
||||||
|
<button className="flex items-center gap-2 p-2 rounded-xl hover:bg-secondary/80 transition-colors border border-transparent hover:border-border font-medium text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<Languages size={18} />
|
||||||
|
<span className="hidden sm:inline">{lang === "EN" ? "English" : lang === "HI" ? "Hindi" : "Bilingual"}</span>
|
||||||
|
</button>
|
||||||
|
<div className="absolute top-full right-0 mt-2 w-36 bg-background border border-border rounded-xl shadow-lg opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setLang("EN")}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "EN" ? "font-bold text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLang("HI")}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "HI" ? "font-bold text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
Hindi
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLang("BI")}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "BI" ? "font-bold text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
Bilingual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
src/components/StreakCounter.tsx
Normal file
16
src/components/StreakCounter.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Flame } from "lucide-react"
|
||||||
|
|
||||||
|
interface StreakCounterProps {
|
||||||
|
streak: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreakCounter({ streak }: StreakCounterProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 border border-orange-500/20 text-orange-500 font-bold shadow-[0_0_10px_-2px_rgba(249,115,22,0.5)] cursor-default transition-transform hover:scale-105">
|
||||||
|
<Flame size={18} className="animate-pulse" />
|
||||||
|
<span>{streak}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/components/ThemeToggle.tsx
Normal file
41
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [isDark, setIsDark] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
setIsDark(true);
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
setIsDark(false);
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
setIsDark(false)
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
setIsDark(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-xl hover:bg-secondary/80 transition-colors border border-transparent hover:border-border text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Toggle Dark Mode"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/components/TierBadge.tsx
Normal file
21
src/components/TierBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
interface TierBadgeProps {
|
||||||
|
tier: "Free" | "Pro" | "AI Coach"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TierBadge({ tier }: TierBadgeProps) {
|
||||||
|
if (tier === "Free") {
|
||||||
|
return <Badge variant="outline" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-secondary/30">Free</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tier === "Pro") {
|
||||||
|
return <Badge className="text-xs font-semibold uppercase tracking-wider bg-primary/10 text-primary border-primary/20 hover:bg-primary/20 transition-colors">Pro</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className="text-xs font-semibold uppercase tracking-wider bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20 hover:bg-[#00F0FF]/20 transition-colors shadow-[0_0_10px_-2px_#00F0FF]">
|
||||||
|
AI Coach
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/components/ui/input.tsx
Normal file
23
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-xl border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
18
src/components/ui/label.tsx
Normal file
18
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Label.displayName = "Label"
|
||||||
|
|
||||||
|
export { Label }
|
||||||
48
src/components/ui/switch.tsx
Normal file
48
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||||
|
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||||
|
checked ? "bg-primary" : "bg-border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled && onCheckedChange) {
|
||||||
|
onCheckedChange(!checked);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="peer sr-only"
|
||||||
|
ref={ref}
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (onCheckedChange) onCheckedChange(e.target.checked);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Switch.displayName = "Switch"
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
49
src/hooks/useExamTimer.ts
Normal file
49
src/hooks/useExamTimer.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useCBTStore } from '@/store/cbtStore';
|
||||||
|
|
||||||
|
export function useExamTimer() {
|
||||||
|
const { startTime, totalDuration, status, submitExam } = useCBTStore();
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<number>(totalDuration);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'IN_PROGRESS' || !startTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTime = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsedSeconds = Math.floor((now - startTime) / 1000);
|
||||||
|
const remaining = Math.max(0, totalDuration - elapsedSeconds);
|
||||||
|
|
||||||
|
setTimeRemaining(remaining);
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
submitExam('TIMED_OUT');
|
||||||
|
} else {
|
||||||
|
// Use requestAnimationFrame for a high-performance loop instead of setInterval
|
||||||
|
// We throttle updates to when the second actually changes
|
||||||
|
frameRef.current = requestAnimationFrame(calculateTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(calculateTime);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||||
|
};
|
||||||
|
}, [startTime, totalDuration, status, submitExam]);
|
||||||
|
|
||||||
|
// Format HH:MM:SS
|
||||||
|
const hours = Math.floor(timeRemaining / 3600);
|
||||||
|
const minutes = Math.floor((timeRemaining % 3600) / 60);
|
||||||
|
const seconds = timeRemaining % 60;
|
||||||
|
|
||||||
|
const formattedTime = [
|
||||||
|
hours.toString().padStart(2, '0'),
|
||||||
|
minutes.toString().padStart(2, '0'),
|
||||||
|
seconds.toString().padStart(2, '0'),
|
||||||
|
].join(':');
|
||||||
|
|
||||||
|
return { timeRemaining, formattedTime, isTimedOut: timeRemaining === 0 };
|
||||||
|
}
|
||||||
285
src/store/cbtStore.ts
Normal file
285
src/store/cbtStore.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist, StateStorage, createJSONStorage } from 'zustand/middleware'
|
||||||
|
import { get, set, del } from 'idb-keyval'
|
||||||
|
|
||||||
|
// Custom IndexedDB storage engine for Zustand
|
||||||
|
const idbStorage: StateStorage = {
|
||||||
|
getItem: async (name: string): Promise<string | null> => {
|
||||||
|
return (await get(name)) || null
|
||||||
|
},
|
||||||
|
setItem: async (name: string, value: string): Promise<void> => {
|
||||||
|
await set(name, value)
|
||||||
|
},
|
||||||
|
removeItem: async (name: string): Promise<void> => {
|
||||||
|
await del(name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuestionStatus = 'UNVISITED' | 'NOT_ANSWERED' | 'ANSWERED' | 'MARKED' | 'ANSWERED_MARKED'
|
||||||
|
|
||||||
|
export interface AnswerState {
|
||||||
|
question_id: string
|
||||||
|
selected_answer: string | null
|
||||||
|
status: QuestionStatus
|
||||||
|
time_spent: number // in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamAttemptPayload {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
source_type: 'DPS' | 'Test' | 'MicroTest'
|
||||||
|
source_id: string
|
||||||
|
started_at: string
|
||||||
|
submitted_at: string
|
||||||
|
time_taken: number
|
||||||
|
answers: Array<{
|
||||||
|
question_id: string
|
||||||
|
selected_answer: string | null
|
||||||
|
is_correct: boolean // Typically evaluated on server, but requested in payload structure
|
||||||
|
time_spent: number
|
||||||
|
}>
|
||||||
|
score: number
|
||||||
|
total_questions: number
|
||||||
|
accuracy: number
|
||||||
|
status: 'In_Progress' | 'Submitted' | 'Timed_Out'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CBTState {
|
||||||
|
examId: string | null
|
||||||
|
status: 'IN_PROGRESS' | 'SUBMITTED' | 'TIMED_OUT'
|
||||||
|
startTime: number | null
|
||||||
|
totalDuration: number // in seconds
|
||||||
|
questions: any[]
|
||||||
|
answers: Record<string, AnswerState>
|
||||||
|
currentQuestionIndex: number
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initExam: (examId: string, questions: any[], duration: number) => void
|
||||||
|
selectAnswer: (questionId: string, answer: string) => void
|
||||||
|
clearResponse: (questionId: string) => void
|
||||||
|
markForReview: (questionId: string) => void
|
||||||
|
goToQuestion: (index: number) => void
|
||||||
|
submitExam: (reason?: 'SUBMITTED' | 'TIMED_OUT') => void
|
||||||
|
addTimeSpent: (questionId: string, seconds: number) => void
|
||||||
|
getAttemptPayload: (userId: string) => ExamAttemptPayload | null
|
||||||
|
submitAttemptToServer: (userId: string) => Promise<void>
|
||||||
|
resetExamState: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCBTStore = create<CBTState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
examId: null,
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
startTime: null,
|
||||||
|
totalDuration: 0,
|
||||||
|
questions: [],
|
||||||
|
answers: {},
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
|
||||||
|
initExam: (examId, questions, duration) => {
|
||||||
|
const currentExamId = get().examId;
|
||||||
|
const currentQuestions = get().questions;
|
||||||
|
|
||||||
|
// Force reset if it's a new exam or the number of questions has changed
|
||||||
|
const needsReset = currentExamId !== examId || currentQuestions.length !== questions.length;
|
||||||
|
|
||||||
|
if (needsReset) {
|
||||||
|
const initialAnswers: Record<string, AnswerState> = {};
|
||||||
|
questions.forEach((q, idx) => {
|
||||||
|
initialAnswers[q.id] = {
|
||||||
|
question_id: q.id,
|
||||||
|
selected_answer: null,
|
||||||
|
status: idx === 0 ? 'NOT_ANSWERED' : 'UNVISITED',
|
||||||
|
time_spent: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
set({
|
||||||
|
examId,
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
startTime: Date.now(), // Server-synced timestamp ideally
|
||||||
|
totalDuration: duration,
|
||||||
|
questions,
|
||||||
|
answers: initialAnswers,
|
||||||
|
currentQuestionIndex: 0
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Just update questions array in case text/options changed
|
||||||
|
set({ questions, totalDuration: duration })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAnswer: (questionId, answer) => set((state) => {
|
||||||
|
const current = state.answers[questionId]
|
||||||
|
if (!current) return state;
|
||||||
|
|
||||||
|
let newStatus: QuestionStatus = 'ANSWERED';
|
||||||
|
if (current.status === 'MARKED' || current.status === 'ANSWERED_MARKED') {
|
||||||
|
newStatus = 'ANSWERED_MARKED';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
answers: {
|
||||||
|
...state.answers,
|
||||||
|
[questionId]: { ...current, selected_answer: answer, status: newStatus }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearResponse: (questionId) => set((state) => {
|
||||||
|
const current = state.answers[questionId]
|
||||||
|
if (!current) return state;
|
||||||
|
|
||||||
|
let newStatus: QuestionStatus = 'NOT_ANSWERED';
|
||||||
|
if (current.status === 'ANSWERED_MARKED' || current.status === 'MARKED') {
|
||||||
|
newStatus = 'MARKED';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
answers: {
|
||||||
|
...state.answers,
|
||||||
|
[questionId]: { ...current, selected_answer: null, status: newStatus }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
markForReview: (questionId) => set((state) => {
|
||||||
|
const current = state.answers[questionId]
|
||||||
|
if (!current) return state;
|
||||||
|
|
||||||
|
let newStatus: QuestionStatus = current.selected_answer ? 'ANSWERED_MARKED' : 'MARKED';
|
||||||
|
|
||||||
|
// If already marked, unmark it
|
||||||
|
if (current.status === 'MARKED') newStatus = 'NOT_ANSWERED';
|
||||||
|
if (current.status === 'ANSWERED_MARKED') newStatus = 'ANSWERED';
|
||||||
|
|
||||||
|
return {
|
||||||
|
answers: {
|
||||||
|
...state.answers,
|
||||||
|
[questionId]: { ...current, status: newStatus }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
goToQuestion: (index) => set((state) => {
|
||||||
|
if (index < 0 || index >= state.questions.length) return state;
|
||||||
|
|
||||||
|
const targetQId = state.questions[index].id;
|
||||||
|
const targetState = state.answers[targetQId];
|
||||||
|
|
||||||
|
// Update status of the target question to NOT_ANSWERED if it was UNVISITED
|
||||||
|
const newAnswers = { ...state.answers };
|
||||||
|
if (targetState.status === 'UNVISITED') {
|
||||||
|
newAnswers[targetQId] = { ...targetState, status: 'NOT_ANSWERED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentQuestionIndex: index,
|
||||||
|
answers: newAnswers
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
addTimeSpent: (questionId, seconds) => set((state) => {
|
||||||
|
const current = state.answers[questionId]
|
||||||
|
if (!current) return state;
|
||||||
|
return {
|
||||||
|
answers: {
|
||||||
|
...state.answers,
|
||||||
|
[questionId]: { ...current, time_spent: current.time_spent + seconds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
submitExam: (reason = 'SUBMITTED') => set({ status: reason }),
|
||||||
|
|
||||||
|
getAttemptPayload: (userId: string) => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.examId || !state.startTime) return null;
|
||||||
|
|
||||||
|
const timeTaken = Math.floor((Date.now() - state.startTime) / 1000);
|
||||||
|
const answerList = Object.values(state.answers);
|
||||||
|
|
||||||
|
// Mock evaluation (In reality, server does this)
|
||||||
|
let correctCount = 0;
|
||||||
|
const finalAnswers = answerList.map(a => {
|
||||||
|
// Assuming 'A' is correct for dummy data
|
||||||
|
const isCorrect = a.selected_answer === 'A';
|
||||||
|
if (isCorrect) correctCount++;
|
||||||
|
return {
|
||||||
|
question_id: a.question_id,
|
||||||
|
selected_answer: a.selected_answer,
|
||||||
|
is_correct: isCorrect,
|
||||||
|
time_spent: a.time_spent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalQ = state.questions.length;
|
||||||
|
const payload: ExamAttemptPayload = {
|
||||||
|
id: crypto.randomUUID(), // Mock Attempt ID
|
||||||
|
user_id: userId,
|
||||||
|
source_type: 'Test', // Or DPS based on exam metadata
|
||||||
|
source_id: state.examId,
|
||||||
|
started_at: new Date(state.startTime).toISOString(),
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
time_taken: timeTaken,
|
||||||
|
answers: finalAnswers,
|
||||||
|
score: correctCount * 4, // Assuming +4 logic
|
||||||
|
total_questions: totalQ,
|
||||||
|
accuracy: totalQ > 0 ? (correctCount / totalQ) * 100 : 0,
|
||||||
|
status: state.status === 'IN_PROGRESS' ? 'Submitted' : (state.status === 'TIMED_OUT' ? 'Timed_Out' : 'Submitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
submitAttemptToServer: async (userId: string) => {
|
||||||
|
const payload = get().getAttemptPayload(userId);
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
const apiUrl = rawUrl.replace(/\/$/, '');
|
||||||
|
const res = await fetch(`${apiUrl}/api/attempts/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Failed to submit attempt to server", await res.text());
|
||||||
|
} else {
|
||||||
|
console.log("Successfully submitted attempt to server");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Network error during submission:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetExamState: () => {
|
||||||
|
set({
|
||||||
|
examId: null,
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
startTime: null,
|
||||||
|
totalDuration: 0,
|
||||||
|
questions: [],
|
||||||
|
answers: {},
|
||||||
|
currentQuestionIndex: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'cbt-storage', // unique name
|
||||||
|
storage: createJSONStorage(() => idbStorage), // use custom IndexedDB storage
|
||||||
|
partialize: (state) => ({
|
||||||
|
examId: state.examId,
|
||||||
|
status: state.status,
|
||||||
|
startTime: state.startTime,
|
||||||
|
totalDuration: state.totalDuration,
|
||||||
|
questions: state.questions,
|
||||||
|
answers: state.answers,
|
||||||
|
currentQuestionIndex: state.currentQuestionIndex,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user