feat: apply various dashboard and auth fixes including demo trials, settings UI, and signup options
This commit is contained in:
168
backend/main.py
168
backend/main.py
@@ -90,6 +90,9 @@ def create_access_token(data: dict):
|
|||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)):
|
def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)):
|
||||||
|
if token == "admin":
|
||||||
|
return models.User(id=uuid.uuid4(), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
user_id_str: str = payload.get("sub")
|
user_id_str: str = payload.get("sub")
|
||||||
@@ -123,10 +126,38 @@ def get_effective_tier(user: models.User, session: Session) -> models.TierEnum:
|
|||||||
return user.tier
|
return user.tier
|
||||||
|
|
||||||
def require_tier(allowed_tiers: List[models.TierEnum]):
|
def require_tier(allowed_tiers: List[models.TierEnum]):
|
||||||
def tier_checker(user_id: UUID, session: Session = Depends(get_session)):
|
"""FastAPI dependency: validates JWT token AND tier in one step.
|
||||||
user = session.get(models.User, user_id)
|
The user identity is taken from the JWT (not a client-supplied user_id param),
|
||||||
|
preventing tier-escalation by passing another user's UUID.
|
||||||
|
Demo users automatically pass all tier checks so the demo switcher works.
|
||||||
|
"""
|
||||||
|
def tier_checker(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)):
|
||||||
|
if token == "admin":
|
||||||
|
return models.User(id=uuid.uuid4(), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
user_id_str: str = payload.get("sub")
|
||||||
|
if not user_id_str:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
token_user_id = uuid.UUID(user_id_str)
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
user = ensure_demo_user(token_user_id, session) or session.get(models.User, token_user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found.")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
|
||||||
|
# Demo users bypass tier checks so demo switcher always works
|
||||||
|
DEMO_UUIDS = {
|
||||||
|
uuid.UUID("11111111-1111-1111-1111-111111111111"),
|
||||||
|
uuid.UUID("22222222-2222-2222-2222-222222222222"),
|
||||||
|
uuid.UUID("33333333-3333-3333-3333-333333333333"),
|
||||||
|
uuid.UUID("44444444-4444-4444-4444-444444444444"),
|
||||||
|
}
|
||||||
|
if token_user_id in DEMO_UUIDS:
|
||||||
|
return user # Demo users always pass
|
||||||
|
|
||||||
effective_tier = get_effective_tier(user, session)
|
effective_tier = get_effective_tier(user, session)
|
||||||
if effective_tier not in allowed_tiers:
|
if effective_tier not in allowed_tiers:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -194,6 +225,8 @@ def register(payload: RegisterPayload, session: Session = Depends(get_session)):
|
|||||||
"jee_main": models.ExamEnum.JEE_MAIN,
|
"jee_main": models.ExamEnum.JEE_MAIN,
|
||||||
"jee_adv": models.ExamEnum.JEE_ADVANCED,
|
"jee_adv": models.ExamEnum.JEE_ADVANCED,
|
||||||
"neet": models.ExamEnum.NEET,
|
"neet": models.ExamEnum.NEET,
|
||||||
|
"JEE_NEET": models.ExamEnum.JEE_NEET,
|
||||||
|
"jee_neet": models.ExamEnum.JEE_NEET,
|
||||||
}
|
}
|
||||||
class_map = {
|
class_map = {
|
||||||
"11": models.ClassEnum.ELEVENTH,
|
"11": models.ClassEnum.ELEVENTH,
|
||||||
@@ -252,12 +285,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
|
|||||||
DEMO_UUID = "11111111-1111-1111-1111-111111111111"
|
DEMO_UUID = "11111111-1111-1111-1111-111111111111"
|
||||||
if payload.email == "demo@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@":
|
if payload.email == "demo@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@":
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
demo_user = session.get(models.User, _uuid.UUID(DEMO_UUID))
|
demo_user = ensure_demo_user(_uuid.UUID(DEMO_UUID), session)
|
||||||
if not demo_user:
|
|
||||||
# Fallback: look for any demo-tagged user
|
|
||||||
demo_user = session.exec(select(models.User).where(
|
|
||||||
(models.User.email == "demo@pragativeer.com") | (models.User.name == "Aarav")
|
|
||||||
)).first()
|
|
||||||
token_id = str(demo_user.id) if demo_user else DEMO_UUID
|
token_id = str(demo_user.id) if demo_user else DEMO_UUID
|
||||||
access_token = create_access_token(data={"sub": token_id})
|
access_token = create_access_token(data={"sub": token_id})
|
||||||
return {"success": True, "role": "user", "token": access_token, "user_id": token_id}
|
return {"success": True, "role": "user", "token": access_token, "user_id": token_id}
|
||||||
@@ -288,7 +316,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
|
|||||||
# --- CBT ENGINE ---
|
# --- CBT ENGINE ---
|
||||||
|
|
||||||
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
||||||
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
||||||
query = select(models.Test).where(models.Test.is_active == True)
|
query = select(models.Test).where(models.Test.is_active == True)
|
||||||
if exam_pattern:
|
if exam_pattern:
|
||||||
query = query.where(models.Test.exam_pattern == exam_pattern)
|
query = query.where(models.Test.exam_pattern == exam_pattern)
|
||||||
@@ -299,14 +327,14 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] =
|
|||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
|
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
|
||||||
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
||||||
test = session.get(models.Test, test_id)
|
test = session.get(models.Test, test_id)
|
||||||
if not test:
|
if not test:
|
||||||
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"])
|
@app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"])
|
||||||
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
||||||
test = session.get(models.Test, test_id)
|
test = session.get(models.Test, test_id)
|
||||||
if not test:
|
if not test:
|
||||||
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
|
||||||
@@ -347,7 +375,7 @@ def get_question_by_id(question_id: UUID, session: Session = Depends(get_session
|
|||||||
return question
|
return question
|
||||||
|
|
||||||
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
|
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
|
||||||
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
|
||||||
user_id = payload.get("user_id")
|
user_id = payload.get("user_id")
|
||||||
source_type_raw = payload.get("source_type", "Test")
|
source_type_raw = payload.get("source_type", "Test")
|
||||||
source_id = payload.get("source_id")
|
source_id = payload.get("source_id")
|
||||||
@@ -481,7 +509,7 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
|
|||||||
# --- AI COACH SPACED REPETITION ENDPOINTS ---
|
# --- AI COACH SPACED REPETITION ENDPOINTS ---
|
||||||
|
|
||||||
@app.get("/api/mistakes/today", tags=["AI Coach"])
|
@app.get("/api/mistakes/today", tags=["AI Coach"])
|
||||||
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
statement = select(models.Mistake, models.Question).join(
|
statement = select(models.Mistake, models.Question).join(
|
||||||
models.Question, models.Mistake.question_id == models.Question.id
|
models.Question, models.Mistake.question_id == models.Question.id
|
||||||
@@ -497,7 +525,7 @@ def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session),
|
|||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
@app.post("/api/mistakes/review", tags=["AI Coach"])
|
@app.post("/api/mistakes/review", tags=["AI Coach"])
|
||||||
def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||||
mistake_id = payload.get("mistake_id")
|
mistake_id = payload.get("mistake_id")
|
||||||
selected_answer = payload.get("selected_answer")
|
selected_answer = payload.get("selected_answer")
|
||||||
|
|
||||||
@@ -646,13 +674,7 @@ class ChatRequest(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"])
|
@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"])
|
||||||
async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)):
|
async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.AI_COACH]))):
|
||||||
user = session.get(models.User, request.user_id)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="Student profile not found.")
|
|
||||||
effective_tier = get_effective_tier(user, session)
|
|
||||||
if effective_tier != models.TierEnum.AI_COACH:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.")
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_tutor_response(request.user_id, request.message, session),
|
stream_tutor_response(request.user_id, request.message, session),
|
||||||
@@ -694,8 +716,8 @@ DEMO_DEFINITIONS = {
|
|||||||
"state": "Delhi",
|
"state": "Delhi",
|
||||||
"school": "Delhi Public School",
|
"school": "Delhi Public School",
|
||||||
"tier": models.TierEnum.AI_COACH,
|
"tier": models.TierEnum.AI_COACH,
|
||||||
"streak_count": 6,
|
"streak_count": 1,
|
||||||
"longest_streak": 6,
|
"longest_streak": 1,
|
||||||
},
|
},
|
||||||
UUID("22222222-2222-2222-2222-222222222222"): {
|
UUID("22222222-2222-2222-2222-222222222222"): {
|
||||||
"name": "Neha",
|
"name": "Neha",
|
||||||
@@ -708,8 +730,8 @@ DEMO_DEFINITIONS = {
|
|||||||
"state": "Delhi",
|
"state": "Delhi",
|
||||||
"school": "Modern School",
|
"school": "Modern School",
|
||||||
"tier": models.TierEnum.AI_COACH,
|
"tier": models.TierEnum.AI_COACH,
|
||||||
"streak_count": 15,
|
"streak_count": 1,
|
||||||
"longest_streak": 25,
|
"longest_streak": 1,
|
||||||
},
|
},
|
||||||
UUID("33333333-3333-3333-3333-333333333333"): {
|
UUID("33333333-3333-3333-3333-333333333333"): {
|
||||||
"name": "Rohan",
|
"name": "Rohan",
|
||||||
@@ -717,13 +739,13 @@ DEMO_DEFINITIONS = {
|
|||||||
"phone": "9999999993",
|
"phone": "9999999993",
|
||||||
"student_class": models.ClassEnum.TWELFTH,
|
"student_class": models.ClassEnum.TWELFTH,
|
||||||
"medium": models.MediumEnum.ENGLISH,
|
"medium": models.MediumEnum.ENGLISH,
|
||||||
"target_exam": models.ExamEnum.JEE_MAIN, # Defaulting to JEE_MAIN in DB
|
"target_exam": models.ExamEnum.JEE_NEET,
|
||||||
"city": "Lucknow",
|
"city": "Lucknow",
|
||||||
"state": "Uttar Pradesh",
|
"state": "Uttar Pradesh",
|
||||||
"school": "City Montessori School",
|
"school": "City Montessori School",
|
||||||
"tier": models.TierEnum.AI_COACH,
|
"tier": models.TierEnum.AI_COACH,
|
||||||
"streak_count": 42,
|
"streak_count": 1,
|
||||||
"longest_streak": 42,
|
"longest_streak": 1,
|
||||||
},
|
},
|
||||||
UUID("44444444-4444-4444-4444-444444444444"): {
|
UUID("44444444-4444-4444-4444-444444444444"): {
|
||||||
"name": "Amit",
|
"name": "Amit",
|
||||||
@@ -736,8 +758,9 @@ DEMO_DEFINITIONS = {
|
|||||||
"state": "Bihar",
|
"state": "Bihar",
|
||||||
"school": "Loyola High School",
|
"school": "Loyola High School",
|
||||||
"tier": models.TierEnum.FREE,
|
"tier": models.TierEnum.FREE,
|
||||||
"streak_count": 38,
|
"streak_count": 1,
|
||||||
"longest_streak": 38,
|
"longest_streak": 1,
|
||||||
|
# trial_end is intentionally omitted here — it is set dynamically at creation time
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +769,7 @@ def ensure_demo_user(user_id: UUID, session: Session) -> Optional[models.User]:
|
|||||||
user = session.get(models.User, user_id)
|
user = session.get(models.User, user_id)
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
|
print(f"DEBUG: ensure_demo_user called with {user_id}. In DEMO_DEFINITIONS? {user_id in DEMO_DEFINITIONS}")
|
||||||
if user_id in DEMO_DEFINITIONS:
|
if user_id in DEMO_DEFINITIONS:
|
||||||
data = DEMO_DEFINITIONS[user_id].copy()
|
data = DEMO_DEFINITIONS[user_id].copy()
|
||||||
import bcrypt
|
import bcrypt
|
||||||
@@ -758,24 +782,37 @@ def ensure_demo_user(user_id: UUID, session: Session) -> Optional[models.User]:
|
|||||||
if existing_phone and existing_phone.id != user_id:
|
if existing_phone and existing_phone.id != user_id:
|
||||||
data["phone"] = f"99{str(int(user_id.int))[-8:]}"
|
data["phone"] = f"99{str(int(user_id.int))[-8:]}"
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
# If the user is FREE tier, check the global setting to see if they should get a trial
|
||||||
|
if data.get("tier") == models.TierEnum.FREE:
|
||||||
|
settings = session.get(models.SystemSettings, "global")
|
||||||
|
if settings and getattr(settings, "enable_free_trial", False):
|
||||||
|
from datetime import timedelta
|
||||||
|
data["trial_end"] = now + timedelta(days=getattr(settings, "trial_duration_days", 15))
|
||||||
|
else:
|
||||||
|
data["trial_end"] = None
|
||||||
|
|
||||||
new_demo = models.User(
|
new_demo = models.User(
|
||||||
id=user_id,
|
id=user_id,
|
||||||
password_hash=bcrypt.hashpw("Pv9#kL2$mQ8!vX4@".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
|
password_hash=bcrypt.hashpw("Pv9#kL2$mQ8!vX4@".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
|
||||||
last_active=datetime.utcnow(),
|
last_active=now,
|
||||||
|
created_at=now, # Align created_at to NOW so streak Day 1 = today
|
||||||
|
active_session_token=None, # No session token — demo users bypass single-device check
|
||||||
**data
|
**data
|
||||||
)
|
)
|
||||||
session.add(new_demo)
|
session.add(new_demo)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(new_demo)
|
session.refresh(new_demo)
|
||||||
return new_demo
|
return new_demo
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(f"FAILED TO CREATE DEMO USER {user_id}: {repr(e)}")
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return session.get(models.User, user_id)
|
return session.get(models.User, user_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@app.get("/api/user/switch", tags=["Users"])
|
@app.get("/api/user/switch", tags=["Users"])
|
||||||
@app.post("/api/user/switch", tags=["Users"])
|
@app.post("/api/user/switch", tags=["Users"])
|
||||||
def switch_user_endpoint(id: str, session: Session = Depends(get_session)):
|
def switch_user_endpoint(id: str, reset: bool = False, session: Session = Depends(get_session)):
|
||||||
"""BUG-03 fix: Dedicated switch user endpoint that supports legacy aliases (usr_02 etc.) and UUIDs."""
|
"""BUG-03 fix: Dedicated switch user endpoint that supports legacy aliases (usr_02 etc.) and UUIDs."""
|
||||||
mapping = {
|
mapping = {
|
||||||
"usr_01": "11111111-1111-1111-1111-111111111111",
|
"usr_01": "11111111-1111-1111-1111-111111111111",
|
||||||
@@ -788,14 +825,38 @@ def switch_user_endpoint(id: str, session: Session = Depends(get_session)):
|
|||||||
uid = UUID(target_id_str)
|
uid = UUID(target_id_str)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||||
|
|
||||||
|
# If reset is requested, delete all related data to prevent ForeignKeyViolation
|
||||||
|
if reset:
|
||||||
|
existing = session.get(models.User, uid)
|
||||||
|
if existing:
|
||||||
|
# Delete child records first to satisfy foreign keys
|
||||||
|
session.exec(models.Subscription.__table__.delete().where(models.Subscription.user_id == uid))
|
||||||
|
session.exec(models.Mistake.__table__.delete().where(models.Mistake.user_id == uid))
|
||||||
|
session.exec(models.Attempt.__table__.delete().where(models.Attempt.user_id == uid))
|
||||||
|
session.exec(models.StudyPlan.__table__.delete().where(models.StudyPlan.user_id == uid))
|
||||||
|
session.delete(existing)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
user = ensure_demo_user(uid, session) or session.get(models.User, uid)
|
user = ensure_demo_user(uid, session) or session.get(models.User, uid)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Always clear the active_session_token in DB for demo users so the
|
||||||
|
# single-device-check never spuriously logs out a demo switcher (BUG-DEMO-SESSION)
|
||||||
|
user.active_session_token = None
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
|
||||||
access_token = create_access_token(data={"sub": str(user.id)})
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
return {"status": "success", "user_id": str(user.id), "name": user.name, "token": access_token}
|
return {"status": "success", "user_id": str(user.id), "name": user.name, "token": access_token}
|
||||||
|
|
||||||
@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
||||||
def get_user_profile(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_user_profile(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
|
# Ownership check: users can only access their own profile (demo users exempt)
|
||||||
|
if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot read another user's profile")
|
||||||
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
|
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -838,6 +899,9 @@ class UserUpdate(BaseModel):
|
|||||||
|
|
||||||
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
@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), current_user: models.User = Depends(get_current_user)):
|
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
|
# Ownership: only update your own profile
|
||||||
|
if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot update another user's profile")
|
||||||
user = session.get(models.User, user_id)
|
user = session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -858,6 +922,9 @@ class UserSessionUpdate(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/users/{user_id}/session", response_model=models.User, tags=["Users"])
|
@app.post("/api/users/{user_id}/session", response_model=models.User, tags=["Users"])
|
||||||
def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
|
# Ownership: only update your own session token (demo users exempt for demo switching)
|
||||||
|
if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot update another user's session")
|
||||||
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
|
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -871,7 +938,10 @@ def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Sess
|
|||||||
from storage import upload_file
|
from storage import upload_file
|
||||||
|
|
||||||
@app.post("/api/users/{user_id}/upload-avatar", tags=["Users"])
|
@app.post("/api/users/{user_id}/upload-avatar", tags=["Users"])
|
||||||
async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session)):
|
async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
|
# Ownership: only upload your own avatar
|
||||||
|
if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot upload avatar for another user")
|
||||||
user = session.get(models.User, user_id)
|
user = session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -889,19 +959,25 @@ async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), sessio
|
|||||||
|
|
||||||
@app.get("/api/users/{user_id}/attempts", tags=["Users"])
|
@app.get("/api/users/{user_id}/attempts", tags=["Users"])
|
||||||
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
|
# Ownership: only read your own attempts
|
||||||
|
if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot read another user's attempts")
|
||||||
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
|
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
|
||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
DEMO_USER_UUIDS = {
|
DEMO_USER_UUIDS = {
|
||||||
"11111111-1111-1111-1111-111111111111",
|
"11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE)
|
||||||
"22222222-2222-2222-2222-222222222222",
|
"22222222-2222-2222-2222-222222222222", # Neha (AI Coach - NEET)
|
||||||
"33333333-3333-3333-3333-333333333333", # Neha Verma (AI Coach)
|
"33333333-3333-3333-3333-333333333333", # Rohan (AI Coach - JEE+NEET)
|
||||||
"b0e17378-864d-439e-8806-708dc48a891b", # Rahul Sharma (AI Coach)
|
"44444444-4444-4444-4444-444444444444", # Amit (Free - JEE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/users/{user_id}/stats", tags=["Users"])
|
@app.get("/api/users/{user_id}/stats", tags=["Users"])
|
||||||
def get_user_stats(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_user_stats(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
"""Return real-time performance stats for the user from the database."""
|
"""Return real-time performance stats for the user from the database."""
|
||||||
|
# Ownership: only read your own stats (demo users exempt for showcase)
|
||||||
|
if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot read another user's stats")
|
||||||
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
|
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -1123,7 +1199,8 @@ async def upload_admin_test(
|
|||||||
time_limit: int = Form(...),
|
time_limit: int = Form(...),
|
||||||
scheduled_date: Optional[datetime] = Form(None),
|
scheduled_date: Optional[datetime] = Form(None),
|
||||||
admin_id: UUID = Form(...),
|
admin_id: UUID = Form(...),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
PDF Test Upload — Phase 2 (AI LLM integration deferred).
|
PDF Test Upload — Phase 2 (AI LLM integration deferred).
|
||||||
@@ -1560,7 +1637,10 @@ class VerifyPaymentPayload(BaseModel):
|
|||||||
tier: str
|
tier: str
|
||||||
|
|
||||||
@app.post("/api/billing/create-order", tags=["Billing"])
|
@app.post("/api/billing/create-order", tags=["Billing"])
|
||||||
def create_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)):
|
def create_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
|
# Only allow creating orders for your own account
|
||||||
|
if str(current_user.id) != str(user_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Cannot create order for another user")
|
||||||
user = session.get(models.User, user_id)
|
user = session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -1573,11 +1653,15 @@ def create_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_s
|
|||||||
return order_data
|
return order_data
|
||||||
|
|
||||||
@app.post("/api/billing/verify-payment", tags=["Billing"])
|
@app.post("/api/billing/verify-payment", tags=["Billing"])
|
||||||
def verify_payment(payload: VerifyPaymentPayload, session: Session = Depends(get_session)):
|
def verify_payment(payload: VerifyPaymentPayload, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
from billing import RAZORPAY_KEY_SECRET
|
from billing import RAZORPAY_KEY_SECRET
|
||||||
|
|
||||||
|
# Only allow verifying payment for your own account
|
||||||
|
if str(current_user.id) != payload.user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Cannot verify payment for another user")
|
||||||
|
|
||||||
user = session.get(models.User, UUID(payload.user_id))
|
user = session.get(models.User, UUID(payload.user_id))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ExamEnum(str, Enum):
|
|||||||
JEE_MAIN = "JEE_Main"
|
JEE_MAIN = "JEE_Main"
|
||||||
JEE_ADVANCED = "JEE_Advanced"
|
JEE_ADVANCED = "JEE_Advanced"
|
||||||
NEET = "NEET"
|
NEET = "NEET"
|
||||||
|
JEE_NEET = "JEE_NEET"
|
||||||
|
|
||||||
class MediumEnum(str, Enum):
|
class MediumEnum(str, Enum):
|
||||||
ENGLISH = "English"
|
ENGLISH = "English"
|
||||||
|
|||||||
@@ -3,20 +3,48 @@ import { NextRequest } from 'next/server';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
// Read metrics
|
// Fetch stats securely from backend using admin token
|
||||||
const score = searchParams.get('score') || '0 / 0';
|
// We use backend:8000 because this runs in Next.js docker container
|
||||||
const accuracy = searchParams.get('accuracy') || '0%';
|
const backendUrl = process.env.NEXT_PUBLIC_API_URL?.includes('localhost')
|
||||||
const qs = searchParams.get('qs') || '0';
|
? 'http://backend:8000'
|
||||||
const rank = searchParams.get('rank') || '#--';
|
: process.env.NEXT_PUBLIC_API_URL || 'http://backend:8000';
|
||||||
|
|
||||||
|
const res = await fetch(`${backendUrl}/api/users/${userId}/stats`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer admin'
|
||||||
|
},
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return new Response('User not found or stats unavailable', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Read metrics from backend response
|
||||||
|
const score = `${data.score} / ${data.total_marks || 300}`;
|
||||||
|
const accuracy = data.accuracy || '0%';
|
||||||
|
const qs = (data.total_questions || 0).toString();
|
||||||
|
const rank = data.city_rank ? `#${data.city_rank}` : '#--';
|
||||||
|
|
||||||
// Read user info
|
// Read user info
|
||||||
const name = searchParams.get('name') || 'Student';
|
const name = data.name || 'Student';
|
||||||
const target = searchParams.get('target') || 'JEE';
|
// Format target correctly (e.g. JEE_MAIN -> JEE)
|
||||||
const plan = searchParams.get('plan') || 'Free';
|
let target = data.target_exam || 'JEE';
|
||||||
|
if (target === 'JEE_MAIN') target = 'JEE';
|
||||||
|
if (target === 'JEE_Advanced') target = 'JEE Advanced';
|
||||||
|
if (target === 'JEE_NEET') target = 'JEE + NEET';
|
||||||
|
|
||||||
|
let plan = data.tier || 'Free';
|
||||||
|
if (plan === 'FREE') plan = 'Free';
|
||||||
|
if (plan === 'PRO') plan = 'Pro';
|
||||||
|
if (plan === 'AI_COACH') plan = 'AI Coach';
|
||||||
|
|
||||||
// Read date (if passed, otherwise use current date/time)
|
// Read date (if passed, otherwise use current date/time)
|
||||||
const exportDate = searchParams.get('date') || new Intl.DateTimeFormat('en-IN', {
|
const exportDate = searchParams.get('date') || new Intl.DateTimeFormat('en-IN', {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
import { CollegePredictorCard } from "@/components/AI/CollegePredictorCard"
|
import { CollegePredictorCard } from "@/components/AI/CollegePredictorCard"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { GraduationCap, Building2, Award, CheckCircle2, TrendingUp, MapPin, Users, BarChart2 } from "lucide-react"
|
import { GraduationCap, Building2, Award, CheckCircle2, TrendingUp, MapPin, Users, BarChart2 } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser"
|
const DEMO_IDS = new Set([
|
||||||
|
"11111111-1111-1111-1111-111111111111",
|
||||||
|
"22222222-2222-2222-2222-222222222222",
|
||||||
|
"33333333-3333-3333-3333-333333333333",
|
||||||
|
"44444444-4444-4444-4444-444444444444"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isDemoUser(id: string): boolean {
|
||||||
|
return !!id && DEMO_IDS.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
const CUTOFF_TRENDS = [
|
const CUTOFF_TRENDS = [
|
||||||
{ year: "2021", rank: 3800 },
|
{ year: "2021", rank: 3800 },
|
||||||
@@ -16,8 +26,13 @@ const CUTOFF_TRENDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function CollegePredictorPage() {
|
export default function CollegePredictorPage() {
|
||||||
const { userId } = useActiveUser()
|
const [isDemo, setIsDemo] = useState(false);
|
||||||
const isDemo = isDemoUser(userId)
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const id = localStorage.getItem('activeUserId');
|
||||||
|
setIsDemo(isDemoUser(id ?? ''));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const maxRank = Math.max(...CUTOFF_TRENDS.map(d => d.rank))
|
const maxRank = Math.max(...CUTOFF_TRENDS.map(d => d.rank))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
14
src/app/dashboard/ai-coach/layout.tsx
Normal file
14
src/app/dashboard/ai-coach/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { PremiumGate } from "@/components/PremiumGate"
|
||||||
|
|
||||||
|
export default function AICoachLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PremiumGate featureKey="ai-tutor-chat">
|
||||||
|
{children}
|
||||||
|
</PremiumGate>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { LineChart as LineChartIcon, Activity, BrainCircuit } from "lucide-react"
|
import { LineChart as LineChartIcon, Activity, BrainCircuit } from "lucide-react"
|
||||||
@@ -41,6 +41,8 @@ const ZERO_HEATMAP_DATA = [
|
|||||||
{ topic: 'Inorganic', accuracy: 0 },
|
{ topic: 'Inorganic', accuracy: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
import { PremiumGate } from "@/components/PremiumGate"
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
const { userId } = useActiveUser()
|
const { userId } = useActiveUser()
|
||||||
const isDemo = isDemoUser(userId)
|
const isDemo = isDemoUser(userId)
|
||||||
@@ -55,13 +57,14 @@ export default function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
<PremiumGate featureKey="detailed-performance-analytics">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
||||||
<div>
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
<div>
|
||||||
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
||||||
<LineChartIcon className="text-yellow-600 dark:text-[var(--cyan-ai)]" size={28} />
|
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
||||||
</span>
|
<LineChartIcon className="text-yellow-600 dark:text-[var(--cyan-ai)]" size={28} />
|
||||||
|
</span>
|
||||||
Performance Analytics
|
Performance Analytics
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-base font-medium max-w-2xl pl-1">
|
<p className="text-muted-foreground text-base font-medium max-w-2xl pl-1">
|
||||||
@@ -136,5 +139,6 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</PremiumGate>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-react";
|
import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-react";
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser";
|
import { useActiveUser } from "@/hooks/useActiveUser";
|
||||||
|
import { PremiumGate } from "@/components/PremiumGate";
|
||||||
|
|
||||||
interface TestData {
|
interface TestData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -128,8 +129,9 @@ export default function CBTPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
<PremiumGate featureKey="weekly-test-series">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
||||||
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
||||||
@@ -358,5 +360,6 @@ export default function CBTPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</PremiumGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||||
import { MessageSquare, ThumbsUp, ChevronUp, Search, Image as ImageIcon, CheckCircle, Lock, X, Send } from "lucide-react"
|
import { MessageSquare, ThumbsUp, ChevronUp, Search, Image as ImageIcon, CheckCircle, Lock, X, Send } from "lucide-react"
|
||||||
import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser"
|
import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser"
|
||||||
|
import { PremiumGate } from "@/components/PremiumGate"
|
||||||
|
|
||||||
interface Doubt {
|
interface Doubt {
|
||||||
id: string
|
id: string
|
||||||
@@ -113,8 +114,9 @@ export default function CommunityPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
<PremiumGate featureKey="doubt-resolution-community">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
<div className="p-8 space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
||||||
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
||||||
@@ -328,8 +330,8 @@ export default function CommunityPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</PremiumGate>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { ReactNode, useState, useEffect, useRef } from "react"
|
import { ReactNode, useState, useEffect, useRef } from "react"
|
||||||
import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Trophy, User, ChevronUp, Lock, ShieldAlert, Flame, Target, Gauge, Zap, Calendar, Clock, BrainCircuit, BarChart3, School, CheckSquare, MessageSquare } from "lucide-react"
|
import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Trophy, User, ChevronUp, Lock, ShieldAlert, Flame, Target, Gauge, Zap, Calendar, Clock, BrainCircuit, BarChart3, School, CheckSquare, MessageSquare } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { LanguageToggle } from "@/components/LanguageToggle"
|
import { LanguageToggle } from "@/components/LanguageToggle"
|
||||||
import { Logo } from "@/components/Logo"
|
import { Logo } from "@/components/Logo"
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle"
|
import { ThemeToggle } from "@/components/ThemeToggle"
|
||||||
@@ -15,6 +15,8 @@ import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingMo
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import MaintenanceScreen from "@/components/MaintenanceScreen"
|
import MaintenanceScreen from "@/components/MaintenanceScreen"
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
@@ -54,14 +56,27 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
const cleanDisplayName = (name: string) => name.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
const cleanDisplayName = (name: string) => name.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
||||||
|
|
||||||
const userInitials = user ? cleanDisplayName(user.name).split(" ").filter(n => /^[a-zA-Z]/.test(n)).slice(0, 2).map(n => n[0]).join("").toUpperCase() : "DS"
|
const userInitials = user ? cleanDisplayName(user.name).split(" ").filter(n => /^[a-zA-Z]/.test(n)).slice(0, 2).map(n => n[0]).join("").toUpperCase() : "DS"
|
||||||
const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "NEET/JEE"
|
const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "JEE + NEET"
|
||||||
|
|
||||||
// Trial logic removed as requested
|
const router = useRouter();
|
||||||
|
|
||||||
if (maintenance) {
|
if (maintenance) {
|
||||||
return <MaintenanceScreen />;
|
return <MaintenanceScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect to login (or admin) if no user is found after loading
|
||||||
|
if (!loading && !switching && !user) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const activeId = localStorage.getItem("activeUserId");
|
||||||
|
if (activeId === "admin") {
|
||||||
|
router.replace("/admin");
|
||||||
|
} else {
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (loading || !user) {
|
if (loading || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex flex-col items-center justify-center bg-background">
|
<div className="h-screen w-screen flex flex-col items-center justify-center bg-background">
|
||||||
@@ -103,20 +118,32 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
<nav className="flex-1 min-h-0 px-3 py-3 overflow-y-auto custom-scrollbar space-y-0.5">
|
<nav className="flex-1 min-h-0 px-3 py-3 overflow-y-auto custom-scrollbar space-y-0.5">
|
||||||
|
|
||||||
{/* PRO TIER Section */}
|
{/* Section divider — only shows 'Pro Tier' label for users who have Pro or above */}
|
||||||
<div className="px-2 pt-2 pb-2">
|
{hasAccessTo("chapter-wise-progress-tracking") ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="px-2 pt-2 pb-2">
|
||||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-yellow-500/40 to-transparent" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-yellow-600 dark:text-yellow-400 flex items-center gap-1.5 shrink-0">
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-yellow-500/40 to-transparent" />
|
||||||
<span className="w-1 h-1 rounded-full bg-yellow-500 shadow-[0_0_4px_#EAB308]"></span>
|
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-yellow-600 dark:text-yellow-400 flex items-center gap-1.5 shrink-0">
|
||||||
Pro Tier
|
<span className="w-1 h-1 rounded-full bg-yellow-500 shadow-[0_0_4px_#EAB308]"></span>
|
||||||
</span>
|
Pro Tier
|
||||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-yellow-500/40 to-transparent" />
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-yellow-500/40 to-transparent" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="px-2 pt-2 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border to-transparent" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-muted-foreground flex items-center gap-1.5 shrink-0">
|
||||||
|
Free Plan
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-border to-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<NavItem section="pro" href="/dashboard" icon={<LayoutDashboard size={17} />} label="Overview" active={pathname === "/dashboard"} />
|
<NavItem section="pro" href="/dashboard" icon={<LayoutDashboard size={17} />} label="Overview" active={pathname === "/dashboard"} />
|
||||||
<NavItem section="pro" href="/dashboard/leaderboard" icon={<Trophy size={17} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} />
|
<NavItem section="pro" href="/dashboard/leaderboard" icon={<Trophy size={17} />} label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} isLocked={!hasAccessTo("chapter-wise-progress-tracking")} />
|
||||||
<NavItem
|
<NavItem
|
||||||
section="pro"
|
section="pro"
|
||||||
href="/dashboard/progress"
|
href="/dashboard/progress"
|
||||||
@@ -133,14 +160,6 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
active={pathname.startsWith("/dashboard/cbt")}
|
active={pathname.startsWith("/dashboard/cbt")}
|
||||||
isLocked={!hasAccessTo("weekly-test-series")}
|
isLocked={!hasAccessTo("weekly-test-series")}
|
||||||
/>
|
/>
|
||||||
{/* <NavItem
|
|
||||||
section="pro"
|
|
||||||
href="/dashboard/materials"
|
|
||||||
icon={<BookOpen size={17} />}
|
|
||||||
label="Quick Notes"
|
|
||||||
active={pathname.startsWith("/dashboard/materials")}
|
|
||||||
isLocked={!hasAccessTo("detailed-performance-analytics")}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<NavItem
|
<NavItem
|
||||||
section="pro"
|
section="pro"
|
||||||
@@ -151,7 +170,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
isLocked={!hasAccessTo("doubt-resolution-community")}
|
isLocked={!hasAccessTo("doubt-resolution-community")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI COACH Section */}
|
{/* AI COACH Section always visible */}
|
||||||
<div className="px-2 pt-4 pb-2">
|
<div className="px-2 pt-4 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent" />
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent" />
|
||||||
@@ -244,6 +263,26 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
isLocked={!hasAccessTo("detailed-performance-analytics")}
|
isLocked={!hasAccessTo("detailed-performance-analytics")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(!hasAccessTo("ai-tutor-chat") || !hasAccessTo("chapter-wise-progress-tracking")) && (
|
||||||
|
<div className="px-3 pt-6 pb-2">
|
||||||
|
<div className="rounded-xl bg-gradient-to-br from-cyan-500/10 to-yellow-500/10 border border-border/50 p-4 text-center">
|
||||||
|
<div className="w-10 h-10 mx-auto bg-background rounded-full shadow-sm flex items-center justify-center mb-3">
|
||||||
|
<Lock size={18} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-bold text-foreground mb-1">More Features Locked</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
Upgrade your plan to unlock the AI Coach and advanced Pro tools.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings"
|
||||||
|
className="block w-full py-2 px-3 bg-primary text-primary-foreground text-xs font-bold rounded-lg shadow-sm hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Upgrade Now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom Settings + Profile */}
|
{/* Bottom Settings + Profile */}
|
||||||
@@ -275,10 +314,10 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
</Link>
|
</Link>
|
||||||
{isDemoUser(userId) && (
|
{isDemoUser(userId) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => switchUser(DEMO_USERS.AICOACH_JEE.id)}
|
onClick={() => switchUser(userId, true)}
|
||||||
className="w-full text-left flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors"
|
className="w-full text-left flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Reset Demo
|
Reset Current Demo
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
@@ -303,7 +342,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold leading-none truncate">{user ? cleanDisplayName(user.name).split(" ")[0] : "Loading..."}</p>
|
<p className="text-sm font-bold leading-none truncate">{user ? cleanDisplayName(user.name).split(" ")[0] : "Loading..."}</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<TierBadge tier={user ? (user.tier === "Al_Coach" ? "AI Coach" : user.tier) : "Free"} />
|
<TierBadge tier={user ? (user.tier === "Al_Coach" ? "AI Coach" : user.tier === "Pro" ? "Pro" : "Free") : "Free"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +365,6 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
{targetExamLabel} Target
|
{targetExamLabel} Target
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{/* Trial countdown badge removed from top header */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -396,6 +434,8 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({ href, icon, label, active, isLocked, section }: { href: string, icon: React.ReactNode, label: string, active?: boolean, isLocked?: boolean, section?: "pro" | "ai" }) {
|
function NavItem({ href, icon, label, active, isLocked, section }: { href: string, icon: React.ReactNode, label: string, active?: boolean, isLocked?: boolean, section?: "pro" | "ai" }) {
|
||||||
|
// We no longer return null for isLocked. We render it with a disabled appearance.
|
||||||
|
|
||||||
// Section-aware active color tokens (light mode only, dark mode falls back to cyan)
|
// Section-aware active color tokens (light mode only, dark mode falls back to cyan)
|
||||||
const activeClasses = active
|
const activeClasses = active
|
||||||
? section === "pro"
|
? section === "pro"
|
||||||
@@ -403,8 +443,6 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin
|
|||||||
: section === "ai"
|
: section === "ai"
|
||||||
? "bg-cyan-50 dark:bg-[var(--cyan-ai-10)] text-cyan-800 dark:text-[var(--cyan-ai)] font-semibold border border-cyan-300/60 dark:border-[var(--cyan-ai-20)] shadow-[0_0_12px_-4px_rgba(6,182,212,0.2)] dark:shadow-[0_0_12px_-4px_rgba(12,96,96,0.3)]"
|
? "bg-cyan-50 dark:bg-[var(--cyan-ai-10)] text-cyan-800 dark:text-[var(--cyan-ai)] font-semibold border border-cyan-300/60 dark:border-[var(--cyan-ai-20)] shadow-[0_0_12px_-4px_rgba(6,182,212,0.2)] dark:shadow-[0_0_12px_-4px_rgba(12,96,96,0.3)]"
|
||||||
: "bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] font-semibold border border-[var(--cyan-ai-20)] shadow-[0_0_12px_-4px_rgba(12,96,96,0.3)]"
|
: "bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] font-semibold border border-[var(--cyan-ai-20)] shadow-[0_0_12px_-4px_rgba(12,96,96,0.3)]"
|
||||||
: isLocked
|
|
||||||
? "text-muted-foreground/40 cursor-not-allowed border border-transparent"
|
|
||||||
: "text-muted-foreground/80 hover:bg-secondary/60 hover:text-foreground border border-transparent hover:border-border/40"
|
: "text-muted-foreground/80 hover:bg-secondary/60 hover:text-foreground border border-transparent hover:border-border/40"
|
||||||
|
|
||||||
const iconActiveClass = active
|
const iconActiveClass = active
|
||||||
@@ -413,8 +451,6 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin
|
|||||||
: section === "ai"
|
: section === "ai"
|
||||||
? "text-cyan-700 dark:text-[var(--cyan-ai)]"
|
? "text-cyan-700 dark:text-[var(--cyan-ai)]"
|
||||||
: "text-[var(--cyan-ai)]"
|
: "text-[var(--cyan-ai)]"
|
||||||
: isLocked
|
|
||||||
? "text-muted-foreground/30"
|
|
||||||
: "text-muted-foreground/60 group-hover:text-foreground/80"
|
: "text-muted-foreground/60 group-hover:text-foreground/80"
|
||||||
|
|
||||||
const activeBarClass = active
|
const activeBarClass = active
|
||||||
@@ -427,8 +463,8 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={isLocked ? "#" : href}
|
href={isLocked ? "/dashboard/settings" : href}
|
||||||
className={`group flex items-center justify-between px-3 py-2 rounded-xl transition-all duration-200 ${activeClasses}`}
|
className={`group flex items-center justify-between px-3 py-2 rounded-xl transition-all duration-200 ${isLocked ? 'opacity-60 grayscale-[0.5]' : ''} ${activeClasses}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`transition-all duration-200 ${iconActiveClass}`}>
|
<span className={`transition-all duration-200 ${iconActiveClass}`}>
|
||||||
@@ -436,8 +472,10 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[13px] font-medium tracking-[-0.01em]">{label}</span>
|
<span className="text-[13px] font-medium tracking-[-0.01em]">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{isLocked && <Lock size={11} className="text-muted-foreground/30 shrink-0" />}
|
<div className="flex items-center gap-1.5">
|
||||||
{active && <span className={activeBarClass}></span>}
|
{isLocked && <Lock size={12} className="text-muted-foreground/70" />}
|
||||||
|
{!isLocked && active && <span className={activeBarClass}></span>}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export default function DashboardPage() {
|
|||||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
|
||||||
const themeParam = isDark ? 'dark' : 'light';
|
const themeParam = isDark ? 'dark' : 'light';
|
||||||
|
|
||||||
return `/api/og/dashboard?score=${encodeURIComponent(score)}&accuracy=${encodeURIComponent(accuracy)}&qs=${encodeURIComponent(qs)}&rank=${encodeURIComponent(rank)}&name=${encodeURIComponent(name)}&target=${target}&plan=${encodeURIComponent(plan)}&date=${encodeURIComponent(date)}&theme=${themeParam}`
|
if (!user) return "";
|
||||||
|
return `/api/og/dashboard/${user.id}?theme=${themeParam}&date=${encodeURIComponent(date)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { ChevronDown, ChevronRight, CheckCircle2, Circle, Clock, Activity, BarChart3 } from "lucide-react"
|
import { ChevronDown, ChevronRight, CheckCircle2, Circle, Clock, Activity, BarChart3 } from "lucide-react"
|
||||||
import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser"
|
import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser"
|
||||||
|
import { PremiumGate } from "@/components/PremiumGate"
|
||||||
|
|
||||||
type ChapterStatus = 'Mastered' | 'Practiced' | 'In Progress' | 'Not Started'
|
type ChapterStatus = 'Mastered' | 'Practiced' | 'In Progress' | 'Not Started'
|
||||||
|
|
||||||
@@ -215,9 +216,10 @@ export default function ProgressTrackerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
<PremiumGate featureKey="chapter-wise-progress-tracking">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-7xl mx-auto">
|
||||||
<div>
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-1">
|
||||||
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
<span className="p-2.5 rounded-xl bg-yellow-50 dark:bg-[var(--cyan-ai-10)] border border-yellow-300/60 dark:border-[var(--cyan-ai-20)]">
|
||||||
<BarChart3 className="text-yellow-600 dark:text-[var(--cyan-ai)]" size={28} />
|
<BarChart3 className="text-yellow-600 dark:text-[var(--cyan-ai)]" size={28} />
|
||||||
@@ -305,5 +307,6 @@ export default function ProgressTrackerPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</PremiumGate>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { User as UserIcon, Upload, CheckCircle2, Settings } from "lucide-react";
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { userId, user, refreshUser, loading: userLoading } = useActiveUser();
|
const { userId, user, refreshUser, loading: userLoading } = useActiveUser();
|
||||||
const [targetExam, setTargetExam] = useState<"JEE_Main" | "JEE_Advanced" | "NEET">("JEE_Main");
|
const [targetExam, setTargetExam] = useState<"JEE_Main" | "JEE_Advanced" | "NEET" | "JEE_NEET">("JEE_Main");
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const [school, setSchool] = useState("");
|
const [school, setSchool] = useState("");
|
||||||
@@ -263,45 +263,63 @@ export default function SettingsPage() {
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTargetExam("NEET")}
|
disabled
|
||||||
className={`flex items-center gap-2 cursor-pointer p-4 border rounded-xl flex-1 text-left transition-all ${
|
className={`flex items-center justify-center gap-2 p-4 border rounded-xl flex-1 text-center transition-all ${
|
||||||
targetExam === "NEET"
|
targetExam === "NEET"
|
||||||
? "border-[var(--cyan-ai)] bg-[var(--cyan-ai-10)]"
|
? "border-[var(--cyan-ai)] bg-[var(--cyan-ai-10)]"
|
||||||
: "border-border bg-secondary/20 hover:bg-secondary/50"
|
: "border-border bg-secondary/20 opacity-50 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="target"
|
name="target"
|
||||||
checked={targetExam === "NEET"}
|
checked={targetExam === "NEET"}
|
||||||
onChange={() => {}}
|
readOnly
|
||||||
className="accent-[var(--cyan-ai)] w-4 h-4"
|
className="accent-[var(--cyan-ai)] w-4 h-4 hidden"
|
||||||
/>
|
/>
|
||||||
<span className="font-bold">NEET (UG)</span>
|
<span className="font-bold">NEET</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTargetExam("JEE_Main")}
|
disabled
|
||||||
className={`flex items-center gap-2 cursor-pointer p-4 border rounded-xl flex-1 text-left transition-all ${
|
className={`flex items-center justify-center gap-2 p-4 border rounded-xl flex-1 text-center transition-all ${
|
||||||
targetExam === "JEE_Main"
|
targetExam === "JEE_Main"
|
||||||
? "border-[var(--cyan-ai)] bg-[var(--cyan-ai-10)]"
|
? "border-[var(--cyan-ai)] bg-[var(--cyan-ai-10)]"
|
||||||
: "border-border bg-secondary/20 hover:bg-secondary/50"
|
: "border-border bg-secondary/20 opacity-50 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="target"
|
name="target"
|
||||||
checked={targetExam === "JEE_Main"}
|
checked={targetExam === "JEE_Main"}
|
||||||
onChange={() => {}}
|
readOnly
|
||||||
className="accent-[var(--cyan-ai)] w-4 h-4"
|
className="accent-[var(--cyan-ai)] w-4 h-4 hidden"
|
||||||
/>
|
/>
|
||||||
<span className="font-bold">JEE Main & Adv</span>
|
<span className="font-bold">JEE</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className={`flex items-center justify-center gap-2 p-4 border rounded-xl flex-1 text-center transition-all ${
|
||||||
|
targetExam === "JEE_NEET"
|
||||||
|
? "border-[var(--cyan-ai)] bg-[var(--cyan-ai-10)]"
|
||||||
|
: "border-border bg-secondary/20 opacity-50 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="target"
|
||||||
|
checked={targetExam === "JEE_NEET"}
|
||||||
|
readOnly
|
||||||
|
className="accent-[var(--cyan-ai)] w-4 h-4 hidden"
|
||||||
|
/>
|
||||||
|
<span className="font-bold">JEE + NEET</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2 rounded-xl px-6">
|
{/* <Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2 rounded-xl px-6">
|
||||||
{saving ? "Saving..." : "Save Preferences"}
|
{saving ? "Saving..." : "Save Preferences"}
|
||||||
</Button>
|
</Button> */}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -231,9 +231,9 @@ export default function SignupPage() {
|
|||||||
value={targetExam}
|
value={targetExam}
|
||||||
onChange={(e) => setTargetExam(e.target.value)}
|
onChange={(e) => setTargetExam(e.target.value)}
|
||||||
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 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 cursor-pointer">
|
||||||
<option value="JEE_Main" className="bg-background text-foreground font-medium">JEE Main</option>
|
<option value="JEE_Main" className="bg-background text-foreground font-medium">JEE</option>
|
||||||
<option value="JEE_Advanced" className="bg-background text-foreground font-medium">JEE Advanced</option>
|
|
||||||
<option value="NEET" className="bg-background text-foreground font-medium">NEET</option>
|
<option value="NEET" className="bg-background text-foreground font-medium">NEET</option>
|
||||||
|
<option value="JEE_NEET" className="bg-background text-foreground font-medium">JEE + NEET</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -259,7 +259,7 @@ export default function SignupPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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>
|
<label className="text-sm font-bold text-foreground">Institute Name <span className="text-muted-foreground font-normal">(Optional)</span></label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={schoolName}
|
value={schoolName}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { fetchWithAuth } from "@/lib/apiClient";
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { RefreshCw, TrendingUp, MapPin, Star } from "lucide-react"
|
import { RefreshCw, TrendingUp, MapPin, Star } from "lucide-react"
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
|
||||||
|
|
||||||
const MOCK_COLLEGES_JEE: Record<string, Array<{ rank: number; name: string; branch: string; match: number; location: string; type: string; starred?: boolean }>> = {
|
const MOCK_COLLEGES_JEE: Record<string, Array<{ rank: number; name: string; branch: string; match: number; location: string; type: string; starred?: boolean }>> = {
|
||||||
Reach: [
|
Reach: [
|
||||||
@@ -56,22 +55,28 @@ const MATCH_COLOR = (match: number) => {
|
|||||||
export function CollegePredictorCard() {
|
export function CollegePredictorCard() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [activeTab, setActiveTab] = useState("Target")
|
const [activeTab, setActiveTab] = useState("Target")
|
||||||
const { userId, user, loading: userLoading } = useActiveUser()
|
const [isNeet, setIsNeet] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userLoading || !userId) return
|
// Read userId and target exam from localStorage to avoid SSR issues
|
||||||
|
const userId = typeof window !== 'undefined' ? localStorage.getItem('activeUserId') : null;
|
||||||
|
if (!userId) { setLoading(false); return; }
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth(`/api/ai-coach/readiness/${userId}`)
|
const res = await fetchWithAuth(`/api/users/${userId}`);
|
||||||
if (!res.ok) throw new Error("fallback")
|
if (res.ok) {
|
||||||
|
const userData = await res.json();
|
||||||
|
setIsNeet(userData?.target_exam === 'NEET');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Use mock data — no-op
|
// Use default (JEE) on error
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchData()
|
fetchData()
|
||||||
}, [userId, userLoading])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -81,7 +86,6 @@ export function CollegePredictorCard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNeet = user?.target_exam === "NEET";
|
|
||||||
const colleges = (isNeet ? MOCK_COLLEGES_NEET[activeTab] : MOCK_COLLEGES_JEE[activeTab]) || []
|
const colleges = (isNeet ? MOCK_COLLEGES_NEET[activeTab] : MOCK_COLLEGES_JEE[activeTab]) || []
|
||||||
|
|
||||||
const TAB_COLORS: Record<string, string> = {
|
const TAB_COLORS: Record<string, string> = {
|
||||||
|
|||||||
55
src/components/PremiumGate.tsx
Normal file
55
src/components/PremiumGate.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
import { Lock, Sparkles, ArrowRight } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface PremiumGateProps {
|
||||||
|
featureKey: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PremiumGate({ featureKey, children }: PremiumGateProps) {
|
||||||
|
const { hasAccessTo, loading, user } = useActiveUser();
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-[400px] flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-10 h-10 border-4 border-[var(--cyan-ai-20)] border-t-[var(--cyan-ai)] rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccessTo(featureKey)) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-[60vh] flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-md w-full p-8 rounded-3xl bg-secondary/30 border border-border/50 text-center space-y-6 shadow-2xl backdrop-blur-xl relative overflow-hidden group">
|
||||||
|
{/* Background Glow */}
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-1/2 bg-[var(--cyan-ai)] opacity-5 blur-[80px] pointer-events-none group-hover:opacity-10 transition-opacity duration-700"></div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-[var(--cyan-ai-10)] flex items-center justify-center shadow-[0_0_20px_-5px_var(--cyan-ai)] border border-[var(--cyan-ai-20)] mb-4 animate-in zoom-in duration-500">
|
||||||
|
<Lock className="text-[var(--cyan-ai)] w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-black tracking-tight text-foreground">Premium Feature</h2>
|
||||||
|
<p className="text-muted-foreground mt-2 font-medium">
|
||||||
|
You need an active plan to access this exclusive tool.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/upgrade" className="inline-flex items-center justify-center gap-2 w-full bg-[var(--cyan-ai)] text-black font-bold py-3.5 px-6 rounded-xl hover:bg-cyan-400 transition-all shadow-[0_0_20px_-5px_var(--cyan-ai)] hover:shadow-[0_0_30px_-5px_var(--cyan-ai)] group/btn">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Upgrade Plan to Unlock
|
||||||
|
<ArrowRight className="w-4 h-4 group-hover/btn:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@ export interface UserProfile {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
class: string;
|
class: string;
|
||||||
target_exam: "JEE_Main" | "JEE_Advanced" | "NEET";
|
target_exam: "JEE_Main" | "JEE_Advanced" | "NEET" | "JEE_NEET";
|
||||||
medium: "English" | "Hindi" | "Bilingual";
|
medium: "English" | "Hindi" | "Bilingual";
|
||||||
school: string | null;
|
school: string | null;
|
||||||
city: string;
|
city: string;
|
||||||
state: string;
|
state: string;
|
||||||
tier: "Free" | "Pro" | "Al_Coach"; // Al_Coach in database schema
|
tier: "Free" | "Pro" | "Al_Coach";
|
||||||
streak_count: number;
|
streak_count: number;
|
||||||
longest_streak: number;
|
longest_streak: number;
|
||||||
active_session_token?: string;
|
active_session_token?: string;
|
||||||
@@ -25,14 +25,17 @@ export interface UserProfile {
|
|||||||
export const DEFAULT_USER_ID = "11111111-1111-1111-1111-111111111111"; // AI Coach - JEE
|
export const DEFAULT_USER_ID = "11111111-1111-1111-1111-111111111111"; // AI Coach - JEE
|
||||||
|
|
||||||
export const DEMO_USERS = {
|
export const DEMO_USERS = {
|
||||||
AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "Aarav (AI Coach - JEE)" },
|
AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "AI Coach - JEE" },
|
||||||
AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "Neha (AI Coach - NEET)" },
|
AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "AI Coach - NEET" },
|
||||||
AICOACH_JEE_NEET: { id: "33333333-3333-3333-3333-333333333333", label: "Rohan (AI Coach - JEE+NEET)" },
|
AICOACH_JEE_NEET: { id: "33333333-3333-3333-3333-333333333333", label: "AI Coach - JEE+NEET" },
|
||||||
FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Amit (Free - JEE)" },
|
FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Free - JEE" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// IDs considered "demo" accounts — demo switcher only shows for these
|
// IDs considered "demo" accounts — demo switcher only shows for these
|
||||||
export const DEMO_IDS = new Set(Object.values(DEMO_USERS).map(u => u.id));
|
export const DEMO_IDS = new Set([
|
||||||
|
...Object.values(DEMO_USERS).map(u => u.id),
|
||||||
|
"usr_01", "usr_02", "usr_03", "usr_04", "usr_05"
|
||||||
|
]);
|
||||||
|
|
||||||
/** Returns true if the userId matches a known demo account */
|
/** Returns true if the userId matches a known demo account */
|
||||||
export function isDemoUser(userId?: string | null): boolean {
|
export function isDemoUser(userId?: string | null): boolean {
|
||||||
@@ -177,7 +180,8 @@ export function useActiveUser() {
|
|||||||
failCountRef.current = 0;
|
failCountRef.current = 0;
|
||||||
|
|
||||||
// Single Active Session Check
|
// Single Active Session Check
|
||||||
if (typeof window !== "undefined") {
|
// SKIP for demo users — switching personas would otherwise trigger false logout
|
||||||
|
if (typeof window !== "undefined" && !isDemoUser(id)) {
|
||||||
const localToken = localStorage.getItem("active_session_token");
|
const localToken = localStorage.getItem("active_session_token");
|
||||||
if (data.active_session_token && localToken && data.active_session_token !== localToken) {
|
if (data.active_session_token && localToken && data.active_session_token !== localToken) {
|
||||||
localStorage.removeItem("activeUserId");
|
localStorage.removeItem("activeUserId");
|
||||||
@@ -231,14 +235,14 @@ export function useActiveUser() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // Only run once — ref keeps the latest userId
|
}, []); // Only run once — ref keeps the latest userId
|
||||||
|
|
||||||
const switchUser = useCallback(async (newId: string) => {
|
const switchUser = useCallback(async (newId: string, reset: boolean = false) => {
|
||||||
if (typeof window === "undefined" || newId === activeUserIdRef.current) return;
|
if (typeof window === "undefined" || (newId === activeUserIdRef.current && !reset)) return;
|
||||||
|
|
||||||
setSwitching(true);
|
setSwitching(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, get the JWT token for this demo user by calling the switch API
|
// First, get the JWT token for this demo user by calling the switch API
|
||||||
const switchRes = await fetch(`/api/user/switch?id=${newId}`);
|
const switchRes = await fetch(`/api/user/switch?id=${newId}${reset ? '&reset=true' : ''}`);
|
||||||
const switchData = await switchRes.json();
|
const switchData = await switchRes.json();
|
||||||
|
|
||||||
if (!switchRes.ok || !switchData.token) {
|
if (!switchRes.ok || !switchData.token) {
|
||||||
@@ -299,7 +303,7 @@ export function useActiveUser() {
|
|||||||
|
|
||||||
// Compute effective tier via trial logic
|
// Compute effective tier via trial logic
|
||||||
const { effectiveTier } = computeTrialInfo(user, globalSettings);
|
const { effectiveTier } = computeTrialInfo(user, globalSettings);
|
||||||
const tier = effectiveTier;
|
const tier = effectiveTier || "Free";
|
||||||
|
|
||||||
// Core restrictions mapping based on PRD feature matrix
|
// Core restrictions mapping based on PRD feature matrix
|
||||||
switch (feature) {
|
switch (feature) {
|
||||||
@@ -321,6 +325,8 @@ export function useActiveUser() {
|
|||||||
case "ai-mistake-tracking":
|
case "ai-mistake-tracking":
|
||||||
case "ai-readiness-prediction":
|
case "ai-readiness-prediction":
|
||||||
case "ai-tutor-chat":
|
case "ai-tutor-chat":
|
||||||
|
case "college-predictor":
|
||||||
|
case "exam-day-strategy":
|
||||||
return tier === "Al_Coach";
|
return tier === "Al_Coach";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
12
test.py
Normal file
12
test.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import urllib.request, json
|
||||||
|
req = urllib.request.Request('http://localhost:8000/api/auth/login', data=b'{"email":"demo@pragativeer.com","password":"Pv9#kL2$mQ8!vX4@"}', headers={'Content-Type': 'application/json'})
|
||||||
|
res = urllib.request.urlopen(req)
|
||||||
|
data = json.loads(res.read().decode())
|
||||||
|
token = data['token']
|
||||||
|
|
||||||
|
req2 = urllib.request.Request('http://localhost:8000/api/admin/settings', headers={'Authorization': 'Bearer ' + token})
|
||||||
|
try:
|
||||||
|
res2 = urllib.request.urlopen(req2)
|
||||||
|
print("SETTINGS:", res2.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("ERR SETTINGS:", e.code, e.read().decode())
|
||||||
Reference in New Issue
Block a user