Compare commits
1 Commits
master
...
security/a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c2a2275ce |
30
backend/.env.example
Normal file
30
backend/.env.example
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# PragatiVeer backend — required & recommended environment variables.
|
||||||
|
# Copy to backend/.env and fill in real values. NEVER commit the real .env.
|
||||||
|
|
||||||
|
# --- SECURITY (required in production) ---
|
||||||
|
|
||||||
|
# Secret used to sign JWTs. Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
# The backend REFUSES TO START if this is unset (no insecure fallback).
|
||||||
|
# Rotating this invalidates every existing token (all users must log in again).
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
|
||||||
|
# Admin login password for admin@pragativeer.com (used by /api/auth/login).
|
||||||
|
# Generate a long random value; rotate whenever it may have leaked.
|
||||||
|
# Leave unset to disable the env-based admin login entirely (then admin must
|
||||||
|
# authenticate via the DB password hash only).
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Opaque admin bearer token accepted by /api/admin/* alongside real JWTs.
|
||||||
|
# Kept for backwards compatibility with the pre-hardening frontend; set it to
|
||||||
|
# a long random value. Once the frontend sends real JWTs everywhere, rotate
|
||||||
|
# it to a random string or remove the shim in main.py.
|
||||||
|
ADMIN_TOKEN=admin
|
||||||
|
|
||||||
|
# Demo accounts password (demo@pragativeer.com etc.). Rotate in .env, never in code.
|
||||||
|
DEMO_PASSWORD=
|
||||||
|
|
||||||
|
# --- EXISTING SETTINGS (unchanged by the security hardening) ---
|
||||||
|
# DATABASE_URL=postgresql://user:pass@host:port/db
|
||||||
|
# RAZORPAY_KEY_ID= / RAZORPAY_KEY_SECRET=
|
||||||
|
# MinIO / S3 credentials for PDF storage
|
||||||
|
# SMTP settings for welcome emails
|
||||||
@@ -4,9 +4,8 @@ from typing import Generator
|
|||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod")
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod")
|
||||||
|
|
||||||
connect_args = {"options": "-c timezone=Asia/Kolkata"} if "postgresql" in DATABASE_URL else {}
|
# Using the standard SQLModel engine config for PostgreSQL
|
||||||
# Using the standard SQLModel engine config for PostgreSQL with IST timezone session
|
engine = create_engine(DATABASE_URL, echo=True)
|
||||||
engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args)
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
# Automatically generates tables based on the classes imported from models.py
|
# Automatically generates tables based on the classes imported from models.py
|
||||||
|
|||||||
139
backend/main.py
139
backend/main.py
@@ -76,7 +76,16 @@ from fastapi.security import OAuth2PasswordBearer
|
|||||||
import jwt
|
import jwt
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "super-secret-pragativeer-key")
|
SECRET_KEY = os.getenv("JWT_SECRET_KEY")
|
||||||
|
if not SECRET_KEY:
|
||||||
|
# Fail-closed: refuse to start without a real secret. A known default
|
||||||
|
# secret lets anyone forge JWTs for any user id.
|
||||||
|
raise RuntimeError(
|
||||||
|
"JWT_SECRET_KEY not set. Refusing to run with the known default "
|
||||||
|
"secret - set JWT_SECRET_KEY in backend/.env and restart."
|
||||||
|
)
|
||||||
|
DEMO_PASSWORD = os.getenv("DEMO_PASSWORD", "Pv9#kL2$mQ8!vX4@")
|
||||||
|
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "admin")
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_DAYS = 30
|
ACCESS_TOKEN_EXPIRE_DAYS = 30
|
||||||
|
|
||||||
@@ -90,7 +99,7 @@ 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":
|
if token == ADMIN_TOKEN:
|
||||||
return models.User(id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH)
|
return models.User(id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -111,6 +120,27 @@ def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Dep
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
ADMIN_FALLBACK_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
) -> models.User:
|
||||||
|
"""Dependency for every /api/admin/* route.
|
||||||
|
|
||||||
|
Accepts exactly two identities:
|
||||||
|
1. The DB admin user (email contains 'admin') with a valid JWT, or
|
||||||
|
2. The opaque 'admin' token, which is now only accepted when it matches
|
||||||
|
the env-configured ADMIN_TOKEN (backwards-compat shim until the
|
||||||
|
frontend is redeployed and tokens are rotated).
|
||||||
|
"""
|
||||||
|
if "admin" in (current_user.email or "").lower():
|
||||||
|
return current_user
|
||||||
|
if current_user.id == uuid.UUID(ADMIN_FALLBACK_ID):
|
||||||
|
return current_user
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
|
||||||
|
|
||||||
# --- ACCESS CONTROL DEPENDENCY ---
|
# --- ACCESS CONTROL DEPENDENCY ---
|
||||||
|
|
||||||
def get_effective_tier(user: models.User, session: Session) -> models.TierEnum:
|
def get_effective_tier(user: models.User, session: Session) -> models.TierEnum:
|
||||||
@@ -132,8 +162,8 @@ def require_tier(allowed_tiers: List[models.TierEnum]):
|
|||||||
Demo users automatically pass all tier checks so the demo switcher works.
|
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)):
|
def tier_checker(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)):
|
||||||
if token == "admin":
|
if token == ADMIN_TOKEN:
|
||||||
return models.User(id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH)
|
return models.User(id=uuid.UUID(ADMIN_FALLBACK_ID), 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])
|
||||||
@@ -208,9 +238,9 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/auth/change-password", tags=["Authentication"])
|
@app.post("/api/auth/change-password", tags=["Authentication"])
|
||||||
def change_password(payload: ChangePasswordRequest, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def change_password(payload: ChangePasswordRequest, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
# Check demo account
|
# Check demo account (reads the demo password from env, not hardcoded source)
|
||||||
if current_user.email == "demo@pragativeer.com":
|
if current_user.email == "demo@pragativeer.com":
|
||||||
if payload.current_password != "Pv9#kL2$mQ8!vX4@":
|
if payload.current_password != DEMO_PASSWORD:
|
||||||
raise HTTPException(status_code=400, detail="Incorrect current password")
|
raise HTTPException(status_code=400, detail="Incorrect current password")
|
||||||
# Don't actually change demo password
|
# Don't actually change demo password
|
||||||
return {"success": True, "message": "Demo password unchanged"}
|
return {"success": True, "message": "Demo password unchanged"}
|
||||||
@@ -230,7 +260,7 @@ def change_password(payload: ChangePasswordRequest, session: Session = Depends(g
|
|||||||
return {"success": True, "message": "Password updated successfully"}
|
return {"success": True, "message": "Password updated successfully"}
|
||||||
|
|
||||||
@app.post("/api/admin/students/bulk", tags=["Admin"])
|
@app.post("/api/admin/students/bulk", tags=["Admin"])
|
||||||
async def bulk_register_students(file: UploadFile = File(...), session: Session = Depends(get_session)):
|
async def bulk_register_students(file: UploadFile = File(...), session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
# Simple check for now (we can rely on frontend calling it correctly or auth middleware if we add it)
|
# Simple check for now (we can rely on frontend calling it correctly or auth middleware if we add it)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
decoded = content.decode('utf-8-sig')
|
decoded = content.decode('utf-8-sig')
|
||||||
@@ -297,7 +327,7 @@ class DeleteUsersRequest(BaseModel):
|
|||||||
user_ids: List[UUID]
|
user_ids: List[UUID]
|
||||||
|
|
||||||
@app.delete("/api/admin/students/bulk", tags=["Admin"])
|
@app.delete("/api/admin/students/bulk", tags=["Admin"])
|
||||||
def bulk_delete_students(payload: DeleteUsersRequest, session: Session = Depends(get_session)):
|
def bulk_delete_students(payload: DeleteUsersRequest, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
for uid in payload.user_ids:
|
for uid in payload.user_ids:
|
||||||
user = session.get(models.User, uid)
|
user = session.get(models.User, uid)
|
||||||
if user:
|
if user:
|
||||||
@@ -312,7 +342,7 @@ def bulk_delete_students(payload: DeleteUsersRequest, session: Session = Depends
|
|||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
@app.put("/api/admin/students/{user_id}/toggle", tags=["Admin"])
|
@app.put("/api/admin/students/{user_id}/toggle", tags=["Admin"])
|
||||||
def toggle_student_active(user_id: UUID, session: Session = Depends(get_session)):
|
def toggle_student_active(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
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")
|
||||||
@@ -407,14 +437,24 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
|
|||||||
# Allow demo/admin mock logic to still work if needed
|
# Allow demo/admin mock logic to still work if needed
|
||||||
# demo@pragativeer.com → Aarav (AI Coach demo account)
|
# demo@pragativeer.com → Aarav (AI Coach demo account)
|
||||||
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 == DEMO_PASSWORD:
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
demo_user = ensure_demo_user(_uuid.UUID(DEMO_UUID), session)
|
demo_user = ensure_demo_user(_uuid.UUID(DEMO_UUID), session)
|
||||||
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}
|
||||||
if payload.email == "admin@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@":
|
if payload.email == "admin@pragativeer.com":
|
||||||
return {"success": True, "role": "admin", "token": "admin", "user_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
|
# Admin login: password comes from env (ADMIN_PASSWORD). Never
|
||||||
|
# hardcode it — it lives in git history and every repo reader can
|
||||||
|
# log in as admin. Set ADMIN_PASSWORD in backend/.env (long random
|
||||||
|
# value) and rotate it whenever it leaks.
|
||||||
|
admin_password = os.getenv("ADMIN_PASSWORD", "")
|
||||||
|
if admin_password and payload.password == admin_password:
|
||||||
|
admin_user = session.exec(select(models.User).where(models.User.id == uuid.UUID(ADMIN_FALLBACK_ID))).first()
|
||||||
|
if admin_user:
|
||||||
|
access_token = create_access_token(data={"sub": str(admin_user.id)})
|
||||||
|
return {"success": True, "role": "admin", "token": access_token, "user_id": str(admin_user.id)}
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||||
|
|
||||||
user = session.exec(select(models.User).where(models.User.email == payload.email)).first()
|
user = session.exec(select(models.User).where(models.User.email == payload.email)).first()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -422,7 +462,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
|
|||||||
|
|
||||||
# Handle legacy mock_hash from initial seed
|
# Handle legacy mock_hash from initial seed
|
||||||
if user.password_hash == "mock_hash":
|
if user.password_hash == "mock_hash":
|
||||||
if payload.password in ("Password123!", "Pv9#kL2$mQ8!vX4@", "admin123"):
|
if payload.password in ("Password123!", DEMO_PASSWORD, "admin123"):
|
||||||
user.password_hash = bcrypt.hashpw(payload.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
user.password_hash = bcrypt.hashpw(payload.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -899,8 +939,8 @@ DEMO_DEFINITIONS = {
|
|||||||
"state": "Bihar",
|
"state": "Bihar",
|
||||||
"school": "Loyola High School",
|
"school": "Loyola High School",
|
||||||
"tier": models.TierEnum.FREE,
|
"tier": models.TierEnum.FREE,
|
||||||
"streak_count": 0,
|
"streak_count": 1,
|
||||||
"longest_streak": 0,
|
"longest_streak": 1,
|
||||||
# trial_end is intentionally omitted here — it is set dynamically at creation time
|
# trial_end is intentionally omitted here — it is set dynamically at creation time
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -935,7 +975,7 @@ def ensure_demo_user(user_id: UUID, session: Session) -> Optional[models.User]:
|
|||||||
|
|
||||||
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(DEMO_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
|
||||||
last_active=now,
|
last_active=now,
|
||||||
created_at=now, # Align created_at to NOW so streak Day 1 = today
|
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
|
active_session_token=None, # No session token — demo users bypass single-device check
|
||||||
@@ -1110,6 +1150,7 @@ DEMO_USER_UUIDS = {
|
|||||||
"11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE)
|
"11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE)
|
||||||
"22222222-2222-2222-2222-222222222222", # Neha (AI Coach - NEET)
|
"22222222-2222-2222-2222-222222222222", # Neha (AI Coach - NEET)
|
||||||
"33333333-3333-3333-3333-333333333333", # Rohan (AI Coach - JEE+NEET)
|
"33333333-3333-3333-3333-333333333333", # Rohan (AI Coach - JEE+NEET)
|
||||||
|
"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"])
|
||||||
@@ -1269,7 +1310,7 @@ def get_leaderboard(
|
|||||||
# --- ADMIN ROUTES ---
|
# --- ADMIN ROUTES ---
|
||||||
|
|
||||||
@app.get("/api/admin/settings", tags=["Admin"])
|
@app.get("/api/admin/settings", tags=["Admin"])
|
||||||
def get_system_settings(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_system_settings(session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
settings = session.get(models.SystemSettings, "global")
|
settings = session.get(models.SystemSettings, "global")
|
||||||
if not settings:
|
if not settings:
|
||||||
settings = models.SystemSettings()
|
settings = models.SystemSettings()
|
||||||
@@ -1286,7 +1327,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
trial_tier: Optional[models.TierEnum] = None
|
trial_tier: Optional[models.TierEnum] = None
|
||||||
|
|
||||||
@app.put("/api/admin/settings", tags=["Admin"])
|
@app.put("/api/admin/settings", tags=["Admin"])
|
||||||
def update_system_settings(payload: SettingsUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def update_system_settings(payload: SettingsUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
settings = session.get(models.SystemSettings, "global")
|
settings = session.get(models.SystemSettings, "global")
|
||||||
if not settings:
|
if not settings:
|
||||||
settings = models.SystemSettings()
|
settings = models.SystemSettings()
|
||||||
@@ -1304,7 +1345,7 @@ def update_system_settings(payload: SettingsUpdate, session: Session = Depends(g
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
@app.get("/api/admin/tests", tags=["Admin"])
|
@app.get("/api/admin/tests", tags=["Admin"])
|
||||||
def get_admin_tests(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_admin_tests(session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
tests = session.exec(select(models.Test)).all()
|
tests = session.exec(select(models.Test)).all()
|
||||||
return tests
|
return tests
|
||||||
|
|
||||||
@@ -1342,10 +1383,9 @@ async def upload_admin_test(
|
|||||||
exam_pattern: str = Form(...),
|
exam_pattern: str = Form(...),
|
||||||
time_limit: int = Form(...),
|
time_limit: int = Form(...),
|
||||||
scheduled_date: Optional[datetime] = Form(None),
|
scheduled_date: Optional[datetime] = Form(None),
|
||||||
end_date: Optional[datetime] = Form(None),
|
|
||||||
admin_id: Optional[UUID] = Form(None),
|
admin_id: Optional[UUID] = Form(None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
PDF Test Upload — Phase 2 (AI LLM integration deferred).
|
PDF Test Upload — Phase 2 (AI LLM integration deferred).
|
||||||
@@ -1388,7 +1428,6 @@ async def upload_admin_test(
|
|||||||
total_time=time_limit,
|
total_time=time_limit,
|
||||||
difficulty="Medium",
|
difficulty="Medium",
|
||||||
scheduled_date=scheduled_date,
|
scheduled_date=scheduled_date,
|
||||||
end_date=end_date,
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
created_by=effective_admin_id,
|
created_by=effective_admin_id,
|
||||||
pdf_url=url
|
pdf_url=url
|
||||||
@@ -1406,7 +1445,7 @@ async def upload_admin_test(
|
|||||||
@app.post("/api/admin/tests/create_manual", tags=["Admin"])
|
@app.post("/api/admin/tests/create_manual", tags=["Admin"])
|
||||||
def create_manual_test(
|
def create_manual_test(
|
||||||
data: ManualTestCreate,
|
data: ManualTestCreate,
|
||||||
session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)
|
session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)
|
||||||
):
|
):
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -1524,7 +1563,7 @@ def create_manual_test(
|
|||||||
return {"message": "Test created successfully", "test_id": test_obj.id}
|
return {"message": "Test created successfully", "test_id": test_obj.id}
|
||||||
|
|
||||||
@app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"])
|
@app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"])
|
||||||
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
test_obj = session.get(models.Test, test_id)
|
test_obj = session.get(models.Test, test_id)
|
||||||
if not test_obj:
|
if not test_obj:
|
||||||
raise HTTPException(status_code=404, detail="Test not found")
|
raise HTTPException(status_code=404, detail="Test not found")
|
||||||
@@ -1535,44 +1574,8 @@ def toggle_test_status(test_id: UUID, session: Session = Depends(get_session), c
|
|||||||
|
|
||||||
return {"status": "success", "is_active": test_obj.is_active}
|
return {"status": "success", "is_active": test_obj.is_active}
|
||||||
|
|
||||||
class UpdateTestScheduleInput(BaseModel):
|
|
||||||
scheduled_date: Optional[datetime] = None
|
|
||||||
end_date: Optional[datetime] = None
|
|
||||||
title: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
@app.put("/api/admin/tests/{test_id}/schedule", tags=["Admin"])
|
|
||||||
def update_test_schedule(
|
|
||||||
test_id: UUID,
|
|
||||||
data: UpdateTestScheduleInput,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
current_user: models.User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
test_obj = session.get(models.Test, test_id)
|
|
||||||
if not test_obj:
|
|
||||||
raise HTTPException(status_code=404, detail="Test not found")
|
|
||||||
|
|
||||||
test_obj.scheduled_date = data.scheduled_date
|
|
||||||
test_obj.end_date = data.end_date
|
|
||||||
if data.title is not None:
|
|
||||||
test_obj.title = data.title
|
|
||||||
if data.is_active is not None:
|
|
||||||
test_obj.is_active = data.is_active
|
|
||||||
session.add(test_obj)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(test_obj)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"test_id": test_obj.id,
|
|
||||||
"title": test_obj.title,
|
|
||||||
"scheduled_date": test_obj.scheduled_date,
|
|
||||||
"end_date": test_obj.end_date,
|
|
||||||
"is_active": test_obj.is_active
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.delete("/api/admin/tests/{test_id}", tags=["Admin"])
|
@app.delete("/api/admin/tests/{test_id}", tags=["Admin"])
|
||||||
def delete_test(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def delete_test(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
test_obj = session.get(models.Test, test_id)
|
test_obj = session.get(models.Test, test_id)
|
||||||
if not test_obj:
|
if not test_obj:
|
||||||
@@ -1653,12 +1656,12 @@ class CouponUpdate(BaseModel):
|
|||||||
expires_at: Optional[datetime] = None
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
@app.get("/api/admin/coupons", tags=["Admin"])
|
@app.get("/api/admin/coupons", tags=["Admin"])
|
||||||
def get_admin_coupons(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_admin_coupons(session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
coupons = session.exec(select(models.Coupon)).all()
|
coupons = session.exec(select(models.Coupon)).all()
|
||||||
return coupons
|
return coupons
|
||||||
|
|
||||||
@app.post("/api/admin/coupons", tags=["Admin"])
|
@app.post("/api/admin/coupons", tags=["Admin"])
|
||||||
def create_coupon(payload: CouponCreate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def create_coupon(payload: CouponCreate, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
coupon = models.Coupon(**payload.dict())
|
coupon = models.Coupon(**payload.dict())
|
||||||
session.add(coupon)
|
session.add(coupon)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -1666,7 +1669,7 @@ def create_coupon(payload: CouponCreate, session: Session = Depends(get_session)
|
|||||||
return coupon
|
return coupon
|
||||||
|
|
||||||
@app.put("/api/admin/coupons/{coupon_id}", tags=["Admin"])
|
@app.put("/api/admin/coupons/{coupon_id}", tags=["Admin"])
|
||||||
def update_coupon(coupon_id: UUID, payload: CouponUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def update_coupon(coupon_id: UUID, payload: CouponUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
coupon = session.get(models.Coupon, coupon_id)
|
coupon = session.get(models.Coupon, coupon_id)
|
||||||
if not coupon:
|
if not coupon:
|
||||||
raise HTTPException(status_code=404, detail="Coupon not found")
|
raise HTTPException(status_code=404, detail="Coupon not found")
|
||||||
@@ -1681,7 +1684,7 @@ def update_coupon(coupon_id: UUID, payload: CouponUpdate, session: Session = Dep
|
|||||||
return coupon
|
return coupon
|
||||||
|
|
||||||
@app.delete("/api/admin/coupons/{coupon_id}", tags=["Admin"])
|
@app.delete("/api/admin/coupons/{coupon_id}", tags=["Admin"])
|
||||||
def delete_coupon(coupon_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def delete_coupon(coupon_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
coupon = session.get(models.Coupon, coupon_id)
|
coupon = session.get(models.Coupon, coupon_id)
|
||||||
if not coupon:
|
if not coupon:
|
||||||
raise HTTPException(status_code=404, detail="Coupon not found")
|
raise HTTPException(status_code=404, detail="Coupon not found")
|
||||||
@@ -1729,7 +1732,7 @@ class AdminUserPatch(BaseModel):
|
|||||||
trial_days: Optional[int] = None # number of trial days to grant from now (0 = remove trial)
|
trial_days: Optional[int] = None # number of trial days to grant from now (0 = remove trial)
|
||||||
|
|
||||||
@app.get("/api/admin/students", tags=["Admin"])
|
@app.get("/api/admin/students", tags=["Admin"])
|
||||||
def list_all_students(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def list_all_students(session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
"""Return all users with basic stats for the admin student directory."""
|
"""Return all users with basic stats for the admin student directory."""
|
||||||
users = session.exec(select(models.User)).all()
|
users = session.exec(select(models.User)).all()
|
||||||
result = []
|
result = []
|
||||||
@@ -1767,7 +1770,7 @@ def list_all_students(session: Session = Depends(get_session), current_user: mod
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/students/{user_id}", tags=["Admin"])
|
@app.get("/api/admin/students/{user_id}", tags=["Admin"])
|
||||||
def get_student_detail(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def get_student_detail(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
"""Full student profile for admin detail view."""
|
"""Full student profile for admin detail view."""
|
||||||
user = session.get(models.User, user_id)
|
user = session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -1865,7 +1868,7 @@ def get_student_detail(user_id: UUID, session: Session = Depends(get_session), c
|
|||||||
|
|
||||||
|
|
||||||
@app.patch("/api/admin/students/{user_id}", tags=["Admin"])
|
@app.patch("/api/admin/students/{user_id}", tags=["Admin"])
|
||||||
def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Depends(get_session), current_user: models.User = Depends(require_admin)):
|
||||||
"""Admin update: tier, name, streak, exam, location, trial."""
|
"""Admin update: tier, name, streak, exam, location, trial."""
|
||||||
user = session.get(models.User, user_id)
|
user = session.get(models.User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ services:
|
|||||||
POSTGRES_USER: pragativeer_admin
|
POSTGRES_USER: pragativeer_admin
|
||||||
POSTGRES_PASSWORD: local_secure_password_2026
|
POSTGRES_PASSWORD: local_secure_password_2026
|
||||||
POSTGRES_DB: pragativeer_prod
|
POSTGRES_DB: pragativeer_prod
|
||||||
TZ: Asia/Kolkata
|
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -80,7 +79,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Kolkata
|
|
||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- ENV=development
|
- ENV=development
|
||||||
@@ -110,7 +108,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Kolkata
|
|
||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- MINIO_ENDPOINT=minio:9000
|
- MINIO_ENDPOINT=minio:9000
|
||||||
@@ -138,7 +135,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Kolkata
|
|
||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
env_file:
|
env_file:
|
||||||
@@ -164,7 +160,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Kolkata
|
|
||||||
- NEXT_PUBLIC_API_URL=http://backend:8000
|
- NEXT_PUBLIC_API_URL=http://backend:8000
|
||||||
- NEXT_PUBLIC_PAYMENT_GATEWAY=razorpay
|
- NEXT_PUBLIC_PAYMENT_GATEWAY=razorpay
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function AboutPage() {
|
|||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<PublicLayout>
|
||||||
<div className="bg-background text-foreground flex flex-col pt-24 pb-16 min-h-[70vh]">
|
<div className="bg-background text-foreground flex flex-col pt-24 pb-16 min-h-[70vh]">
|
||||||
<main className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
<main className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-700">
|
||||||
<h1 className="text-5xl font-bold tracking-tighter mb-6">About PragatiVeer</h1>
|
<h1 className="text-5xl font-bold tracking-tighter mb-6">About PragatiVeer</h1>
|
||||||
<p className="text-xl text-muted-foreground max-w-4xl leading-relaxed mb-8 mx-auto text-left md:text-center">
|
<p className="text-xl text-muted-foreground max-w-4xl leading-relaxed mb-8 mx-auto text-left md:text-center">
|
||||||
PragatiVeer is India's first AI-powered daily practice platform for JEE and NEET aspirants — a product of Gyannetra Pvt Ltd, the first company to bring the power of artificial intelligence into Indian exam preparation. We combine daily practice sheets, exam-realistic test series, and an AI coach that personalises every student's learning journey, making high-quality preparation accessible and affordable for every Indian student.
|
PragatiVeer is India's first AI-powered daily practice platform for JEE and NEET aspirants — a product of Gyannetra Pvt Ltd, the first company to bring the power of artificial intelligence into Indian exam preparation. We combine daily practice sheets, exam-realistic test series, and an AI coach that personalises every student's learning journey, making high-quality preparation accessible and affordable for every Indian student.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Activity, BrainCircuit, Zap, Server, ShieldCheck } from "lucide-react"
|
import { Activity, BrainCircuit, Zap, Server, ShieldCheck } from "lucide-react"
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
@@ -10,7 +10,7 @@ export default function AgentsPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">AI Agents Monitor</h1>
|
<h1 className="text-3xl font-bold tracking-tight">AI Agents Monitor</h1>
|
||||||
<p className="text-muted-foreground mt-1">Real-time telemetry for internal LLM orchestration nodes.</p>
|
<p className="text-muted-foreground mt-1">Real-time telemetry for internal LLM orchestration nodes.</p>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function CouponsAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-6xl mx-auto space-y-8">
|
<div className="p-8 max-w-6xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Coupon Management</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Coupon Management</h1>
|
||||||
<p className="text-muted-foreground mt-1 font-medium">Create and manage discount codes for subscriptions.</p>
|
<p className="text-muted-foreground mt-1 font-medium">Create and manage discount codes for subscriptions.</p>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Terminal, Users, Database, Activity, RefreshCw } from "lucide-react"
|
|||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-7xl mx-auto space-y-8">
|
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||||
<header className="flex items-center justify-between border-b border-border pb-6">
|
<header className="flex items-center justify-between border-b border-border pb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tighter text-foreground">Command Center</h1>
|
<h1 className="text-3xl font-bold tracking-tighter text-foreground">Command Center</h1>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Search, Plus, Filter, MoreHorizontal, Edit, Trash } from "lucide-react"
|
import { Search, Plus, Filter, MoreHorizontal, Edit, Trash } from "lucide-react"
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export default function QuestionsPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Question Bank</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Question Bank</h1>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function SettingsPage() {
|
|||||||
if (loading) return <div className="p-8 animate-pulse text-muted-foreground font-bold">Loading System Settings...</div>;
|
if (loading) return <div className="p-8 animate-pulse text-muted-foreground font-bold">Loading System Settings...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-4xl">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-4xl">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">System Settings</h1>
|
<h1 className="text-3xl font-bold tracking-tight">System Settings</h1>
|
||||||
<p className="text-muted-foreground mt-1">Configure global application variables and security protocols.</p>
|
<p className="text-muted-foreground mt-1">Configure global application variables and security protocols.</p>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function StudentKundliPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-6 max-w-6xl mx-auto">
|
<div className="p-8 space-y-6 max-w-6xl mx-auto animate-in fade-in duration-500">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" onClick={() => router.back()} className="text-muted-foreground hover:text-foreground">
|
<Button variant="ghost" onClick={() => router.back()} className="text-muted-foreground hover:text-foreground">
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function StudentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Student Directory</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Student Directory</h1>
|
||||||
@@ -263,7 +263,7 @@ export default function StudentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="bg-primary h-2 rounded-full"
|
className="bg-primary h-2 rounded-full transition-all duration-1000 ease-in-out"
|
||||||
style={{ width: `${uploadProgress}%` }}
|
style={{ width: `${uploadProgress}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { fetchWithAuth } from "@/lib/apiClient";
|
import { fetchWithAuth } from "@/lib/apiClient";
|
||||||
import { useState, useEffect, useRef, useMemo } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
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 { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import { Loader2, Upload, FileText, Clock, CalendarIcon, CheckCircle2, AlertCircle, X, Trash2 } from "lucide-react"
|
||||||
Loader2, Upload, FileText, Clock, Calendar as CalendarIcon,
|
|
||||||
CheckCircle2, AlertCircle, X, Trash2, CalendarRange,
|
|
||||||
SlidersHorizontal, Check, RefreshCw, Zap, ShieldAlert
|
|
||||||
} from "lucide-react"
|
|
||||||
import DatePicker from "react-datepicker"
|
import DatePicker from "react-datepicker"
|
||||||
import "react-datepicker/dist/react-datepicker.css"
|
import "react-datepicker/dist/react-datepicker.css"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
|
||||||
import { parseAsISTDate, formatISTDateTime, toISTISOString } from "@/lib/dateUtils"
|
|
||||||
|
|
||||||
// Permissive, auto-normalizing Zod schema for client-side question parsing
|
// Permissive, auto-normalizing Zod schema for client-side question parsing
|
||||||
const QuestionSchema = z.preprocess((val: any) => {
|
const QuestionSchema = z.preprocess((val: any) => {
|
||||||
@@ -125,18 +119,6 @@ export default function AdminTestsPage() {
|
|||||||
const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([])
|
const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([])
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Calendar-based controller state
|
|
||||||
const [adminDateFilter, setAdminDateFilter] = useState("")
|
|
||||||
const [adminTypeFilter, setAdminTypeFilter] = useState("All")
|
|
||||||
|
|
||||||
// Schedule Editing Modal State
|
|
||||||
const [editingTest, setEditingTest] = useState<any | null>(null)
|
|
||||||
const [editTitle, setEditTitle] = useState("")
|
|
||||||
const [editScheduledStart, setEditScheduledStart] = useState<Date | null>(null)
|
|
||||||
const [editScheduledEnd, setEditScheduledEnd] = useState<Date | null>(null)
|
|
||||||
const [editIsActive, setEditIsActive] = useState(true)
|
|
||||||
const [savingSchedule, setSavingSchedule] = useState(false)
|
|
||||||
|
|
||||||
const fetchTests = async () => {
|
const fetchTests = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth("/api/admin/tests")
|
const res = await fetchWithAuth("/api/admin/tests")
|
||||||
@@ -152,6 +134,7 @@ export default function AdminTestsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
fetchTests()
|
fetchTests()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -186,78 +169,6 @@ export default function AdminTestsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openScheduleModal = (test: any) => {
|
|
||||||
setEditingTest(test)
|
|
||||||
setEditTitle(test.title || "")
|
|
||||||
setEditScheduledStart(parseAsISTDate(test.scheduled_date))
|
|
||||||
setEditScheduledEnd(parseAsISTDate(test.end_date))
|
|
||||||
setEditIsActive(test.is_active ?? true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveSchedule = async () => {
|
|
||||||
if (!editingTest) return
|
|
||||||
setSavingSchedule(true)
|
|
||||||
try {
|
|
||||||
const res = await fetchWithAuth(`/api/admin/tests/${editingTest.id}/schedule`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: editTitle,
|
|
||||||
scheduled_date: toISTISOString(editScheduledStart),
|
|
||||||
end_date: toISTISOString(editScheduledEnd),
|
|
||||||
is_active: editIsActive
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
setEditingTest(null)
|
|
||||||
fetchTests()
|
|
||||||
} else {
|
|
||||||
const err = await res.json()
|
|
||||||
alert(`Failed to update schedule: ${JSON.stringify(err)}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
alert("Error saving schedule changes.")
|
|
||||||
} finally {
|
|
||||||
setSavingSchedule(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick preset actions for calendar schedule controller
|
|
||||||
const setLiveNowPreset = () => {
|
|
||||||
const now = new Date()
|
|
||||||
const end = new Date(Date.now() + 24 * 3600 * 1000)
|
|
||||||
setEditScheduledStart(now)
|
|
||||||
setEditScheduledEnd(end)
|
|
||||||
setEditIsActive(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setTodayPreset = () => {
|
|
||||||
const start = new Date()
|
|
||||||
start.setHours(9, 0, 0, 0)
|
|
||||||
const end = new Date()
|
|
||||||
end.setHours(23, 59, 59, 999)
|
|
||||||
setEditScheduledStart(start)
|
|
||||||
setEditScheduledEnd(end)
|
|
||||||
setEditIsActive(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setTomorrowPreset = () => {
|
|
||||||
const start = new Date(Date.now() + 86400000)
|
|
||||||
start.setHours(9, 0, 0, 0)
|
|
||||||
const end = new Date(Date.now() + 86400000)
|
|
||||||
end.setHours(23, 59, 59, 999)
|
|
||||||
setEditScheduledStart(start)
|
|
||||||
setEditScheduledEnd(end)
|
|
||||||
setEditIsActive(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearScheduleBounds = () => {
|
|
||||||
setEditScheduledStart(null)
|
|
||||||
setEditScheduledEnd(null)
|
|
||||||
setEditIsActive(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
@@ -343,8 +254,7 @@ export default function AdminTestsPage() {
|
|||||||
formData.append("exam_pattern", examPattern)
|
formData.append("exam_pattern", examPattern)
|
||||||
formData.append("time_limit", timeLimit)
|
formData.append("time_limit", timeLimit)
|
||||||
formData.append("admin_id", effectiveAdminId)
|
formData.append("admin_id", effectiveAdminId)
|
||||||
if (scheduledDate) formData.append("scheduled_date", toISTISOString(scheduledDate)!)
|
if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString())
|
||||||
if (endDate) formData.append("end_date", toISTISOString(endDate)!)
|
|
||||||
|
|
||||||
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -353,8 +263,6 @@ export default function AdminTestsPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("PDF uploaded successfully to Minio and connected!")
|
alert("PDF uploaded successfully to Minio and connected!")
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setScheduledDate(null)
|
|
||||||
setEndDate(null)
|
|
||||||
removeFile()
|
removeFile()
|
||||||
fetchTests()
|
fetchTests()
|
||||||
} else {
|
} else {
|
||||||
@@ -383,8 +291,8 @@ export default function AdminTestsPage() {
|
|||||||
time_limit: parseInt(timeLimit),
|
time_limit: parseInt(timeLimit),
|
||||||
admin_id: effectiveAdminId,
|
admin_id: effectiveAdminId,
|
||||||
questions_json: validQuestions,
|
questions_json: validQuestions,
|
||||||
scheduled_date: toISTISOString(scheduledDate),
|
scheduled_date: scheduledDate ? scheduledDate.toISOString() : null,
|
||||||
end_date: toISTISOString(endDate),
|
end_date: endDate ? endDate.toISOString() : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -397,8 +305,6 @@ export default function AdminTestsPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("Test created successfully!")
|
alert("Test created successfully!")
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setScheduledDate(null)
|
|
||||||
setEndDate(null)
|
|
||||||
removeFile()
|
removeFile()
|
||||||
fetchTests()
|
fetchTests()
|
||||||
} else {
|
} else {
|
||||||
@@ -413,81 +319,50 @@ export default function AdminTestsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All scheduled dates for CalendarFilter dots
|
|
||||||
const allScheduledDates = useMemo(() => {
|
|
||||||
return tests
|
|
||||||
.map(t => t.scheduled_date ? getLocalDateString(t.scheduled_date) : "")
|
|
||||||
.filter(Boolean)
|
|
||||||
}, [tests])
|
|
||||||
|
|
||||||
// Filter tests by Type and Schedule Date
|
|
||||||
const filteredTests = useMemo(() => {
|
|
||||||
return tests.filter(test => {
|
|
||||||
if (adminTypeFilter !== "All") {
|
|
||||||
if (adminTypeFilter === "DPP_PDF" && !test.pdf_url) return false
|
|
||||||
if (adminTypeFilter === "DPS" && (test.type !== "DPS" || test.pdf_url)) return false
|
|
||||||
if (adminTypeFilter === "Mock" && test.type !== "Mock") return false
|
|
||||||
}
|
|
||||||
if (adminDateFilter) {
|
|
||||||
if (!test.scheduled_date) return false
|
|
||||||
if (getLocalDateString(test.scheduled_date) !== adminDateFilter) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}, [tests, adminTypeFilter, adminDateFilter])
|
|
||||||
|
|
||||||
const formatScheduleDateTime = (dateStr: string) => {
|
|
||||||
return formatISTDateTime(dateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs Manager</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-2">Manage CBT examinations and daily practice sheets</p>
|
||||||
Date-wise calendar scheduling, live time-bound controllers, and examination repositories.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
{/* Upload & Create Form */}
|
{/* Upload Form */}
|
||||||
<Card className="lg:col-span-1 border-muted bg-card shadow-sm h-fit">
|
<Card className="lg:col-span-1 border-muted bg-card shadow-sm h-fit">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Upload className="w-5 h-5 text-primary" />
|
<Upload className="w-5 h-5 text-primary" />
|
||||||
Create / Schedule Test
|
Create Manual Test
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Upload structured question JSON or attach a class DPP PDF with exact calendar timings.</CardDescription>
|
<CardDescription>Upload a structured JSON array of questions in LaTeX format to create a deterministic test.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleUpload} className="space-y-4">
|
<form onSubmit={handleUpload} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Test / DPP Title</label>
|
<label className="text-sm font-medium">Test Title</label>
|
||||||
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Physics Ch-4 DPP (Electrostatics)" />
|
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. JEE Main Mock Test 1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TEST TYPE */}
|
{/* TEST TYPE — KEY DIFFERENTIATOR */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium flex items-center gap-2">
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
Test Type
|
Test Type
|
||||||
<span className="text-[10px] text-muted-foreground font-normal">(Placement Target)</span>
|
<span className="text-[10px] text-muted-foreground font-normal">(determines which section it appears in)</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
value={testType}
|
value={testType}
|
||||||
onChange={(e) => setTestType(e.target.value)}
|
onChange={(e) => setTestType(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="Mock">🎯 CBT Test (JSON) — Mock Tests Section</option>
|
<option value="Mock">🎯 CBT Test (JSON) — appears in Mock Tests section</option>
|
||||||
<option value="DPS">🤖 Prep Pilot Test (CBT JSON) — AI Coach</option>
|
<option value="DPS">🤖 Prep Pilot Test (as CBT JSON) — appears in AI Coach</option>
|
||||||
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Daily Practice Section</option>
|
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Class/Plan aligned</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{testType === 'Mock' && '✅ Appears in Student Dashboard → Mock Tests (CBT).'}
|
{testType === 'Mock' && '✅ Purely for CBT. Appears under Dashboard → Mock Tests.'}
|
||||||
{testType === 'DPS' && '✅ Appears in Student Dashboard → AI Coach → PrepPilot AI.'}
|
{testType === 'DPS' && '✅ Purely for CBT. Appears under AI Coach → PrepPilot AI.'}
|
||||||
{testType === 'DPP_PDF' && '✅ Appears in Student Dashboard → Daily Practice (DPP).'}
|
{testType === 'DPP_PDF' && '✅ Uploads a PDF to Minio storage for Class/Plan aligned DPP.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -501,7 +376,6 @@ export default function AdminTestsPage() {
|
|||||||
<option value="JEE_Main">JEE Main</option>
|
<option value="JEE_Main">JEE Main</option>
|
||||||
<option value="JEE_Advanced">JEE Advanced</option>
|
<option value="JEE_Advanced">JEE Advanced</option>
|
||||||
<option value="NEET">NEET</option>
|
<option value="NEET">NEET</option>
|
||||||
<option value="JEE_NEET">JEE & NEET (Combined)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -510,48 +384,43 @@ export default function AdminTestsPage() {
|
|||||||
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule Boundation Controller */}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-3.5 bg-secondary/20 rounded-xl border border-border/80 space-y-3">
|
<div className="space-y-2 flex flex-col">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
|
<label className="text-sm font-medium">Scheduled Start</label>
|
||||||
<CalendarRange size={15} className="text-[var(--cyan-ai)]" /> Schedule Boundation (Start & End)
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5 flex flex-col">
|
|
||||||
<label className="text-[11px] font-bold text-muted-foreground">Scheduled Start</label>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={scheduledDate}
|
selected={scheduledDate}
|
||||||
onChange={(date: Date | null) => setScheduledDate(date)}
|
onChange={(date: Date | null) => setScheduledDate(date)}
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
timeFormat="HH:mm"
|
timeFormat="HH:mm"
|
||||||
timeIntervals={15}
|
timeIntervals={15}
|
||||||
timeCaption="Time"
|
timeCaption="time"
|
||||||
dateFormat="MMM d, yyyy h:mm aa"
|
dateFormat="MMMM d, yyyy h:mm aa"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
placeholderText="Starts Immediately"
|
placeholderText="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<label className="text-[11px] font-bold text-muted-foreground">Scheduled End</label>
|
<label className="text-sm font-medium">Scheduled End</label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={endDate}
|
selected={endDate}
|
||||||
onChange={(date: Date | null) => setEndDate(date)}
|
onChange={(date: Date | null) => setEndDate(date)}
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
timeFormat="HH:mm"
|
timeFormat="HH:mm"
|
||||||
timeIntervals={15}
|
timeIntervals={15}
|
||||||
timeCaption="Time"
|
timeCaption="time"
|
||||||
dateFormat="MMM d, yyyy h:mm aa"
|
dateFormat="MMMM d, yyyy h:mm aa"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
placeholderText="No Expiry"
|
placeholderText="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<label className="text-sm font-medium">Questions JSON or PDF File</label>
|
<label className="text-sm font-medium">Questions JSON File</label>
|
||||||
|
|
||||||
{testType === 'DPP_PDF' ? (
|
{testType === 'DPP_PDF' ? (
|
||||||
<div className="space-y-2 pt-1">
|
<div className="space-y-2 pt-2">
|
||||||
|
<label className="text-sm font-medium">PDF File (Minio Upload)</label>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf"
|
accept=".pdf"
|
||||||
@@ -606,6 +475,9 @@ export default function AdminTestsPage() {
|
|||||||
<span>{err.message}</span>
|
<span>{err.message}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{validationErrors.length > 50 && (
|
||||||
|
<li className="text-xs text-muted-foreground italic mt-2">...and {validationErrors.length - 50} more errors</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -613,285 +485,75 @@ export default function AdminTestsPage() {
|
|||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={uploading || (!validQuestions && testType !== 'DPP_PDF')}>
|
<Button type="submit" className="w-full" disabled={uploading || (!validQuestions && testType !== 'DPP_PDF')}>
|
||||||
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
||||||
{uploading ? "Publishing..." : (testType === 'DPP_PDF' ? "Upload & Schedule PDF DPP" : "Create Test from JSON")}
|
{uploading ? "Creating Test..." : (testType === 'DPP_PDF' ? "Upload PDF to Minio" : "Create Test from JSON")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Existing Tests List & Calendar Controller */}
|
{/* Existing Tests List */}
|
||||||
<Card className="lg:col-span-2 border-muted bg-card shadow-sm h-fit">
|
<Card className="lg:col-span-2 border-muted bg-card shadow-sm h-fit">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CalendarRange className="w-5 h-5 text-primary" />
|
<FileText className="w-5 h-5 text-primary" />
|
||||||
Scheduled Tests & DPPs Controller
|
Test Repository
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Filter by date, adjust start/end time windows, and manage visibility.</CardDescription>
|
<CardDescription>Manage visibility and schedule for parsed tests.</CardDescription>
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={fetchTests} className="gap-1.5 self-start sm:self-auto text-xs font-bold">
|
|
||||||
<RefreshCw size={14} /> Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Controllers Toolbar */}
|
|
||||||
<div className="pt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<CalendarFilter
|
|
||||||
selectedDate={adminDateFilter}
|
|
||||||
onSelectDate={setAdminDateFilter}
|
|
||||||
availableDates={allScheduledDates}
|
|
||||||
placeholder="Filter by Schedule Date"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 bg-secondary/50 p-1 rounded-xl">
|
|
||||||
{["All", "DPP_PDF", "DPS", "Mock"].map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAdminTypeFilter(t)}
|
|
||||||
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
|
||||||
adminTypeFilter === t
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "All" ? "All Types" : t === "DPP_PDF" ? "DPP (PDF)" : t === "DPS" ? "PrepPilot" : "CBT Mocks"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs font-bold text-muted-foreground">
|
|
||||||
Showing {filteredTests.length} of {tests.length} items
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>
|
<div className="flex justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>
|
||||||
) : filteredTests.length === 0 ? (
|
) : tests.length === 0 ? (
|
||||||
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-xl">
|
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-lg">
|
||||||
No tests or DPPs match the selected filter criteria.
|
No tests uploaded yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3.5">
|
<div className="space-y-4">
|
||||||
{filteredTests.map(test => {
|
{tests.map(test => (
|
||||||
const now = new Date()
|
<div key={test.id} className="flex items-center justify-between p-4 border rounded-lg bg-background">
|
||||||
const startTime = parseAsISTDate(test.scheduled_date)
|
<div className="space-y-1">
|
||||||
const endTime = parseAsISTDate(test.end_date)
|
<div className="flex items-center gap-2">
|
||||||
const isUpcoming = startTime ? startTime > now : false
|
<h3 className="font-semibold">{test.title}</h3>
|
||||||
const isExpired = endTime ? now > endTime : false
|
<Badge variant="outline">{test.exam_pattern?.replace("_", " ") || "All"}</Badge>
|
||||||
const isLive = !isUpcoming && !isExpired
|
<Badge variant={test.is_active ? "default" : "secondary"}>
|
||||||
|
{test.is_active ? "Active" : "Inactive / Processing"}
|
||||||
return (
|
|
||||||
<div key={test.id} className="p-4 border rounded-xl bg-background hover:border-primary/40 transition-colors shadow-sm">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
||||||
<div className="space-y-1.5 flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<h3 className="font-bold text-base truncate">{test.title}</h3>
|
|
||||||
<Badge variant="outline" className="text-[10px] font-bold">
|
|
||||||
{test.pdf_url ? "DPP (PDF)" : test.type === "DPS" ? "PrepPilot (DPS)" : "CBT Mock"}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-[10px] uppercase font-bold border-primary/20 bg-primary/5 text-primary">
|
|
||||||
{test.exam_pattern?.replace("_", " ") || "All"}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Live status pill */}
|
|
||||||
{isUpcoming ? (
|
|
||||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-500 border-amber-500/20 text-[10px] font-bold">
|
|
||||||
🔒 Upcoming
|
|
||||||
</Badge>
|
|
||||||
) : isExpired ? (
|
|
||||||
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20 text-[10px] font-bold">
|
|
||||||
⏳ Expired
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary" className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20 text-[10px] font-bold">
|
|
||||||
🟢 Live Now
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Badge variant={test.is_active ? "default" : "secondary"} className="text-[10px]">
|
|
||||||
{test.is_active ? "Published" : "Hidden"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1 font-medium">
|
<Clock className="w-3 h-3" />
|
||||||
<Clock className="w-3.5 h-3.5" />
|
|
||||||
{test.total_time} mins
|
{test.total_time} mins
|
||||||
</div>
|
</div>
|
||||||
|
{test.scheduled_date && (
|
||||||
{test.scheduled_date ? (
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1 font-semibold text-foreground">
|
<CalendarIcon className="w-3 h-3" />
|
||||||
<span className="text-muted-foreground font-normal">Starts:</span> {formatScheduleDateTime(test.scheduled_date)}
|
{new Date(test.scheduled_date).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">Always Available</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{test.end_date && (
|
|
||||||
<div className="flex items-center gap-1 font-semibold text-foreground border-l border-border pl-3">
|
|
||||||
<span className="text-muted-foreground font-normal">Ends:</span> {formatScheduleDateTime(test.end_date)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
{/* Actions */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2.5 shrink-0 self-end sm:self-center">
|
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
|
||||||
<Button
|
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openScheduleModal(test)}
|
|
||||||
className="gap-1.5 rounded-lg text-xs font-bold border-[var(--cyan-ai-30)] hover:bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] h-8"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal size={13} /> Edit Timing
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-1.5 border-l border-border pl-2.5">
|
|
||||||
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} title="Toggle published status" />
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||||
onClick={() => handleDelete(test.id)}
|
onClick={() => handleDelete(test.id)}
|
||||||
title="Delete test"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SCHEDULE CONTROLLER MODAL */}
|
|
||||||
{editingTest && (
|
|
||||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
|
|
||||||
<div className="bg-background border border-border rounded-2xl w-full max-w-lg overflow-hidden shadow-2xl">
|
|
||||||
<div className="p-5 border-b border-border bg-secondary/30 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="p-2 bg-[var(--cyan-ai-10)] rounded-xl border border-[var(--cyan-ai-20)] text-[var(--cyan-ai)]">
|
|
||||||
<SlidersHorizontal size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-extrabold text-base">Edit Time Boundation & Schedule</h3>
|
|
||||||
<p className="text-xs text-muted-foreground font-medium truncate max-w-xs">{editingTest.title}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingTest(null)}
|
|
||||||
className="w-8 h-8 rounded-lg bg-secondary text-muted-foreground hover:text-foreground flex items-center justify-center transition-colors"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Title</label>
|
|
||||||
<Input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Preset Buttons */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Quick Timing Presets</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={setLiveNowPreset}
|
|
||||||
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span> Live Now (Next 24h)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={setTodayPreset}
|
|
||||||
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-[var(--cyan-ai)]"></span> Today (09 AM - 12 AM)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={setTomorrowPreset}
|
|
||||||
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-yellow-500"></span> Tomorrow Full Day
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearScheduleBounds}
|
|
||||||
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-muted-foreground"></span> Always Available (24/7)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Precise Date/Time Pickers */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5 flex flex-col">
|
|
||||||
<label className="text-xs font-bold text-foreground">Scheduled Start Time</label>
|
|
||||||
<DatePicker
|
|
||||||
selected={editScheduledStart}
|
|
||||||
onChange={(date: Date | null) => setEditScheduledStart(date)}
|
|
||||||
showTimeSelect
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
timeIntervals={15}
|
|
||||||
timeCaption="Time"
|
|
||||||
dateFormat="MMM d, yyyy h:mm aa"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-bold"
|
|
||||||
placeholderText="Immediate / Open"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted-foreground">When access opens for students</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 flex flex-col">
|
|
||||||
<label className="text-xs font-bold text-foreground">Scheduled End Time</label>
|
|
||||||
<DatePicker
|
|
||||||
selected={editScheduledEnd}
|
|
||||||
onChange={(date: Date | null) => setEditScheduledEnd(date)}
|
|
||||||
showTimeSelect
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
timeIntervals={15}
|
|
||||||
timeCaption="Time"
|
|
||||||
dateFormat="MMM d, yyyy h:mm aa"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-bold"
|
|
||||||
placeholderText="No Expiry"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted-foreground">When test expires / closes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Switch */}
|
|
||||||
<div className="flex items-center justify-between p-3.5 bg-secondary/30 rounded-xl border border-border">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold text-foreground">Published / Active</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Students can see this test when scheduled</p>
|
|
||||||
</div>
|
|
||||||
<Switch checked={editIsActive} onCheckedChange={setEditIsActive} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 border-t border-border bg-secondary/20 flex items-center justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setEditingTest(null)} disabled={savingSchedule}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveSchedule} disabled={savingSchedule} className="bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2">
|
|
||||||
{savingSchedule ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
|
||||||
{savingSchedule ? "Saving Changes..." : "Apply & Save Timing"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { Scratchpad } from "@/components/CBT/Scratchpad"
|
|||||||
import { PeriodicTable } from "@/components/CBT/PeriodicTable"
|
import { PeriodicTable } from "@/components/CBT/PeriodicTable"
|
||||||
import { ExamResult } from "@/components/CBT/ExamResult"
|
import { ExamResult } from "@/components/CBT/ExamResult"
|
||||||
import { fetchWithAuth } from "@/lib/apiClient"
|
import { fetchWithAuth } from "@/lib/apiClient"
|
||||||
import { parseAsISTDate, formatISTDateTime } from "@/lib/dateUtils"
|
|
||||||
|
|
||||||
// Dummy data for testing the CBT client engine
|
// Dummy data for testing the CBT client engine
|
||||||
const DUMMY_QUESTIONS = Array.from({ length: 20 }).map((_, i) => ({
|
const DUMMY_QUESTIONS = Array.from({ length: 20 }).map((_, i) => ({
|
||||||
@@ -104,17 +103,15 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
if (!testRes.ok) throw new Error("Failed to fetch test structure");
|
if (!testRes.ok) throw new Error("Failed to fetch test structure");
|
||||||
const testData = await testRes.json();
|
const testData = await testRes.json();
|
||||||
|
|
||||||
// Strict boundary checking (IST aware)
|
// Strict boundary checking
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = parseAsISTDate(testData.scheduled_date);
|
if (testData.scheduled_date && new Date(testData.scheduled_date) > now) {
|
||||||
const endTime = parseAsISTDate(testData.end_date);
|
setAccessError(`This exam is scheduled for ${new Date(testData.scheduled_date).toLocaleString()}. You cannot start it yet.`);
|
||||||
if (scheduledTime && scheduledTime > now) {
|
|
||||||
setAccessError(`This exam is scheduled for ${formatISTDateTime(testData.scheduled_date)}. You cannot start it yet.`);
|
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (endTime && endTime < now) {
|
if (testData.end_date && new Date(testData.end_date) < now) {
|
||||||
setAccessError(`This exam expired on ${formatISTDateTime(testData.end_date)} and is no longer available.`);
|
setAccessError(`This exam expired on ${new Date(testData.end_date).toLocaleString()} and is no longer available.`);
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -285,7 +282,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
|
|
||||||
if (status === 'NOT_STARTED') {
|
if (status === 'NOT_STARTED') {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center justify-center bg-background p-8 text-center">
|
<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">
|
<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">
|
<h1 className="text-3xl font-bold mb-4 flex items-center justify-center gap-3">
|
||||||
<Shield className="text-red-500" /> Secure Exam Environment
|
<Shield className="text-red-500" /> Secure Exam Environment
|
||||||
@@ -395,8 +392,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
<div className="h-screen flex flex-col overflow-hidden">
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
{/* Warning Modal */}
|
{/* Warning Modal */}
|
||||||
{showWarning && (
|
{showWarning && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
<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">
|
<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">
|
<div className="p-6 text-center space-y-4">
|
||||||
<AlertTriangle size={64} className="text-red-500 mx-auto animate-pulse" />
|
<AlertTriangle size={64} className="text-red-500 mx-auto animate-pulse" />
|
||||||
<h3 className="text-2xl font-bold text-red-500">Warning!</h3>
|
<h3 className="text-2xl font-bold text-red-500">Warning!</h3>
|
||||||
@@ -740,7 +737,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
|
|
||||||
{showSubmitConfirm && (
|
{showSubmitConfirm && (
|
||||||
<div className="absolute inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4">
|
<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">
|
<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>
|
<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>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ function CheckoutContent() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-6">
|
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-6 animate-in fade-in zoom-in duration-500">
|
||||||
<Card className="max-w-md w-full border-border bg-secondary/10 backdrop-blur-xl shadow-2xl shadow-[var(--cyan-ai-20)] rounded-[2rem] text-center p-8">
|
<Card className="max-w-md w-full border-border bg-secondary/10 backdrop-blur-xl shadow-2xl shadow-[var(--cyan-ai-20)] rounded-[2rem] text-center p-8">
|
||||||
<CheckCircle2 size={80} className="text-[var(--cyan-ai)] mx-auto mb-6" />
|
<CheckCircle2 size={80} className="text-[var(--cyan-ai)] mx-auto mb-6" />
|
||||||
<h2 className="text-3xl font-black mb-2">Payment Successful!</h2>
|
<h2 className="text-3xl font-black mb-2">Payment Successful!</h2>
|
||||||
@@ -198,7 +198,7 @@ function CheckoutContent() {
|
|||||||
<ArrowLeft size={16} /> Back to Plans
|
<ArrowLeft size={16} /> Back to Plans
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8 animate-in fade-in slide-in-from-bottom-8 duration-700">
|
||||||
|
|
||||||
{/* Billing Details */}
|
{/* Billing Details */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -259,7 +259,7 @@ function CheckoutContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-500 text-sm font-medium text-center mt-4">
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-500 text-sm font-medium text-center animate-in zoom-in mt-4">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function ContactPage() {
|
|||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<PublicLayout>
|
||||||
<div className="bg-background text-foreground flex flex-col pt-24 pb-16 min-h-[70vh]">
|
<div className="bg-background text-foreground flex flex-col pt-24 pb-16 min-h-[70vh]">
|
||||||
<main className="flex-1 flex flex-col items-center justify-center p-8">
|
<main className="flex-1 flex flex-col items-center justify-center p-8 animate-in fade-in duration-700">
|
||||||
<h1 className="text-5xl font-bold tracking-tighter mb-4 text-center">Contact Us</h1>
|
<h1 className="text-5xl font-bold tracking-tighter mb-4 text-center">Contact Us</h1>
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl text-center mb-12">
|
<p className="text-lg text-muted-foreground max-w-2xl text-center mb-12">
|
||||||
Have a question or need support? We're here to help you ace your preparation.
|
Have a question or need support? We're here to help you ace your preparation.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -148,7 +148,7 @@ export default function AIComCBTDPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{submitted && (
|
{submitted && (
|
||||||
<Card className="border-emerald-500/30 bg-emerald-500/10 p-6 rounded-3xl space-y-4">
|
<Card className="border-emerald-500/30 bg-emerald-500/10 p-6 rounded-3xl space-y-4 animate-in zoom-in-95">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CheckCircle2 size={32} className="text-emerald-400" />
|
<CheckCircle2 size={32} className="text-emerald-400" />
|
||||||
@@ -216,7 +216,7 @@ export default function AIComCBTDPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{submitted && (
|
{submitted && (
|
||||||
<div className="mt-6 p-4 rounded-2xl bg-[var(--cyan-ai-10)] border border-[var(--cyan-ai-30)] text-sm text-[var(--cyan-ai)] space-y-1">
|
<div className="mt-6 p-4 rounded-2xl bg-[var(--cyan-ai-10)] border border-[var(--cyan-ai-30)] text-sm text-[var(--cyan-ai)] space-y-1 animate-in fade-in">
|
||||||
<span className="font-extrabold uppercase text-xs tracking-wider block">AI Solution & Explanation:</span>
|
<span className="font-extrabold uppercase text-xs tracking-wider block">AI Solution & Explanation:</span>
|
||||||
<p>{sampleQuestions[activeQuestion].explanation}</p>
|
<p>{sampleQuestions[activeQuestion].explanation}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
|
||||||
import { formatISTDateTime, formatISTTime, parseAsISTDate } from "@/lib/dateUtils"
|
|
||||||
|
|
||||||
export default function DPPPage() {
|
export default function DPPPage() {
|
||||||
const { user, userId, hasAccessTo } = useActiveUser()
|
const { user, userId, hasAccessTo } = useActiveUser()
|
||||||
@@ -82,27 +80,21 @@ export default function DPPPage() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, any>);
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
const scheduledDates = useMemo(() => {
|
|
||||||
return dpps
|
|
||||||
.filter((d) => !d.pdf_url && !!d.scheduled_date)
|
|
||||||
.map((d) => getLocalDateString(d.scheduled_date))
|
|
||||||
.filter(Boolean)
|
|
||||||
}, [dpps])
|
|
||||||
|
|
||||||
const filteredDpps = dpps.filter(dpp => {
|
const filteredDpps = dpps.filter(dpp => {
|
||||||
// Only show CBT DPPs here (no PDF)
|
// Only show CBT DPPs here (no PDF)
|
||||||
if (dpp.pdf_url) return false;
|
if (dpp.pdf_url) return false;
|
||||||
|
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
if (!dpp.scheduled_date) return false;
|
if (!dpp.scheduled_date) return false;
|
||||||
const scheduledDate = getLocalDateString(dpp.scheduled_date);
|
const scheduledDate = new Date(dpp.scheduled_date).toISOString().split('T')[0];
|
||||||
if (scheduledDate !== dateFilter) return false;
|
if (scheduledDate !== dateFilter) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
const formatDate = (dateStr: string) => {
|
||||||
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
const getPdfUrl = (title: string) => {
|
const getPdfUrl = (title: string) => {
|
||||||
const t = title.toLowerCase();
|
const t = title.toLowerCase();
|
||||||
@@ -117,7 +109,7 @@ export default function DPPPage() {
|
|||||||
const cleanTitle = (title: string) => title.replace(/\s*\(ExamEnum\.[^)]+\)/, '');
|
const cleanTitle = (title: string) => title.replace(/\s*\(ExamEnum\.[^)]+\)/, '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-6 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<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 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 md:text-4xl font-black tracking-tight flex items-center gap-3 mb-2">
|
<h1 className="text-3xl md:text-4xl font-black tracking-tight flex items-center gap-3 mb-2">
|
||||||
@@ -132,19 +124,23 @@ export default function DPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-border pb-6">
|
<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-3 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarFilter
|
<input
|
||||||
selectedDate={dateFilter}
|
type="date"
|
||||||
onSelectDate={setDateFilter}
|
value={dateFilter}
|
||||||
availableDates={scheduledDates}
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
placeholder="Filter by Schedule Date"
|
className="px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-muted-foreground mr-2"
|
||||||
/>
|
/>
|
||||||
{dateFilter && (
|
<span className="text-sm font-bold text-muted-foreground mr-2"><Filter size={16} className="inline mr-1"/> Difficulty:</span>
|
||||||
<span className="text-xs font-semibold text-muted-foreground bg-secondary/60 px-2.5 py-1 rounded-lg">
|
{['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => (
|
||||||
Showing for {dateFilter}
|
<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'}`}>
|
||||||
</span>
|
{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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -175,32 +171,22 @@ export default function DPPPage() {
|
|||||||
const attempt = attemptsByTest[dpp.id];
|
const attempt = attemptsByTest[dpp.id];
|
||||||
const isAttempted = !!attempt;
|
const isAttempted = !!attempt;
|
||||||
|
|
||||||
// Date & Time logic (IST aware)
|
// Date logic
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = parseAsISTDate(dpp.scheduled_date);
|
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now;
|
||||||
const endTime = parseAsISTDate(dpp.end_date);
|
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
||||||
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
const isUpcoming = scheduledTime > now;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
const isLive = !isUpcoming && !isExpired;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-85 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-70' : 'hover:border-[var(--cyan-ai-20)] bg-background'}`}>
|
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-70 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-80' : 'hover:border-[var(--cyan-ai-10)] bg-background'}`}>
|
||||||
<CardContent className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<CardContent className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="flex gap-4 items-start w-full">
|
<div className="flex gap-4 items-start w-full">
|
||||||
{/* Clean Status Icon — No Selectable Circle Checkboxes */}
|
<div className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
|
||||||
<div className="mt-1 flex-shrink-0">
|
{isAttempted ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground group-hover:border-[var(--cyan-ai)] transition-colors flex items-center justify-center"></div>}
|
||||||
{isAttempted ? (
|
|
||||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-500 border border-emerald-500/20" title="Completed">
|
|
||||||
<CheckCircle2 size={18} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-1.5 rounded-lg bg-secondary/60 text-[var(--cyan-ai)] border border-border" title="Practice Test">
|
|
||||||
<Zap size={18} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
||||||
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
||||||
</span>
|
</span>
|
||||||
@@ -209,30 +195,14 @@ export default function DPPPage() {
|
|||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-amber-500 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-md flex items-center gap-1">
|
<span className="text-[10px] font-bold uppercase text-yellow-500 bg-yellow-500/10 px-1.5 py-0.5 rounded">Upcoming</span>
|
||||||
🔒 Upcoming
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : isExpired ? (
|
) : isExpired ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-2 py-0.5 rounded-md">
|
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">Expired</span>
|
||||||
⏳ Expired
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
|
||||||
<span className="text-[10px] font-bold uppercase text-emerald-500 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-md">
|
|
||||||
🟢 Live Now
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isAttempted && (
|
|
||||||
<span className="text-[10px] font-bold uppercase text-green-500 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-md">
|
|
||||||
Attempted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
||||||
{cleanTitle(dpp.title)}
|
{cleanTitle(dpp.title)}
|
||||||
@@ -241,25 +211,10 @@ export default function DPPPage() {
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
||||||
<span>{count} Questions • {dpp.total_time} mins</span>
|
{count} Questions • {dpp.total_time} mins
|
||||||
{isUpcoming && scheduledTime && (
|
{dpp.scheduled_date && (
|
||||||
<span className="ml-2 border-l border-border pl-2 text-amber-500 font-semibold">
|
<span className="ml-2 border-l border-border pl-2">
|
||||||
Opens on {formatDateTime(dpp.scheduled_date)}
|
{isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isExpired && endTime && (
|
|
||||||
<span className="ml-2 border-l border-border pl-2 text-destructive font-semibold">
|
|
||||||
Expired on {formatDateTime(dpp.end_date)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isLive && endTime && (
|
|
||||||
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
|
||||||
Closes on {formatDateTime(dpp.end_date)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isLive && !endTime && scheduledTime && (
|
|
||||||
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
|
||||||
Available since {formatDateTime(dpp.scheduled_date)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -267,13 +222,9 @@ export default function DPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold text-amber-500 border-amber-500/30 bg-amber-500/5">
|
<Button disabled variant="outline" size="sm" className="font-bold">Starts Soon</Button>
|
||||||
Opens at {scheduledTime ? formatTime(dpp.scheduled_date) : "Scheduled Time"}
|
|
||||||
</Button>
|
|
||||||
) : isExpired && !isAttempted ? (
|
) : isExpired && !isAttempted ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50 text-muted-foreground">
|
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
|
||||||
Expired
|
|
||||||
</Button>
|
|
||||||
) : isAttempted ? (
|
) : isAttempted ? (
|
||||||
<>
|
<>
|
||||||
<Link href={`/cbt/${dpp.id}/result`}>
|
<Link href={`/cbt/${dpp.id}/result`}>
|
||||||
@@ -305,8 +256,7 @@ export default function DPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)})}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── PROTECTED PDF READER MODAL (FREE TIER) ── */}
|
{/* ── PROTECTED PDF READER MODAL (FREE TIER) ── */}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function CollegePredictorPage() {
|
|||||||
const trendData = isDemo ? DEMO_TREND_DATA : ZERO_TREND_DATA
|
const trendData = isDemo ? DEMO_TREND_DATA : ZERO_TREND_DATA
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto min-h-screen">
|
<div className="p-6 md:p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto min-h-screen">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function HeatmapPage() {
|
|||||||
const criticalTopics = isDemo ? DEMO_CRITICAL_TOPICS : []
|
const criticalTopics = isDemo ? DEMO_CRITICAL_TOPICS : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 min-h-screen">
|
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 min-h-screen animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function AICoachDashboard() {
|
|||||||
const currentStreakDays = `${user?.streak_count || (isDemo ? 12 : 1)} d`
|
const currentStreakDays = `${user?.streak_count || (isDemo ? 12 : 1)} d`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<div className="p-6 max-w-6xl mx-auto space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
|
|
||||||
{/* ── METRICS BAR ── */}
|
{/* ── METRICS BAR ── */}
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-2">
|
<div className="flex flex-wrap items-center justify-end gap-2 pt-2">
|
||||||
@@ -108,7 +108,7 @@ export default function AICoachDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── DAILY CHALLENGE BANNER ── */}
|
{/* ── DAILY CHALLENGE BANNER ── */}
|
||||||
<div className=""><DailyChallengeBanner /></div>
|
<DailyChallengeBanner />
|
||||||
|
|
||||||
{/* ── QUICK STATS ── */}
|
{/* ── QUICK STATS ── */}
|
||||||
<section className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<section className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
@@ -119,7 +119,7 @@ export default function AICoachDashboard() {
|
|||||||
{ label: "Mistakes Mastered", value: mistakesMastered, color: "text-green-400", bg: "bg-green-400/5", border: "border-green-400/20", icon: <CheckCircle2 size={32} /> },
|
{ label: "Mistakes Mastered", value: mistakesMastered, color: "text-green-400", bg: "bg-green-400/5", border: "border-green-400/20", icon: <CheckCircle2 size={32} /> },
|
||||||
{ label: "AI Sessions", value: aiSessions, color: "text-purple-400", bg: "bg-purple-400/5", border: "border-purple-400/20", icon: <Bot size={32} /> },
|
{ label: "AI Sessions", value: aiSessions, color: "text-purple-400", bg: "bg-purple-400/5", border: "border-purple-400/20", icon: <Bot size={32} /> },
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<Card key={stat.label} className={`border ${stat.border} ${stat.bg} backdrop-blur-sm rounded-2xl`}>
|
<Card key={stat.label} className={`border ${stat.border} ${stat.bg} backdrop-blur-sm transition-all duration-300 transform hover:-translate-y-1 rounded-2xl`}>
|
||||||
<CardContent className="p-6 flex flex-col items-center justify-center text-center">
|
<CardContent className="p-6 flex flex-col items-center justify-center text-center">
|
||||||
<div className="text-3xl mb-2 filter drop-shadow-md">{stat.icon}</div>
|
<div className="text-3xl mb-2 filter drop-shadow-md">{stat.icon}</div>
|
||||||
<div className={`text-4xl font-black ${stat.color} leading-none tracking-tight mb-2 filter drop-shadow-sm`}>{stat.value}</div>
|
<div className={`text-4xl font-black ${stat.color} leading-none tracking-tight mb-2 filter drop-shadow-sm`}>{stat.value}</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ReadinessGauge } from "@/components/AI/ReadinessGauge"
|
import { ReadinessGauge } from "@/components/AI/ReadinessGauge"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -32,7 +32,7 @@ export default function ReadinessPage() {
|
|||||||
const maxScore = Math.max(1, ...scoreHistory.map(d => d.score))
|
const maxScore = Math.max(1, ...scoreHistory.map(d => d.score))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto min-h-screen">
|
<div className="p-6 md:p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto min-h-screen">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
@@ -58,7 +58,7 @@ export default function ReadinessPage() {
|
|||||||
{[
|
{[
|
||||||
{ label: "Overall Score", value: isDemo ? "72 / 100" : "0 / 100", trend: isDemo ? "+5 this week" : "Baseline", trendUp: true, icon: <Gauge size={20} className="text-[var(--cyan-ai)]" /> },
|
{ label: "Overall Score", value: isDemo ? "72 / 100" : "0 / 100", trend: isDemo ? "+5 this week" : "Baseline", trendUp: true, icon: <Gauge size={20} className="text-[var(--cyan-ai)]" /> },
|
||||||
{ label: "Accuracy Rate", value: isDemo ? "88%" : "0%", trend: isDemo ? "+3% vs last week" : "0 attempts", trendUp: isDemo, icon: <Target size={20} className="text-amber-400" /> },
|
{ label: "Accuracy Rate", value: isDemo ? "88%" : "0%", trend: isDemo ? "+3% vs last week" : "0 attempts", trendUp: isDemo, icon: <Target size={20} className="text-amber-400" /> },
|
||||||
{ label: "Avg Speed / Q", value: isDemo ? "45 sec" : "-- sec", trend: isDemo ? "? Needs work" : "Not tested", trendUp: false, icon: <Clock size={20} className="text-red-400" /> },
|
{ label: "Avg Speed / Q", value: isDemo ? "45 sec" : "-- sec", trend: isDemo ? "↓ Needs work" : "Not tested", trendUp: false, icon: <Clock size={20} className="text-red-400" /> },
|
||||||
{ label: "Syllabus Covered", value: isDemo ? "64%" : "0%", trend: isDemo ? "+8% this month" : "Day 1", trendUp: true, icon: <BookOpen size={20} className="text-emerald-400" /> },
|
{ label: "Syllabus Covered", value: isDemo ? "64%" : "0%", trend: isDemo ? "+8% this month" : "Day 1", trendUp: true, icon: <BookOpen size={20} className="text-emerald-400" /> },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
<Card key={kpi.label} className="border-border/60 bg-secondary/10 rounded-2xl hover:border-[var(--cyan-ai-30)] transition-colors">
|
<Card key={kpi.label} className="border-border/60 bg-secondary/10 rounded-2xl hover:border-[var(--cyan-ai-30)] transition-colors">
|
||||||
@@ -108,7 +108,7 @@ export default function ReadinessPage() {
|
|||||||
<span className={`text-xs font-bold ${sub.statusColor}`}>{sub.score}% · {sub.status}</span>
|
<span className={`text-xs font-bold ${sub.statusColor}`}>{sub.score}% · {sub.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2.5 w-full bg-secondary rounded-full overflow-hidden">
|
<div className="h-2.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<div className={`h-full ${sub.color} rounded-full`} style={{ width: `${sub.score}%` }} />
|
<div className={`h-full ${sub.color} rounded-full transition-all duration-700`} style={{ width: `${sub.score}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -126,7 +126,7 @@ export default function ReadinessPage() {
|
|||||||
<div key={d.week} className="flex flex-col items-center gap-1 flex-1">
|
<div key={d.week} className="flex flex-col items-center gap-1 flex-1">
|
||||||
<span className="text-xs font-black text-muted-foreground">{d.score}</span>
|
<span className="text-xs font-black text-muted-foreground">{d.score}</span>
|
||||||
<div
|
<div
|
||||||
className={`w-full rounded-t-md ${
|
className={`w-full rounded-t-md transition-all duration-700 ${
|
||||||
!isDemo
|
!isDemo
|
||||||
? "bg-white/5 border border-white/10"
|
? "bg-white/5 border border-white/10"
|
||||||
: i === scoreHistory.length - 1
|
: i === scoreHistory.length - 1
|
||||||
@@ -182,7 +182,7 @@ export default function ReadinessPage() {
|
|||||||
<Link key={a.href} href={a.href}
|
<Link key={a.href} href={a.href}
|
||||||
className="flex items-center justify-between p-2.5 rounded-xl border border-border/40 hover:border-[var(--cyan-ai-30)] hover:bg-white/5 transition-all group text-sm font-bold">
|
className="flex items-center justify-between p-2.5 rounded-xl border border-border/40 hover:border-[var(--cyan-ai-30)] hover:bg-white/5 transition-all group text-sm font-bold">
|
||||||
<span className={a.color}>{a.label}</span>
|
<span className={a.color}>{a.label}</span>
|
||||||
<span className="text-muted-foreground group-hover:translate-x-1 transition-transform">?</span>
|
<span className="text-muted-foreground group-hover:translate-x-1 transition-transform">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ExamStrategyCard } from "@/components/AI/ExamStrategyCard"
|
import { ExamStrategyCard } from "@/components/AI/ExamStrategyCard"
|
||||||
import { StudyTimeOptimizationCard } from "@/components/AI/StudyTimeOptimizationCard"
|
import { StudyTimeOptimizationCard } from "@/components/AI/StudyTimeOptimizationCard"
|
||||||
@@ -24,7 +24,7 @@ export default function StrategyPage() {
|
|||||||
const negMarkingData = isDemo ? DEMO_NEG_MARKING_DATA : ZERO_NEG_MARKING_DATA
|
const negMarkingData = isDemo ? DEMO_NEG_MARKING_DATA : ZERO_NEG_MARKING_DATA
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto min-h-screen">
|
<div className="p-6 md:p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto min-h-screen">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
@@ -49,7 +49,7 @@ export default function StrategyPage() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: "Time Per Question", value: isDemo ? "45 sec" : "-- sec", note: isDemo ? "Target: 38 sec" : "Not calibrated", icon: <Clock size={18} className="text-red-400" />, color: "border-red-400/20 bg-red-400/5" },
|
{ label: "Time Per Question", value: isDemo ? "45 sec" : "-- sec", note: isDemo ? "Target: 38 sec" : "Not calibrated", icon: <Clock size={18} className="text-red-400" />, color: "border-red-400/20 bg-red-400/5" },
|
||||||
{ label: "Negative Marks Lost", value: isDemo ? "-29" : "0", note: isDemo ? "Last mock test" : "0 negative marks", icon: <AlertTriangle size={18} className="text-amber-400" />, color: "border-amber-400/20 bg-amber-400/5" },
|
{ label: "Negative Marks Lost", value: isDemo ? "−29" : "0", note: isDemo ? "Last mock test" : "0 negative marks", icon: <AlertTriangle size={18} className="text-amber-400" />, color: "border-amber-400/20 bg-amber-400/5" },
|
||||||
{ label: "Attempted Questions", value: isDemo ? "93 / 120" : "0 / 120", note: isDemo ? "+7 vs last test" : "0 attempted", icon: <Target size={18} className="text-[var(--cyan-ai)]" />, color: "border-[var(--cyan-ai-20)] bg-[var(--cyan-ai-10)]" },
|
{ label: "Attempted Questions", value: isDemo ? "93 / 120" : "0 / 120", note: isDemo ? "+7 vs last test" : "0 attempted", icon: <Target size={18} className="text-[var(--cyan-ai)]" />, color: "border-[var(--cyan-ai-20)] bg-[var(--cyan-ai-10)]" },
|
||||||
{ label: "Score Yield", value: isDemo ? "214" : "0", note: isDemo ? "Predicted score" : "Baseline", icon: <TrendingUp size={18} className="text-green-400" />, color: "border-green-400/20 bg-green-400/5" },
|
{ label: "Score Yield", value: isDemo ? "214" : "0", note: isDemo ? "Predicted score" : "Baseline", icon: <TrendingUp size={18} className="text-green-400" />, color: "border-green-400/20 bg-green-400/5" },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
@@ -101,7 +101,7 @@ export default function StrategyPage() {
|
|||||||
<div className="text-[11px] text-muted-foreground font-semibold">Attempted</div>
|
<div className="text-[11px] text-muted-foreground font-semibold">Attempted</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-sm font-black text-red-400">{row.negMarks === 0 ? "0" : `-${row.negMarks}`}</div>
|
<div className="text-sm font-black text-red-400">{row.negMarks === 0 ? "0" : `−${row.negMarks}`}</div>
|
||||||
<div className="text-[11px] text-muted-foreground font-semibold">Neg. Marks</div>
|
<div className="text-[11px] text-muted-foreground font-semibold">Neg. Marks</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { StudyPlanCard } from "@/components/AI/StudyPlanCard"
|
import { StudyPlanCard } from "@/components/AI/StudyPlanCard"
|
||||||
import { WeeklyProgressReport } from "@/components/AI/WeeklyProgressReport"
|
import { WeeklyProgressReport } from "@/components/AI/WeeklyProgressReport"
|
||||||
@@ -12,7 +12,7 @@ export default function StudyPlanPage() {
|
|||||||
const streakCount = user?.streak_count || (isDemo ? 12 : 1)
|
const streakCount = user?.streak_count || (isDemo ? 12 : 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto min-h-screen">
|
<div className="p-6 md:p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto min-h-screen">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
@@ -99,7 +99,7 @@ export default function StudyPlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-2.5 w-full bg-secondary rounded-full overflow-hidden">
|
<div className="h-2.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full ${sub.covered >= 70 ? "bg-green-400" : sub.covered >= 55 ? "bg-amber-400" : "bg-red-400"}`}
|
className={`h-full rounded-full transition-all duration-700 ${sub.covered >= 70 ? "bg-green-400" : sub.covered >= 55 ? "bg-amber-400" : "bg-red-400"}`}
|
||||||
style={{ width: `${sub.covered}%` }}
|
style={{ width: `${sub.covered}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function TimeOptimizationPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 min-h-screen">
|
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 min-h-screen animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { TutorChat } from "@/components/AI/TutorChat"
|
import { TutorChat } from "@/components/AI/TutorChat"
|
||||||
import { BrainCircuit, Sparkles } from "lucide-react"
|
import { BrainCircuit, Sparkles } from "lucide-react"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function WeeklyProgressPage() {
|
|||||||
const syllabusProgress = isDemo ? DEMO_SYLLABUS_PROGRESS : ZERO_SYLLABUS_PROGRESS
|
const syllabusProgress = isDemo ? DEMO_SYLLABUS_PROGRESS : ZERO_SYLLABUS_PROGRESS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 min-h-screen">
|
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 min-h-screen animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
@@ -108,7 +108,7 @@ export default function WeeklyProgressPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${item.color} rounded-full`}
|
className={`h-full ${item.color} rounded-full transition-all duration-500`}
|
||||||
style={{ width: `${item.percentage}%` }}
|
style={{ width: `${item.percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PremiumGate featureKey="detailed-performance-analytics">
|
<PremiumGate featureKey="detailed-performance-analytics">
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<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">
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
|
|||||||
const maxScore = attempt.total_questions * 4;
|
const maxScore = attempt.total_questions * 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-8">
|
<div className="max-w-6xl mx-auto p-8 animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
<Link href={backRoute} className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors">
|
<Link href={backRoute} className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors">
|
||||||
<ArrowLeft size={16} className="mr-2" /> {test?.type === 'DPS' ? 'Back to PrepPilot AI' : 'Back to Tests'}
|
<ArrowLeft size={16} className="mr-2" /> {test?.type === 'DPS' ? 'Back to PrepPilot AI' : 'Back to Tests'}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-r
|
|||||||
import { useActiveUser } from "@/hooks/useActiveUser";
|
import { useActiveUser } from "@/hooks/useActiveUser";
|
||||||
import { PremiumGate } from "@/components/PremiumGate";
|
import { PremiumGate } from "@/components/PremiumGate";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter";
|
|
||||||
import { formatISTDateTime, formatISTDate, formatISTTime, parseAsISTDate } from "@/lib/dateUtils";
|
|
||||||
|
|
||||||
interface TestData {
|
interface TestData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -95,29 +91,22 @@ export default function CBTPage() {
|
|||||||
loadTests();
|
loadTests();
|
||||||
}, [userId, selectedPattern]);
|
}, [userId, selectedPattern]);
|
||||||
|
|
||||||
const scheduledDates = useMemo(() => {
|
|
||||||
return exams
|
|
||||||
.filter((e) => !!e.scheduled_date)
|
|
||||||
.map((e) => getLocalDateString(e.scheduled_date))
|
|
||||||
.filter(Boolean)
|
|
||||||
}, [exams]);
|
|
||||||
|
|
||||||
const filteredExams = exams.filter(exam => {
|
const filteredExams = exams.filter(exam => {
|
||||||
const matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase());
|
const matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase());
|
||||||
if (!matchesSearch) return false;
|
if (!matchesSearch) return false;
|
||||||
|
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
if (!exam.scheduled_date) return false;
|
if (!exam.scheduled_date) return false;
|
||||||
const scheduledDate = getLocalDateString(exam.scheduled_date);
|
const scheduledDate = new Date(exam.scheduled_date).toISOString().split('T')[0];
|
||||||
if (scheduledDate !== dateFilter) return false;
|
if (scheduledDate !== dateFilter) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
const formatDate = (dateStr: string) => {
|
||||||
const formatDate = (dateStr: string) => formatISTDate(dateStr);
|
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
}
|
||||||
|
|
||||||
// Get the highest score for each test if attempted multiple times
|
// Get the highest score for each test if attempted multiple times
|
||||||
const attemptsByTest = attempts.reduce((acc, curr) => {
|
const attemptsByTest = attempts.reduce((acc, curr) => {
|
||||||
@@ -141,7 +130,7 @@ export default function CBTPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<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">
|
||||||
@@ -154,13 +143,15 @@ export default function CBTPage() {
|
|||||||
Real exam environment simulations to conquer {selectedPattern === "NEET" ? "NEET" : (targetExam || "JEE")}.
|
Real exam environment simulations to conquer {selectedPattern === "NEET" ? "NEET" : (targetExam || "JEE")}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<CalendarFilter
|
<div className="relative w-full sm:w-48">
|
||||||
selectedDate={dateFilter}
|
<input
|
||||||
onSelectDate={setDateFilter}
|
type="date"
|
||||||
availableDates={scheduledDates}
|
value={dateFilter}
|
||||||
placeholder="Filter by Exam Date"
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-foreground dark:[color-scheme:dark] [color-scheme:light]"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm: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
|
<input
|
||||||
@@ -212,8 +203,8 @@ export default function CBTPage() {
|
|||||||
|
|
||||||
// Date logic
|
// Date logic
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = parseAsISTDate(exam.scheduled_date) || now;
|
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now;
|
||||||
const endTime = parseAsISTDate(exam.end_date);
|
const endTime = exam.end_date ? new Date(exam.end_date) : null;
|
||||||
const isUpcoming = scheduledTime > now;
|
const isUpcoming = scheduledTime > now;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
|
|
||||||
@@ -250,32 +241,14 @@ export default function CBTPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{isUpcoming && exam.scheduled_date ? (
|
{isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'}
|
||||||
<span className="text-amber-500 font-semibold">
|
|
||||||
Opens: {formatDateTime(exam.scheduled_date)}
|
|
||||||
</span>
|
|
||||||
) : isExpired && exam.end_date ? (
|
|
||||||
<span className="text-destructive font-semibold">
|
|
||||||
Expired on {formatDateTime(exam.end_date)}
|
|
||||||
</span>
|
|
||||||
) : exam.end_date ? (
|
|
||||||
<span className="text-emerald-500 font-semibold">
|
|
||||||
Live Now • Closes {formatDateTime(exam.end_date)}
|
|
||||||
</span>
|
|
||||||
) : exam.scheduled_date ? (
|
|
||||||
<span className="text-emerald-500 font-semibold">
|
|
||||||
Available since {formatDateTime(exam.scheduled_date)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-emerald-500 font-semibold">Available Now</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<Button disabled className="w-full gap-2 rounded-xl bg-amber-500/10 text-amber-500 border border-amber-500/30 font-bold">
|
<Button disabled className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground font-bold">
|
||||||
<Clock size={16} /> Opens at {exam.scheduled_date ? formatTime(exam.scheduled_date) : "Scheduled Time"}
|
<Clock size={16} /> Starts Soon
|
||||||
</Button>
|
</Button>
|
||||||
) : isLocked ? (
|
) : isLocked ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -365,7 +338,7 @@ export default function CBTPage() {
|
|||||||
|
|
||||||
{showUpgradeModal && (
|
{showUpgradeModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||||
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[450px] p-8 text-center space-y-6">
|
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[450px] p-8 text-center space-y-6 animate-in zoom-in-95 duration-200">
|
||||||
<div className="size-16 rounded-full bg-red-500/10 flex items-center justify-center mx-auto text-red-500">
|
<div className="size-16 rounded-full bg-red-500/10 flex items-center justify-center mx-auto text-red-500">
|
||||||
<Lock size={32} />
|
<Lock size={32} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,45 +84,38 @@ export default function CommunityPage() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
|
||||||
if (!hasAccessTo("doubt-resolution-community") && !isDemo) {
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="h-[70vh] flex flex-col items-center justify-center p-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<div className="h-8 w-8 border-4 border-[var(--cyan-ai)] border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||||
<div>
|
<p className="text-muted-foreground font-medium animate-pulse">Loading Doubt Forum...</p>
|
||||||
<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)]">
|
|
||||||
<MessageSquare className="text-yellow-600 dark:text-[var(--cyan-ai)]" size={28} />
|
|
||||||
</span>
|
|
||||||
Doubt Forum
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-base font-medium max-w-2xl pl-1">
|
|
||||||
Ask questions, share knowledge, and learn together with the community.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
<div className="p-12 text-center max-w-2xl mx-auto bg-secondary/20 border border-border rounded-3xl space-y-6">
|
}
|
||||||
<div className="size-20 rounded-full bg-red-500/10 flex items-center justify-center mx-auto text-red-500">
|
|
||||||
|
if (!hasAccessTo("doubt-resolution-community")) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 h-full flex flex-col items-center justify-center text-center max-w-2xl mx-auto">
|
||||||
|
<div className="size-20 rounded-full bg-red-500/10 flex items-center justify-center mx-auto text-red-500 mb-6">
|
||||||
<Lock size={40} />
|
<Lock size={40} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold">Premium Feature</h2>
|
<h2 className="text-3xl font-bold mb-4">Premium Feature</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground text-lg mb-8">
|
||||||
The Doubt Forum is a premium feature. Upgrade your plan to ask doubts and interact with tutors and the community.
|
The Doubt Forum is a premium feature. Upgrade your plan to ask doubts and interact with tutors and the community.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="px-8 py-3 bg-[var(--cyan-ai)] text-black font-bold rounded-xl shadow-[0_0_20px_-5px_var(--cyan-ai)]"
|
className="px-8 py-3 bg-primary text-primary-foreground font-bold rounded-xl shadow-[0_0_20px_-5px_var(--color-primary)] text-lg"
|
||||||
onClick={() => window.location.href = '/upgrade'}
|
onClick={() => window.location.href = '/upgrade'}
|
||||||
>
|
>
|
||||||
Upgrade Plan
|
Upgrade Plan
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<PremiumGate featureKey="doubt-resolution-community">
|
||||||
|
<div className="p-8 space-y-6 animate-in fade-in zoom-in-95 origin-top 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 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">
|
||||||
@@ -166,9 +159,7 @@ export default function CommunityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{loading ? (
|
{filteredDoubts.map((doubt) => (
|
||||||
<div className="text-center py-12 text-muted-foreground animate-pulse">Loading doubts...</div>
|
|
||||||
) : (<>{filteredDoubts.map((doubt) => (
|
|
||||||
<Card key={doubt.id} className="border-border shadow-sm hover:border-[var(--cyan-ai-10)] transition-colors cursor-pointer">
|
<Card key={doubt.id} className="border-border shadow-sm hover:border-[var(--cyan-ai-10)] transition-colors cursor-pointer">
|
||||||
<CardContent className="p-0 flex">
|
<CardContent className="p-0 flex">
|
||||||
{/* Upvote Sidebar */}
|
{/* Upvote Sidebar */}
|
||||||
@@ -268,12 +259,11 @@ export default function CommunityPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Ask Doubt Modal */}
|
{/* Ask Doubt Modal */}
|
||||||
{isAskModalOpen && (
|
{isAskModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
|
||||||
<div className="bg-background border border-border w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden">
|
<div className="bg-background border border-border w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
<div className="flex justify-between items-center p-6 border-b border-border bg-secondary/30">
|
<div className="flex justify-between items-center p-6 border-b border-border bg-secondary/30">
|
||||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<MessageSquare className="text-[var(--cyan-ai)]" /> Ask a Doubt
|
<MessageSquare className="text-[var(--cyan-ai)]" /> Ask a Doubt
|
||||||
@@ -341,7 +331,7 @@ export default function CommunityPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</PremiumGate>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
|
||||||
import { formatISTDateTime, formatISTTime, parseAsISTDate } from "@/lib/dateUtils"
|
|
||||||
|
|
||||||
export default function AIDailyPracticePage() {
|
export default function AIDailyPracticePage() {
|
||||||
const { user, userId, hasAccessTo } = useActiveUser()
|
const { user, userId, hasAccessTo } = useActiveUser()
|
||||||
@@ -83,28 +81,22 @@ export default function AIDailyPracticePage() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, any>);
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
const scheduledDates = useMemo(() => {
|
|
||||||
return dpps
|
|
||||||
.filter((d) => !!d.pdf_url && !!d.scheduled_date)
|
|
||||||
.map((d) => getLocalDateString(d.scheduled_date))
|
|
||||||
.filter(Boolean)
|
|
||||||
}, [dpps])
|
|
||||||
|
|
||||||
const filteredDpps = dpps.filter(dpp => {
|
const filteredDpps = dpps.filter(dpp => {
|
||||||
// Only show PDF DPPs here
|
// Only show PDF DPPs here
|
||||||
if (!dpp.pdf_url) return false;
|
if (!dpp.pdf_url) return false;
|
||||||
|
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
if (!dpp.scheduled_date) return false;
|
if (!dpp.scheduled_date) return false;
|
||||||
const scheduledDate = getLocalDateString(dpp.scheduled_date);
|
const scheduledDate = new Date(dpp.scheduled_date).toISOString().split('T')[0];
|
||||||
if (scheduledDate !== dateFilter) return false;
|
if (scheduledDate !== dateFilter) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
const formatDate = (dateStr: string) => {
|
||||||
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
const getPdfUrl = (title: string) => {
|
const getPdfUrl = (title: string) => {
|
||||||
const t = title.toLowerCase();
|
const t = title.toLowerCase();
|
||||||
@@ -119,7 +111,7 @@ export default function AIDailyPracticePage() {
|
|||||||
const cleanTitle = (title: string) => title.replace(/\s*\(ExamEnum\.[^)]+\)/, '');
|
const cleanTitle = (title: string) => title.replace(/\s*\(ExamEnum\.[^)]+\)/, '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
|
<div className="p-6 md:p-8 space-y-6 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="border-b border-border/50 pb-6">
|
<div className="border-b border-border/50 pb-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
@@ -130,39 +122,33 @@ export default function AIDailyPracticePage() {
|
|||||||
Daily Practice (DPP)
|
Daily Practice (DPP)
|
||||||
</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">
|
||||||
Launch Daily Practice Problems (DPPs) tailored to your exam pattern with time-bound calendar schedules.
|
Launch Daily Practice Problems (DPPs) tailored to your exam pattern.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar Filter & Controller Bar */}
|
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-border pb-6">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<input
|
||||||
<CalendarFilter
|
type="date"
|
||||||
selectedDate={dateFilter}
|
value={dateFilter}
|
||||||
onSelectDate={setDateFilter}
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
availableDates={scheduledDates}
|
className="px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-muted-foreground mr-2"
|
||||||
placeholder="Filter by Schedule Date"
|
|
||||||
/>
|
/>
|
||||||
{dateFilter && (
|
|
||||||
<span className="text-xs font-semibold text-muted-foreground bg-secondary/60 px-2.5 py-1 rounded-lg">
|
|
||||||
Showing DPPs for {dateFilter}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-bold text-muted-foreground">
|
|
||||||
{filteredDpps.length} {filteredDpps.length === 1 ? "Practice Sheet" : "Practice Sheets"} Available
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" className="gap-2 rounded-xl text-sm font-bold">
|
||||||
|
<Download size={16} /> Export as PDF
|
||||||
|
</Button>
|
||||||
</div>
|
</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">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="col-span-2 text-center py-12 text-muted-foreground">Loading AI-generated DPPs...</div>
|
<div className="col-span-2 text-center py-12 text-muted-foreground">Loading AI-generated DPPs...</div>
|
||||||
) : filteredDpps.length === 0 ? (
|
) : filteredDpps.length === 0 ? (
|
||||||
<div className="col-span-2 text-center py-12 text-muted-foreground border border-dashed rounded-xl">
|
<div className="col-span-2 text-center py-12 text-muted-foreground border border-dashed rounded-xl">No DPPs available for the selected criteria.</div>
|
||||||
No DPPs found for the selected schedule criteria.
|
|
||||||
</div>
|
|
||||||
) : filteredDpps.map((dpp, i) => {
|
) : filteredDpps.map((dpp, i) => {
|
||||||
// Calculate total questions dynamically from sections
|
// Calculate total questions dynamically from sections
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -173,32 +159,22 @@ export default function AIDailyPracticePage() {
|
|||||||
const attempt = attemptsByTest[dpp.id];
|
const attempt = attemptsByTest[dpp.id];
|
||||||
const isAttempted = !!attempt;
|
const isAttempted = !!attempt;
|
||||||
|
|
||||||
// Accurate Date & Time logic (IST aware)
|
// Date logic
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = parseAsISTDate(dpp.scheduled_date);
|
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now;
|
||||||
const endTime = parseAsISTDate(dpp.end_date);
|
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
||||||
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
const isUpcoming = scheduledTime > now;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
const isLive = !isUpcoming && !isExpired;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-85 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-70' : 'hover:border-[var(--cyan-ai-20)] bg-background'}`}>
|
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-70 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-80' : 'hover:border-[var(--cyan-ai-10)] bg-background'}`}>
|
||||||
<CardContent className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<CardContent className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="flex gap-4 items-start w-full">
|
<div className="flex gap-4 items-start w-full">
|
||||||
{/* Clean Status Icon — No Selectable Circle Checkboxes */}
|
<div className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
|
||||||
<div className="mt-1 flex-shrink-0">
|
{isAttempted ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground flex items-center justify-center opacity-50"></div>}
|
||||||
{isAttempted ? (
|
|
||||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-500 border border-emerald-500/20" title="Completed">
|
|
||||||
<CheckCircle2 size={18} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-1.5 rounded-lg bg-secondary/60 text-[var(--cyan-ai)] border border-border" title="Practice Sheet">
|
|
||||||
<FileText size={18} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
||||||
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
||||||
</span>
|
</span>
|
||||||
@@ -207,30 +183,14 @@ export default function AIDailyPracticePage() {
|
|||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-amber-500 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-md flex items-center gap-1">
|
<span className="text-[10px] font-bold uppercase text-yellow-500 bg-yellow-500/10 px-1.5 py-0.5 rounded">Upcoming</span>
|
||||||
🔒 Upcoming
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : isExpired ? (
|
) : isExpired ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-2 py-0.5 rounded-md">
|
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">Expired</span>
|
||||||
⏳ Expired
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
|
||||||
<span className="text-[10px] font-bold uppercase text-emerald-500 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-md">
|
|
||||||
🟢 Live Now
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isAttempted && (
|
|
||||||
<span className="text-[10px] font-bold uppercase text-green-500 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-md">
|
|
||||||
Attempted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
||||||
{cleanTitle(dpp.title)}
|
{cleanTitle(dpp.title)}
|
||||||
@@ -241,25 +201,10 @@ export default function AIDailyPracticePage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
||||||
<span>{count} Questions • {dpp.total_time} mins</span>
|
{count} Questions • {dpp.total_time} mins
|
||||||
{isUpcoming && scheduledTime && (
|
{dpp.scheduled_date && (
|
||||||
<span className="ml-2 border-l border-border pl-2 text-amber-500 font-semibold">
|
<span className="ml-2 border-l border-border pl-2">
|
||||||
Opens on {formatDateTime(dpp.scheduled_date)}
|
{isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isExpired && endTime && (
|
|
||||||
<span className="ml-2 border-l border-border pl-2 text-destructive font-semibold">
|
|
||||||
Expired on {formatDateTime(dpp.end_date)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isLive && endTime && (
|
|
||||||
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
|
||||||
Closes on {formatDateTime(dpp.end_date)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isLive && !endTime && scheduledTime && (
|
|
||||||
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
|
||||||
Available since {formatDateTime(dpp.scheduled_date)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -267,13 +212,9 @@ export default function AIDailyPracticePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold text-amber-500 border-amber-500/30 bg-amber-500/5">
|
<Button disabled variant="outline" size="sm" className="font-bold">Starts Soon</Button>
|
||||||
Opens at {scheduledTime ? formatTime(dpp.scheduled_date) : "Scheduled Time"}
|
|
||||||
</Button>
|
|
||||||
) : isExpired && !isAttempted ? (
|
) : isExpired && !isAttempted ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50 text-muted-foreground">
|
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
|
||||||
Expired
|
|
||||||
</Button>
|
|
||||||
) : (
|
) : (
|
||||||
hasAccessTo('PRO') ? (
|
hasAccessTo('PRO') ? (
|
||||||
<Button onClick={() => setSelectedPdfDpp(dpp)} variant="secondary" size="sm" className="rounded-xl bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2 text-xs shadow-[0_0_12px_rgba(12,96,96,0.2)]">
|
<Button onClick={() => setSelectedPdfDpp(dpp)} variant="secondary" size="sm" className="rounded-xl bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2 text-xs shadow-[0_0_12px_rgba(12,96,96,0.2)]">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ThemeToggle } from "@/components/ThemeToggle"
|
|||||||
import { TierBadge } from "@/components/TierBadge"
|
import { TierBadge } from "@/components/TierBadge"
|
||||||
import { StreakCounter } from "@/components/StreakCounter"
|
import { StreakCounter } from "@/components/StreakCounter"
|
||||||
import { AITutorChat } from "@/components/AI/AITutorChat"
|
import { AITutorChat } from "@/components/AI/AITutorChat"
|
||||||
import { useActiveUser, DEMO_USERS, isDemoUser, isDemoAccount } from "@/hooks/useActiveUser"
|
import { useActiveUser, DEMO_USERS, isDemoUser } from "@/hooks/useActiveUser"
|
||||||
import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingModal"
|
import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingModal"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import MaintenanceScreen from "@/components/MaintenanceScreen"
|
import MaintenanceScreen from "@/components/MaintenanceScreen"
|
||||||
@@ -309,7 +309,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
{/* Functional User Dropdown */}
|
{/* Functional User Dropdown */}
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
{dropdownOpen && user && (
|
{dropdownOpen && user && (
|
||||||
<div className="absolute bottom-full left-0 w-full mb-2 bg-background border border-border rounded-xl shadow-xl shadow-black/20 overflow-hidden z-50">
|
<div className="absolute bottom-full left-0 w-full mb-2 bg-background border border-border rounded-xl shadow-xl shadow-black/20 overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200 z-50">
|
||||||
<div className="px-4 py-3 border-b border-border bg-secondary/30">
|
<div className="px-4 py-3 border-b border-border bg-secondary/30">
|
||||||
<p className="text-sm font-bold text-foreground truncate">{cleanDisplayName(user.name)}</p>
|
<p className="text-sm font-bold text-foreground truncate">{cleanDisplayName(user.name)}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">{user.email || user.phone}</p>
|
<p className="text-xs text-muted-foreground truncate">{user.email || user.phone}</p>
|
||||||
@@ -322,7 +322,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
<Link href="/dashboard/settings/password" className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors">
|
<Link href="/dashboard/settings/password" className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors">
|
||||||
<Lock size={16} /> Change Password
|
<Lock size={16} /> Change Password
|
||||||
</Link>
|
</Link>
|
||||||
{isDemoAccount(userId) && (
|
{isDemoUser(userId) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => switchUser(userId, true)}
|
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"
|
||||||
@@ -391,7 +391,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Demo Switcher — only visible when logged in as a known demo account */}
|
{/* Demo Switcher — only visible when logged in as a known demo account */}
|
||||||
{isDemoAccount(userId) && (
|
{isDemoUser(userId) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{switching && (
|
{switching && (
|
||||||
<span className="text-xs text-[var(--cyan-ai)] font-bold animate-pulse hidden lg:inline">Switching…</span>
|
<span className="text-xs text-[var(--cyan-ai)] font-bold animate-pulse hidden lg:inline">Switching…</span>
|
||||||
@@ -430,7 +430,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
{/* Scrollable page body */}
|
{/* Scrollable page body */}
|
||||||
<main className="flex-1 overflow-y-auto relative bg-background/50">
|
<main className="flex-1 overflow-y-auto relative bg-background/50">
|
||||||
<div className="w-full h-full">
|
<div key={pathname} className="w-full h-full">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function LeaderboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top 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 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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -32,7 +32,7 @@ export default function MaterialsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<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">
|
||||||
|
|||||||
@@ -9,34 +9,11 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { BrainCircuit, Play, ChevronRight, Activity, Target, CheckCircle2, AlertTriangle, TrendingUp, Calendar, Zap, LayoutDashboard, Share2, Trophy, Lock, ArrowRight, Clock, Download } from "lucide-react"
|
import { BrainCircuit, Play, ChevronRight, Activity, Target, CheckCircle2, AlertTriangle, TrendingUp, Calendar, Zap, LayoutDashboard, Share2, Trophy, Lock, ArrowRight, Clock, Download } from "lucide-react"
|
||||||
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
||||||
import { AdaptiveStudyPlan } from "@/components/AI/AdaptiveStudyPlan"
|
import { AdaptiveStudyPlan } from "@/components/AI/AdaptiveStudyPlan"
|
||||||
import { useActiveUser, isDemoUser, DEFAULT_USER_ID } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
import { formatISTDate } from "@/lib/dateUtils"
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
|
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
|
||||||
const [userStats, setUserStats] = useState<any>(() => {
|
const [userStats, setUserStats] = useState<any>(null)
|
||||||
const currentId = (typeof window !== "undefined" ? localStorage.getItem("activeUserId") : null) || DEFAULT_USER_ID;
|
|
||||||
if (isDemoUser(currentId)) {
|
|
||||||
return {
|
|
||||||
today_score: 42,
|
|
||||||
today_max_score: 60,
|
|
||||||
overall_accuracy: 82.0,
|
|
||||||
total_questions_attempted: 1245,
|
|
||||||
city_rank: 42,
|
|
||||||
city_total: 1200,
|
|
||||||
is_new_user: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
today_score: 0,
|
|
||||||
today_max_score: 0,
|
|
||||||
overall_accuracy: 0,
|
|
||||||
total_questions_attempted: 0,
|
|
||||||
city_rank: null,
|
|
||||||
city_total: null,
|
|
||||||
is_new_user: true,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [dpps, setDpps] = useState<any[]>([])
|
const [dpps, setDpps] = useState<any[]>([])
|
||||||
const [cbts, setCbts] = useState<any[]>([])
|
const [cbts, setCbts] = useState<any[]>([])
|
||||||
@@ -45,7 +22,8 @@ export default function DashboardPage() {
|
|||||||
const progressRef = useRef<HTMLDivElement>(null)
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentUserId = user?.id || userId || (typeof window !== "undefined" ? localStorage.getItem("activeUserId") : null) || DEFAULT_USER_ID;
|
if (userLoading) return
|
||||||
|
const currentUserId = user?.id || userId
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -70,7 +48,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchStatsAndTests()
|
fetchStatsAndTests()
|
||||||
}, [user?.id, userId, user?.target_exam])
|
}, [userLoading, user?.id, userId])
|
||||||
|
|
||||||
const getShareUrl = () => {
|
const getShareUrl = () => {
|
||||||
const score = isNew ? "0 / 0" : `${userStats?.today_score || 0} / ${userStats?.today_max_score || 60}`
|
const score = isNew ? "0 / 0" : `${userStats?.today_score || 0} / ${userStats?.today_max_score || 60}`
|
||||||
@@ -130,15 +108,24 @@ export default function DashboardPage() {
|
|||||||
setShareMenuOpen(false)
|
setShareMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-[70vh] flex flex-col items-center justify-center p-8">
|
||||||
|
<div className="h-8 w-8 border-4 border-[var(--cyan-ai)] border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||||
|
<p className="text-muted-foreground font-medium animate-pulse">Loading dashboard...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const isNew = userStats ? userStats.is_new_user : true
|
const isNew = userStats ? userStats.is_new_user : true
|
||||||
const isNeet = user?.target_exam === "NEET"
|
const isNeet = user?.target_exam === "NEET"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-7xl mx-auto space-y-8">
|
<div className="p-8 max-w-7xl mx-auto space-y-10 animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Basic Performance Dashboard (F-05) */}
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<section>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
|
||||||
<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)]">
|
||||||
@@ -173,11 +160,9 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div ref={progressRef} className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 -mx-4 bg-background rounded-2xl">
|
||||||
{/* KPI Stat Cards */}
|
|
||||||
<div ref={progressRef} className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{/* Today's Score */}
|
{/* Today's Score */}
|
||||||
<Card className="border-border shadow-sm">
|
<Card>
|
||||||
<CardContent className="p-5 flex flex-col items-center text-center">
|
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||||
<div className="p-3 bg-secondary rounded-full mb-3 text-primary"><Target size={24} /></div>
|
<div className="p-3 bg-secondary rounded-full mb-3 text-primary"><Target size={24} /></div>
|
||||||
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Today's Score</p>
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Today's Score</p>
|
||||||
@@ -191,7 +176,7 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Overall Accuracy */}
|
{/* Overall Accuracy */}
|
||||||
<Card className="border-border shadow-sm">
|
<Card>
|
||||||
<CardContent className="p-5 flex flex-col items-center text-center">
|
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||||
<div className="p-3 bg-secondary rounded-full mb-3 text-amber-500"><Activity size={24} /></div>
|
<div className="p-3 bg-secondary rounded-full mb-3 text-amber-500"><Activity size={24} /></div>
|
||||||
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Overall Accuracy</p>
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Overall Accuracy</p>
|
||||||
@@ -205,7 +190,7 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Qs Attempted */}
|
{/* Qs Attempted */}
|
||||||
<Card className="border-border shadow-sm">
|
<Card>
|
||||||
<CardContent className="p-5 flex flex-col items-center text-center">
|
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||||
<div className="p-3 bg-secondary rounded-full mb-3 text-[var(--cyan-ai)]"><CheckCircle2 size={24} /></div>
|
<div className="p-3 bg-secondary rounded-full mb-3 text-[var(--cyan-ai)]"><CheckCircle2 size={24} /></div>
|
||||||
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Qs Attempted</p>
|
<p className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-1">Qs Attempted</p>
|
||||||
@@ -219,7 +204,7 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* City Rank */}
|
{/* City Rank */}
|
||||||
<Card className="border-border shadow-sm relative overflow-hidden group">
|
<Card className="relative overflow-hidden group">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-background to-secondary/50 z-0"></div>
|
<div className="absolute inset-0 bg-gradient-to-br from-background to-secondary/50 z-0"></div>
|
||||||
<CardContent className="p-5 flex flex-col items-center text-center relative z-10">
|
<CardContent className="p-5 flex flex-col items-center text-center relative z-10">
|
||||||
<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 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>
|
||||||
@@ -239,23 +224,21 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Tests & DPPs Grid */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Dynamic DPP Modules */}
|
{/* Dynamic DPP Modules */}
|
||||||
<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 flex items-center gap-2">
|
<h2 className="text-xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
Today's DPPs
|
Today's DPPs
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/dashboard/dpp">
|
|
||||||
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
|
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{dpps.length > 0 ? (
|
{dpps.length > 0 ? (
|
||||||
dpps.map((dpp) => (
|
dpps.map((dpp) => (
|
||||||
<Card key={dpp.id} className="hover:border-primary/30 cursor-pointer group">
|
<Card key={dpp.id} className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
|
||||||
<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-foreground">
|
||||||
@@ -274,27 +257,8 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
) : loading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<Card key={i} className="animate-pulse">
|
|
||||||
<CardContent className="p-5 flex items-center justify-between">
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<div className="size-11 bg-secondary rounded-xl"></div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-40 bg-secondary rounded"></div>
|
|
||||||
<div className="h-3 w-28 bg-secondary/60 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-16 bg-secondary rounded-xl"></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden p-6 border-dashed opacity-70">
|
<p className="text-muted-foreground text-sm py-4">No DPPs scheduled for today.</p>
|
||||||
<p className="text-muted-foreground text-sm">No DPPs scheduled for today.</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -305,9 +269,6 @@ export default function DashboardPage() {
|
|||||||
<h2 className="text-xl font-semibold tracking-tight flex items-center gap-2">
|
<h2 className="text-xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
Upcoming CBTs
|
Upcoming CBTs
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/dashboard/cbt">
|
|
||||||
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{cbts.length > 0 ? (
|
{cbts.length > 0 ? (
|
||||||
@@ -317,7 +278,7 @@ export default function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">{cbt.exam_pattern}</Badge>
|
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">{cbt.exam_pattern}</Badge>
|
||||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">{cbt.scheduled_date ? formatISTDate(cbt.scheduled_date) : 'Available Now'}</span>
|
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">{cbt.scheduled_date ? new Date(cbt.scheduled_date).toLocaleDateString() : 'Available Now'}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-lg tracking-tight">{cbt.title}</h3>
|
<h3 className="font-semibold text-lg tracking-tight">{cbt.title}</h3>
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
|
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
|
||||||
@@ -330,14 +291,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
) : loading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="animate-pulse p-6 space-y-3">
|
|
||||||
<div className="h-4 w-24 bg-secondary rounded"></div>
|
|
||||||
<div className="h-5 w-48 bg-secondary rounded"></div>
|
|
||||||
<div className="h-3 w-32 bg-secondary/60 rounded"></div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden p-6 border-dashed opacity-70">
|
<Card className="overflow-hidden p-6 border-dashed opacity-70">
|
||||||
<p className="text-muted-foreground text-sm">No upcoming CBTs at the moment.</p>
|
<p className="text-muted-foreground text-sm">No upcoming CBTs at the moment.</p>
|
||||||
@@ -347,14 +300,13 @@ export default function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Intelligence Grid */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div>
|
<section>
|
||||||
<AdaptiveStudyPlan />
|
<AdaptiveStudyPlan />
|
||||||
</div>
|
</section>
|
||||||
<div>
|
<section>
|
||||||
<MistakeHeatmap />
|
<MistakeHeatmap />
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
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, Lock } 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"
|
import { PremiumGate } from "@/components/PremiumGate"
|
||||||
|
|
||||||
@@ -134,26 +134,25 @@ const ZERO_SUBJECTS: Subject[] = [
|
|||||||
// Math/Bio dynamic subjects handled below
|
// Math/Bio dynamic subjects handled below
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export default function ProgressTrackerPage() {
|
||||||
|
const { userId, user } = useActiveUser()
|
||||||
|
const isDemo = isDemoUser(userId)
|
||||||
|
const [subjects, setSubjects] = useState<Subject[]>([])
|
||||||
|
|
||||||
const mathDemoSubject: Subject = {
|
useEffect(() => {
|
||||||
|
const targetExam = user?.target_exam || "JEE_Main";
|
||||||
|
const isNeet = targetExam === "NEET";
|
||||||
|
|
||||||
|
const mathDemoSubject: Subject = {
|
||||||
name: "Mathematics",
|
name: "Mathematics",
|
||||||
completionPct: 28,
|
completionPct: 22,
|
||||||
chapters: [
|
chapters: [
|
||||||
{
|
{
|
||||||
name: "Calculus",
|
name: "Calculus",
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
topics: [
|
topics: [
|
||||||
{ name: "Limits", status: "In Progress", accuracy: 60, questionsAttempted: 45 },
|
{ name: "Limits", status: "In Progress", accuracy: 60, questionsAttempted: 45 },
|
||||||
{ name: "Derivatives", status: "Practiced", accuracy: 72, questionsAttempted: 35 },
|
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
{ name: "Integrals", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Coordinate Geometry",
|
|
||||||
isExpanded: false,
|
|
||||||
topics: [
|
|
||||||
{ name: "Straight Lines", status: "Practiced", accuracy: 80, questionsAttempted: 40 },
|
|
||||||
{ name: "Circles", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -170,14 +169,6 @@ const mathDemoSubject: Subject = {
|
|||||||
{ name: "Cell Wall", status: "Practiced", accuracy: 80, questionsAttempted: 60 },
|
{ name: "Cell Wall", status: "Practiced", accuracy: 80, questionsAttempted: 60 },
|
||||||
{ name: "Nucleus", status: "In Progress", accuracy: 50, questionsAttempted: 25 },
|
{ name: "Nucleus", status: "In Progress", accuracy: 50, questionsAttempted: 25 },
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Genetics & Evolution",
|
|
||||||
isExpanded: false,
|
|
||||||
topics: [
|
|
||||||
{ name: "Mendelian Genetics", status: "Practiced", accuracy: 85, questionsAttempted: 55 },
|
|
||||||
{ name: "Molecular Basis of Inheritance", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -192,15 +183,6 @@ const mathDemoSubject: Subject = {
|
|||||||
topics: [
|
topics: [
|
||||||
{ name: "Limits", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Limits", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
{ name: "Integrals", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Coordinate Geometry",
|
|
||||||
isExpanded: false,
|
|
||||||
topics: [
|
|
||||||
{ name: "Straight Lines", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
{ name: "Circles", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -217,98 +199,15 @@ const mathDemoSubject: Subject = {
|
|||||||
{ name: "Cell Wall", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Cell Wall", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
{ name: "Nucleus", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Nucleus", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Genetics & Evolution",
|
|
||||||
isExpanded: false,
|
|
||||||
topics: [
|
|
||||||
{ name: "Mendelian Genetics", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
{ name: "Molecular Basis of Inheritance", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
function getInitialSubjects(isDemoUser: boolean, targetExam: string): Subject[] {
|
const base = isDemo ? [...DEMO_SUBJECTS] : [...ZERO_SUBJECTS];
|
||||||
const isNeet = targetExam === "NEET";
|
base.push(isDemo ? (isNeet ? bioDemoSubject : mathDemoSubject) : (isNeet ? bioZeroSubject : mathZeroSubject));
|
||||||
const isJeeNeet = targetExam === "JEE_NEET";
|
|
||||||
const base = isDemoUser ? [...DEMO_SUBJECTS] : [...ZERO_SUBJECTS];
|
|
||||||
if (isJeeNeet) {
|
|
||||||
base.push(isDemoUser ? mathDemoSubject : mathZeroSubject);
|
|
||||||
base.push(isDemoUser ? bioDemoSubject : bioZeroSubject);
|
|
||||||
} else if (isNeet) {
|
|
||||||
base.push(isDemoUser ? bioDemoSubject : bioZeroSubject);
|
|
||||||
} else {
|
|
||||||
base.push(isDemoUser ? mathDemoSubject : mathZeroSubject);
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
export default function ProgressTrackerPage() {
|
|
||||||
const { userId, user, hasAccessTo } = useActiveUser();
|
|
||||||
const isDemo = isDemoUser(userId);
|
|
||||||
const [subjects, setSubjects] = useState<Subject[]>(() =>
|
|
||||||
getInitialSubjects(isDemoUser(userId), user?.target_exam || "JEE_Main")
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSubjects(getInitialSubjects(isDemo, user?.target_exam || "JEE_Main"));
|
|
||||||
|
|
||||||
const handleSwitched = (e: any) => {
|
|
||||||
const switchedUser = e.detail?.user;
|
|
||||||
const switchedId = e.detail?.userId;
|
|
||||||
const exam = switchedUser?.target_exam || (switchedId === "33333333-3333-3333-3333-333333333333" ? "JEE_NEET" : "JEE_Main");
|
|
||||||
const demo = isDemoUser(switchedId);
|
|
||||||
setSubjects(getInitialSubjects(demo, exam));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdated = () => {
|
|
||||||
setSubjects(getInitialSubjects(isDemoUser(userId), user?.target_exam || "JEE_Main"));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("pragativeer-user-switched", handleSwitched);
|
|
||||||
window.addEventListener("pragativeer-user-updated", handleUpdated);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pragativeer-user-switched", handleSwitched);
|
|
||||||
window.removeEventListener("pragativeer-user-updated", handleUpdated);
|
|
||||||
};
|
|
||||||
}, [isDemo, user?.target_exam, userId]);
|
|
||||||
|
|
||||||
if (!hasAccessTo("chapter-wise-progress-tracking") && !isDemo) {
|
|
||||||
return (
|
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
|
||||||
<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">
|
|
||||||
<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} />
|
|
||||||
</span>
|
|
||||||
Syllabus Tracker
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-base font-medium max-w-2xl pl-1">
|
|
||||||
Track your syllabus coverage and mastery per chapter to ensure no gaps.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-12 text-center max-w-2xl mx-auto bg-secondary/20 border border-border rounded-3xl space-y-6">
|
|
||||||
<div className="size-20 rounded-full bg-red-500/10 flex items-center justify-center mx-auto text-red-500">
|
|
||||||
<Lock size={40} />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold">Premium Feature</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Chapter-wise syllabus tracking is available on Pro and AI Coach plans.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
className="px-8 py-3 bg-[var(--cyan-ai)] text-black font-bold rounded-xl shadow-[0_0_20px_-5px_var(--cyan-ai)]"
|
|
||||||
onClick={() => window.location.href = '/upgrade'}
|
|
||||||
>
|
|
||||||
Upgrade Plan
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
setSubjects(base);
|
||||||
|
}, [isDemo, user?.target_exam]);
|
||||||
|
|
||||||
const toggleChapter = (subjectIndex: number, chapterIndex: number) => {
|
const toggleChapter = (subjectIndex: number, chapterIndex: number) => {
|
||||||
const newSubjects = [...subjects]
|
const newSubjects = [...subjects]
|
||||||
@@ -317,8 +216,8 @@ export default function ProgressTrackerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PremiumGate featureKey="chapter-wise-progress-tracking">
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<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">
|
||||||
@@ -333,7 +232,7 @@ export default function ProgressTrackerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`grid grid-cols-1 sm:grid-cols-2 ${subjects.length >= 4 ? 'lg:grid-cols-4' : 'md:grid-cols-3'} gap-6 mb-8`}>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
{subjects.map((sub, i) => (
|
{subjects.map((sub, i) => (
|
||||||
<Card key={i} className="border-border shadow-sm">
|
<Card key={i} className="border-border shadow-sm">
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
@@ -408,6 +307,6 @@ export default function ProgressTrackerPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</PremiumGate>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function RankPredictorPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto min-h-screen">
|
<div className="p-6 md:p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto min-h-screen">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center max-w-3xl mx-auto space-y-3">
|
<div className="text-center max-w-3xl mx-auto space-y-3">
|
||||||
@@ -240,7 +240,7 @@ export default function RankPredictorPage() {
|
|||||||
{totalScore === 0 ? "AIR --" : `AIR ~${predictedRank.toLocaleString()}`}
|
{totalScore === 0 ? "AIR --" : `AIR ~${predictedRank.toLocaleString()}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[var(--cyan-ai-20)] text-[var(--cyan-ai)] text-xs font-bold border border-[var(--cyan-ai-40)]">
|
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[var(--cyan-ai-20)] text-[var(--cyan-ai)] text-xs font-bold border border-[var(--cyan-ai-40)]">
|
||||||
<CheckCircle2 size={13} /> {totalScore === 0 ? "Calibrating <EFBFBD> Adjust Sliders" : "High Accuracy Confidence"}
|
<CheckCircle2 size={13} /> {totalScore === 0 ? "Calibrating · Adjust Sliders" : "High Accuracy Confidence"}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { fetchWithAuth } from "@/lib/apiClient";
|
import { fetchWithAuth } from "@/lib/apiClient";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -45,7 +45,7 @@ export default function PasswordPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-3xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-3xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { fetchWithAuth } from "@/lib/apiClient";
|
import { fetchWithAuth } from "@/lib/apiClient";
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
@@ -46,7 +46,7 @@ export default function StreaksPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
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"
|
||||||
@@ -46,7 +46,7 @@ export default function ForgotPasswordPage() {
|
|||||||
<ArrowLeft size={16} /> Back to Login
|
<ArrowLeft size={16} /> Back to Login
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-8 duration-700">
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,40 +116,11 @@ body {
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Intro/opening animations completely eliminated for instant, rock-solid page loading ─── */
|
|
||||||
*, *::before, *::after {
|
|
||||||
--tw-enter-opacity: 1 !important;
|
|
||||||
--tw-enter-scale: 1 !important;
|
|
||||||
--tw-enter-translate-x: 0 !important;
|
|
||||||
--tw-enter-translate-y: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-page-enter,
|
|
||||||
.animate-section,
|
|
||||||
.animate-section-1,
|
|
||||||
.animate-section-2,
|
|
||||||
.animate-section-3,
|
|
||||||
.animate-section-4,
|
|
||||||
.animate-section-5,
|
|
||||||
.animate-section-6,
|
|
||||||
.animate-in,
|
|
||||||
[class*="animate-in"],
|
|
||||||
[class*="fade-in"],
|
|
||||||
[class*="slide-in"],
|
|
||||||
[class*="zoom-in"] {
|
|
||||||
animation: none !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
transform: none !important;
|
|
||||||
filter: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.text-balance {
|
.text-balance {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.bg-grid {
|
.bg-grid {
|
||||||
background-size: 40px 40px;
|
background-size: 40px 40px;
|
||||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||||
@@ -202,4 +173,3 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--muted-foreground);
|
background: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export default function LoginPage() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
// Set auth_token cookie required by Next.js 16 proxy.ts middleware
|
// Set auth_token cookie required by Next.js 16 proxy.ts middleware
|
||||||
const cookieVal = data.role === "admin" ? "admin" : (data.token || "authenticated");
|
// Admin now gets a real JWT (same as users); the opaque "admin"
|
||||||
|
// token is deprecated. Keep "authenticated" fallback for safety.
|
||||||
|
const cookieVal = data.token || "authenticated";
|
||||||
document.cookie = `auth_token=${cookieVal}; path=/; max-age=2592000; SameSite=Lax`;
|
document.cookie = `auth_token=${cookieVal}; path=/; max-age=2592000; SameSite=Lax`;
|
||||||
|
|
||||||
localStorage.setItem("activeUserId", data.user_id || data.token);
|
localStorage.setItem("activeUserId", data.user_id || data.token);
|
||||||
@@ -40,7 +42,7 @@ export default function LoginPage() {
|
|||||||
const sessionToken = data.token;
|
const sessionToken = data.token;
|
||||||
localStorage.setItem("active_session_token", sessionToken);
|
localStorage.setItem("active_session_token", sessionToken);
|
||||||
|
|
||||||
if (data.token && data.token !== "admin") {
|
if (data.token) {
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth(`/api/users/${data.user_id}/session`, {
|
await fetchWithAuth(`/api/users/${data.user_id}/session`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -62,17 +64,7 @@ export default function LoginPage() {
|
|||||||
setError(data.error || "Invalid email or password.")
|
setError(data.error || "Invalid email or password.")
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Local dev offline fallback: if backend is offline on localhost, allow demo login to test frontend
|
setError("An error occurred. Please try again.")
|
||||||
if (email === "demo@pragativeer.com" || email.includes("demo") || email === "admin@pragativeer.com") {
|
|
||||||
const isAdmin = email.includes("admin");
|
|
||||||
const cookieVal = isAdmin ? "admin" : "authenticated";
|
|
||||||
document.cookie = `auth_token=${cookieVal}; path=/; max-age=2592000; SameSite=Lax`;
|
|
||||||
localStorage.setItem("activeUserId", isAdmin ? "admin" : "11111111-1111-1111-1111-111111111111");
|
|
||||||
localStorage.setItem("active_session_token", "dev-offline-token");
|
|
||||||
window.location.href = isAdmin ? "/admin" : "/dashboard";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("Backend server is not running on port 8000. Start backend or use demo@pragativeer.com to test locally.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +74,7 @@ export default function LoginPage() {
|
|||||||
<ArrowLeft size={16} /> Back to Home
|
<ArrowLeft size={16} /> Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-8 duration-700">
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -40,6 +41,7 @@ export default function OnboardingPage() {
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem("chosen_plan", plan);
|
localStorage.setItem("chosen_plan", plan);
|
||||||
}
|
}
|
||||||
|
// Small UX delay so the button animation feels intentional
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
};
|
};
|
||||||
@@ -52,14 +54,19 @@ export default function OnboardingPage() {
|
|||||||
<div className="absolute bottom-0 right-1/4 size-[500px] bg-[var(--cyan-ai-10)] blur-[120px] rounded-full" />
|
<div className="absolute bottom-0 right-1/4 size-[500px] bg-[var(--cyan-ai-10)] blur-[120px] rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-5xl space-y-12">
|
<div className="w-full max-w-5xl space-y-12 animate-in fade-in slide-in-from-bottom-6 duration-700">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Logo href="/" className="w-56" imageClassName="object-contain" height={64} />
|
<Logo href="/" className="w-56" imageClassName="object-contain" height={64} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center space-y-4">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-center space-y-4"
|
||||||
|
>
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 text-primary font-bold text-sm mb-2">
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 text-primary font-bold text-sm mb-2">
|
||||||
<Sparkles size={14} className="animate-pulse" />
|
<Sparkles size={14} className="animate-pulse" />
|
||||||
Account Created Successfully!
|
Account Created Successfully!
|
||||||
@@ -78,12 +85,16 @@ export default function OnboardingPage() {
|
|||||||
<Clock size={16} />
|
<Clock size={16} />
|
||||||
Your 15-day free trial with all features starts now — Day 1 of 15
|
Your 15-day free trial with all features starts now — Day 1 of 15
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Plan Cards */}
|
{/* Plan Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||||
{/* Pro Plan */}
|
{/* Pro Plan */}
|
||||||
<div>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -40 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.15, type: "spring" }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="relative rounded-[2rem] p-[1px] h-full group cursor-pointer"
|
className="relative rounded-[2rem] p-[1px] h-full group cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
@@ -92,7 +103,7 @@ export default function OnboardingPage() {
|
|||||||
}}
|
}}
|
||||||
onClick={() => !selecting && handleSelect("Pro")}
|
onClick={() => !selecting && handleSelect("Pro")}
|
||||||
>
|
>
|
||||||
<div className="bg-background rounded-[calc(2rem-1px)] p-7 flex flex-col h-full hover:bg-secondary/20">
|
<div className="bg-background rounded-[calc(2rem-1px)] p-7 flex flex-col h-full transition-all duration-300 group-hover:bg-secondary/20">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-yellow-500/10 border border-yellow-500/20 text-yellow-500 text-xs font-bold mb-3 uppercase tracking-wider">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-yellow-500/10 border border-yellow-500/20 text-yellow-500 text-xs font-bold mb-3 uppercase tracking-wider">
|
||||||
@@ -124,7 +135,7 @@ export default function OnboardingPage() {
|
|||||||
if (!selecting) handleSelect("Pro");
|
if (!selecting) handleSelect("Pro");
|
||||||
}}
|
}}
|
||||||
disabled={!!selecting}
|
disabled={!!selecting}
|
||||||
className="w-full h-12 rounded-xl bg-yellow-500 text-black font-bold text-base hover:bg-yellow-400 shadow-[0_0_20px_-5px_rgba(234,179,8,0.5)] hover:shadow-[0_0_30px_-3px_rgba(234,179,8,0.7)] disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="w-full h-12 rounded-xl bg-yellow-500 text-black font-bold text-base hover:bg-yellow-400 transition-all duration-300 shadow-[0_0_20px_-5px_rgba(234,179,8,0.5)] hover:shadow-[0_0_30px_-3px_rgba(234,179,8,0.7)] hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{selecting === "Pro" ? (
|
{selecting === "Pro" ? (
|
||||||
<>
|
<>
|
||||||
@@ -140,10 +151,14 @@ export default function OnboardingPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* AI Coach Plan */}
|
{/* AI Coach Plan */}
|
||||||
<div>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 40 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.25, type: "spring" }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="relative rounded-[2rem] p-[1px] h-full group cursor-pointer"
|
className="relative rounded-[2rem] p-[1px] h-full group cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
@@ -157,7 +172,7 @@ export default function OnboardingPage() {
|
|||||||
<Sparkles size={12} /> Most Popular
|
<Sparkles size={12} /> Most Popular
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background rounded-[calc(2rem-1px)] p-7 flex flex-col h-full hover:bg-secondary/20">
|
<div className="bg-background rounded-[calc(2rem-1px)] p-7 flex flex-col h-full transition-all duration-300 group-hover:bg-secondary/20">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 pt-2">
|
<div className="mb-6 pt-2">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--cyan-ai-10)] border border-[var(--cyan-ai-20)] text-[var(--cyan-ai)] text-xs font-bold mb-3 uppercase tracking-wider">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--cyan-ai-10)] border border-[var(--cyan-ai-20)] text-[var(--cyan-ai)] text-xs font-bold mb-3 uppercase tracking-wider">
|
||||||
@@ -189,7 +204,7 @@ export default function OnboardingPage() {
|
|||||||
if (!selecting) handleSelect("AI Coach");
|
if (!selecting) handleSelect("AI Coach");
|
||||||
}}
|
}}
|
||||||
disabled={!!selecting}
|
disabled={!!selecting}
|
||||||
className="w-full h-12 rounded-xl bg-[var(--cyan-ai)] text-black font-bold text-base hover:bg-[var(--cyan-ai-80)] shadow-[0_0_20px_-5px_rgba(12,96,96,0.5)] hover:shadow-[0_0_30px_-3px_rgba(12,96,96,0.8)] disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="w-full h-12 rounded-xl bg-[var(--cyan-ai)] text-black font-bold text-base hover:bg-[var(--cyan-ai-80)] transition-all duration-300 shadow-[0_0_20px_-5px_rgba(12,96,96,0.5)] hover:shadow-[0_0_30px_-3px_rgba(12,96,96,0.8)] hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{selecting === "AI Coach" ? (
|
{selecting === "AI Coach" ? (
|
||||||
<>
|
<>
|
||||||
@@ -205,13 +220,18 @@ export default function OnboardingPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom note */}
|
{/* Bottom note */}
|
||||||
<p className="text-center text-xs text-muted-foreground font-medium pb-8">
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.5 }}
|
||||||
|
className="text-center text-xs text-muted-foreground font-medium pb-8"
|
||||||
|
>
|
||||||
🔒 No credit card required for trial · Cancel anytime · All prices inclusive of GST
|
🔒 No credit card required for trial · Cancel anytime · All prices inclusive of GST
|
||||||
</p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
185
src/app/page.tsx
185
src/app/page.tsx
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -6,20 +6,61 @@ import { Card, CardContent } from "@/components/ui/card"
|
|||||||
import { BrainCircuit, Activity, Zap, CheckCircle2, Shield, ArrowRight, Play, BookOpen, LineChart, Trophy, Sparkles, MessageSquare, X, Minus, Target, Calendar } from "lucide-react"
|
import { BrainCircuit, Activity, Zap, CheckCircle2, Shield, ArrowRight, Play, BookOpen, LineChart, Trophy, Sparkles, MessageSquare, X, Minus, Target, Calendar } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { motion, useScroll, useTransform, Variants } from "framer-motion"
|
||||||
|
import { useRef, useEffect } from "react"
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle"
|
import { ThemeToggle } from "@/components/ThemeToggle"
|
||||||
|
|
||||||
export default function MarketingPage() {
|
export default function MarketingPage() {
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background text-foreground overflow-x-hidden selection:bg-primary/20 relative">
|
|
||||||
|
|
||||||
{/* Deep Background */}
|
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: containerRef,
|
||||||
|
offset: ["start start", "end start"]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Slower background parallax
|
||||||
|
const yBg = useTransform(scrollYProgress, [0, 1], ["0%", "50%"])
|
||||||
|
|
||||||
|
// Stagger variants for the features
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 30 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { type: "spring", stiffness: 100, damping: 12 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="min-h-screen bg-background text-foreground overflow-x-hidden selection:bg-primary/20 relative">
|
||||||
|
|
||||||
|
{/* Animated Deep Background */}
|
||||||
<div className="fixed inset-0 z-0 pointer-events-none bg-grid opacity-30"></div>
|
<div className="fixed inset-0 z-0 pointer-events-none bg-grid opacity-30"></div>
|
||||||
<div className="fixed inset-0 z-0 pointer-events-none" style={{ background: 'radial-gradient(circle at 50% 0%, rgba(209, 255, 77, 0.05) 0%, transparent 50%)' }}></div>
|
<div className="fixed inset-0 z-0 pointer-events-none" style={{ background: 'radial-gradient(circle at 50% 0%, rgba(209, 255, 77, 0.05) 0%, transparent 50%)' }}></div>
|
||||||
<div className="fixed inset-0 z-0 pointer-events-none" style={{ background: 'radial-gradient(circle at 100% 50%, rgba(12, 96, 96, 0.03) 0%, transparent 40%)' }}></div>
|
<div className="fixed inset-0 z-0 pointer-events-none" style={{ background: 'radial-gradient(circle at 100% 50%, rgba(12, 96, 96, 0.03) 0%, transparent 40%)' }}></div>
|
||||||
|
|
||||||
{/* Ambient Glows */}
|
{/* Floating Orbs (Using Framer Motion) */}
|
||||||
<div className="fixed top-1/4 left-1/4 size-[400px] rounded-full bg-primary/20 blur-[120px] pointer-events-none mix-blend-screen -z-10" />
|
<motion.div
|
||||||
<div className="fixed bottom-1/4 right-1/4 size-[500px] rounded-full bg-[var(--cyan-ai-10)] blur-[150px] pointer-events-none mix-blend-screen -z-10" />
|
className="fixed top-1/4 left-1/4 size-[400px] rounded-full bg-primary/20 blur-[120px] pointer-events-none mix-blend-screen -z-10"
|
||||||
|
animate={{ x: [0, 50, -20, 0], y: [0, -30, 40, 0] }}
|
||||||
|
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="fixed bottom-1/4 right-1/4 size-[500px] rounded-full bg-[var(--cyan-ai-10)] blur-[150px] pointer-events-none mix-blend-screen -z-10"
|
||||||
|
animate={{ x: [0, -60, 30, 0], y: [0, 40, -50, 0] }}
|
||||||
|
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="fixed top-0 w-full z-50 bg-background/50 backdrop-blur-xl border-b border-border/50">
|
<nav className="fixed top-0 w-full z-50 bg-background/50 backdrop-blur-xl border-b border-border/50">
|
||||||
@@ -52,32 +93,56 @@ export default function MarketingPage() {
|
|||||||
<main className="relative z-10 pt-32 pb-24">
|
<main className="relative z-10 pt-32 pb-24">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="px-6 max-w-7xl mx-auto text-center space-y-8 pt-12 pb-8">
|
<section className="px-6 max-w-7xl mx-auto text-center space-y-8 pt-12 pb-8">
|
||||||
<div>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 px-4 py-1.5 rounded-full text-xs font-bold tracking-widest uppercase shadow-[0_0_15px_-5px_var(--color-primary)] mb-6">
|
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 px-4 py-1.5 rounded-full text-xs font-bold tracking-widest uppercase shadow-[0_0_15px_-5px_var(--color-primary)] mb-6">
|
||||||
The Next Evolution of Prep
|
The Next Evolution of Prep
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="text-6xl md:text-8xl font-bold tracking-tighter max-w-5xl mx-auto leading-[1.1]">
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.8, type: "spring" }}
|
||||||
|
className="text-6xl md:text-8xl font-bold tracking-tighter max-w-5xl mx-auto leading-[1.1]"
|
||||||
|
>
|
||||||
NEET/JEE Prep, <br className="hidden md:block" />
|
NEET/JEE Prep, <br className="hidden md:block" />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-[var(--cyan-ai)] to-primary animate-gradient pb-2 drop-shadow-[0_0_20px_rgba(209,255,77,0.3)]">Re-engineered by AI.</span>
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-[var(--cyan-ai)] to-primary animate-gradient pb-2 drop-shadow-[0_0_20px_rgba(209,255,77,0.3)]">Re-engineered by AI.</span>
|
||||||
</h1>
|
</motion.h1>
|
||||||
|
|
||||||
<p className="text-xl md:text-2xl text-muted-foreground max-w-2xl mx-auto leading-relaxed font-medium">
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
|
className="text-xl md:text-2xl text-muted-foreground max-w-2xl mx-auto leading-relaxed font-medium"
|
||||||
|
>
|
||||||
Experience the most accurate CBT Exam Engine combined with adaptive DPPs generated specifically for your weak areas in real-time.
|
Experience the most accurate CBT Exam Engine combined with adaptive DPPs generated specifically for your weak areas in real-time.
|
||||||
</p>
|
</motion.p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-8">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
|
className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-8"
|
||||||
|
>
|
||||||
<Link href="/login">
|
<Link href="/login">
|
||||||
<Button size="lg" className="group rounded-full h-14 px-8 text-base font-bold shadow-[0_0_40px_-10px_var(--color-primary)] hover:shadow-[0_0_60px_-10px_var(--color-primary)] transition-all w-full sm:w-auto relative overflow-hidden">
|
<Button size="lg" className="group rounded-full h-14 px-8 text-base font-bold shadow-[0_0_40px_-10px_var(--color-primary)] hover:shadow-[0_0_60px_-10px_var(--color-primary)] transition-all w-full sm:w-auto relative overflow-hidden">
|
||||||
<span className="relative z-10 flex items-center">Start Free Trial <ArrowRight className="ml-2 group-hover:translate-x-1 transition-transform" size={18} /></span>
|
<span className="relative z-10 flex items-center">Start Free Trial <ArrowRight className="ml-2 group-hover:translate-x-1 transition-transform" size={18} /></span>
|
||||||
<div className="absolute inset-0 -translate-x-full animate-shine bg-gradient-to-r from-transparent via-white/20 to-transparent z-0"></div>
|
<div className="absolute inset-0 -translate-x-full animate-shine bg-gradient-to-r from-transparent via-white/20 to-transparent z-0"></div>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Dashboard Preview Mockup */}
|
{/* Dashboard Preview Mockup */}
|
||||||
<div className="relative max-w-5xl mx-auto mt-20 rounded-[2rem] border border-border/60 bg-secondary/5 backdrop-blur-xl shadow-[0_0_50px_-15px_rgba(12,96,96,0.15)] overflow-hidden">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ duration: 1, delay: 0.6, type: "spring" }}
|
||||||
|
className="relative max-w-5xl mx-auto mt-20 rounded-[2rem] border border-border/60 bg-secondary/5 backdrop-blur-xl shadow-[0_0_50px_-15px_rgba(12,96,96,0.15)] overflow-hidden"
|
||||||
|
>
|
||||||
{/* Top Window Chrome Bar */}
|
{/* Top Window Chrome Bar */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-background/50">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-background/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -238,7 +303,7 @@ export default function MarketingPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Infinite Trust Bar (commented out)
|
{/* Infinite Trust Bar (commented out)
|
||||||
@@ -269,13 +334,25 @@ export default function MarketingPage() {
|
|||||||
|
|
||||||
{/* Animated DPP Engine Section */}
|
{/* Animated DPP Engine Section */}
|
||||||
<section id="features" className="py-32 px-6 max-w-7xl mx-auto">
|
<section id="features" className="py-32 px-6 max-w-7xl mx-auto">
|
||||||
<div className="text-center mb-20">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="text-center mb-20"
|
||||||
|
>
|
||||||
<h2 className="text-4xl md:text-6xl font-bold tracking-tighter mb-6">Adaptive Engine</h2>
|
<h2 className="text-4xl md:text-6xl font-bold tracking-tighter mb-6">Adaptive Engine</h2>
|
||||||
<p className="text-muted-foreground text-xl max-w-2xl mx-auto">Our AI analyzes every keystroke and hesitation to generate the exact problems you need to solve next.</p>
|
<p className="text-muted-foreground text-xl max-w-2xl mx-auto">Our AI analyzes every keystroke and hesitation to generate the exact problems you need to solve next.</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<motion.div
|
||||||
<div>
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
||||||
|
>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
<Card className="bg-secondary/10 backdrop-blur-xl border-border/50 hover:border-primary/50 transition-all duration-300 rounded-[2rem] h-full shadow-lg group">
|
<Card className="bg-secondary/10 backdrop-blur-xl border-border/50 hover:border-primary/50 transition-all duration-300 rounded-[2rem] h-full shadow-lg group">
|
||||||
<CardContent className="p-8 relative overflow-hidden">
|
<CardContent className="p-8 relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity transform group-hover:scale-110 group-hover:-rotate-12 duration-500">
|
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity transform group-hover:scale-110 group-hover:-rotate-12 duration-500">
|
||||||
@@ -290,9 +367,9 @@ export default function MarketingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div>
|
<motion.div variants={itemVariants}>
|
||||||
<Card className="bg-[var(--cyan-ai-10)] backdrop-blur-xl border-[var(--cyan-ai-20)] hover:border-[var(--cyan-ai-50)] transition-all duration-300 rounded-[2rem] h-full shadow-[0_0_30px_-15px_var(--cyan-ai)] group">
|
<Card className="bg-[var(--cyan-ai-10)] backdrop-blur-xl border-[var(--cyan-ai-20)] hover:border-[var(--cyan-ai-50)] transition-all duration-300 rounded-[2rem] h-full shadow-[0_0_30px_-15px_var(--cyan-ai)] group">
|
||||||
<CardContent className="p-8 relative overflow-hidden">
|
<CardContent className="p-8 relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity transform group-hover:scale-110 group-hover:rotate-12 duration-500">
|
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity transform group-hover:scale-110 group-hover:rotate-12 duration-500">
|
||||||
@@ -307,9 +384,9 @@ export default function MarketingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div>
|
<motion.div variants={itemVariants}>
|
||||||
<Card className="bg-secondary/10 backdrop-blur-xl border-border/50 hover:border-primary/50 transition-all duration-300 rounded-[2rem] h-full shadow-lg group">
|
<Card className="bg-secondary/10 backdrop-blur-xl border-border/50 hover:border-primary/50 transition-all duration-300 rounded-[2rem] h-full shadow-lg group">
|
||||||
<CardContent className="p-8 relative overflow-hidden">
|
<CardContent className="p-8 relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity transform group-hover:scale-110 group-hover:-rotate-12 duration-500">
|
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity transform group-hover:scale-110 group-hover:-rotate-12 duration-500">
|
||||||
@@ -324,22 +401,56 @@ export default function MarketingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Animated Pricing */}
|
||||||
<section id="pricing" className="py-24 px-6 max-w-7xl mx-auto relative">
|
<section id="pricing" className="py-24 px-6 max-w-7xl mx-auto relative">
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-[600px] bg-primary/5 blur-[120px] rounded-full pointer-events-none -z-10"></div>
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-[600px] bg-primary/5 blur-[120px] rounded-full pointer-events-none -z-10"></div>
|
||||||
|
|
||||||
<div className="text-center mb-20">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="text-center mb-20"
|
||||||
|
>
|
||||||
<h2 className="text-4xl md:text-6xl font-bold tracking-tighter mb-6">Simple, Transparent Pricing</h2>
|
<h2 className="text-4xl md:text-6xl font-bold tracking-tighter mb-6">Simple, Transparent Pricing</h2>
|
||||||
<p className="text-muted-foreground text-xl max-w-2xl mx-auto">Unlock the ultimate unfair advantage for your NEET/JEE preparation.</p>
|
<p className="text-muted-foreground text-xl max-w-2xl mx-auto">Unlock the ultimate unfair advantage for your NEET/JEE preparation.</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-4xl mx-auto items-stretch">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-4xl mx-auto items-stretch">
|
||||||
|
{/* Free Plan
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -50 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, type: "spring" }}
|
||||||
|
>
|
||||||
|
<Card className="bg-background/80 backdrop-blur-md border-border p-6 rounded-[2rem] flex flex-col hover:border-border/80 transition-colors h-full z-10 relative shadow-xl">
|
||||||
|
<h3 className="text-3xl font-bold mb-2">Free</h3>
|
||||||
|
<p className="text-muted-foreground mb-6 text-base">Perfect to get started.</p>
|
||||||
|
<div className="text-6xl font-bold tracking-tighter mb-6">₹0<span className="text-xl text-white/60 font-medium tracking-normal ml-1">/year</span></div>
|
||||||
|
<ul className="space-y-3 mb-6 flex-1 font-medium text-lg">
|
||||||
|
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-white" /> 1 Daily Practice Sheet</li>
|
||||||
|
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-white" /> Basic Performance Analytics</li>
|
||||||
|
<li className="flex items-center gap-4"><CheckCircle2 size={24} className="text-white" /> Community Access</li>
|
||||||
|
</ul>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="outline" size="lg" className="w-full rounded-2xl h-12 border-white text-white font-bold text-lg hover:bg-secondary hover:text-white">Login</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* Pro Plan */}
|
{/* Pro Plan */}
|
||||||
<div>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, type: "spring", delay: 0.1 }}
|
||||||
|
>
|
||||||
<Card className="bg-background/80 backdrop-blur-md border-border p-6 rounded-[2rem] 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-6 rounded-[2rem] flex flex-col hover:border-border/80 transition-colors h-full z-10 relative shadow-xl">
|
||||||
<h3 className="text-3xl font-bold mb-2 text-yellow-500">Pro</h3>
|
<h3 className="text-3xl font-bold mb-2 text-yellow-500">Pro</h3>
|
||||||
<p className="text-muted-foreground mb-6 text-base">Perfect for self-paced practice.</p>
|
<p className="text-muted-foreground mb-6 text-base">Perfect for self-paced practice.</p>
|
||||||
@@ -354,10 +465,16 @@ export default function MarketingPage() {
|
|||||||
<Button size="lg" className="w-full rounded-2xl h-12 bg-yellow-500 text-black font-bold text-lg hover:bg-yellow-400">Get Pro</Button>
|
<Button size="lg" className="w-full rounded-2xl h-12 bg-yellow-500 text-black font-bold text-lg hover:bg-yellow-400">Get Pro</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* AI Coach Plan */}
|
{/* AI Coach Plan */}
|
||||||
<div className="relative mt-8 lg:mt-0">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, type: "spring", delay: 0.2 }}
|
||||||
|
className="relative mt-8 lg:mt-0"
|
||||||
|
>
|
||||||
<Card className="bg-background/80 backdrop-blur-md border-border p-6 rounded-[2rem] 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-6 rounded-[2rem] flex flex-col hover:border-border/80 transition-colors h-full z-10 relative shadow-xl">
|
||||||
<h3 className="text-3xl font-bold mb-2 text-[var(--cyan-ai)]">AI Coach</h3>
|
<h3 className="text-3xl font-bold mb-2 text-[var(--cyan-ai)]">AI Coach</h3>
|
||||||
<p className="text-muted-foreground mb-6 text-base">The complete AI-driven system.</p>
|
<p className="text-muted-foreground mb-6 text-base">The complete AI-driven system.</p>
|
||||||
@@ -372,7 +489,7 @@ export default function MarketingPage() {
|
|||||||
<Button size="lg" className="w-full rounded-2xl h-12 font-bold text-lg bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] transition-all">Get AI Coach</Button>
|
<Button size="lg" className="w-full rounded-2xl h-12 font-bold text-lg bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] transition-all">Get AI Coach</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center mt-8 text-sm text-muted-foreground font-medium w-full">
|
<p className="text-center mt-8 text-sm text-muted-foreground font-medium w-full">
|
||||||
* All above prices are inclusive of GST.
|
* All above prices are inclusive of GST.
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function SignupPage() {
|
|||||||
<ArrowLeft size={16} /> Back to Home
|
<ArrowLeft size={16} /> Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-8 duration-700">
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ export default function SignupPage() {
|
|||||||
<CardContent className="space-y-6 pt-4">
|
<CardContent className="space-y-6 pt-4">
|
||||||
|
|
||||||
{step === 1 ? (
|
{step === 1 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 animate-in fade-in zoom-in-95">
|
||||||
<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 text-foreground">First Name</label>
|
<label className="text-sm font-bold text-foreground">First Name</label>
|
||||||
@@ -185,7 +185,7 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
{/* OTP verification section temporarily disabled
|
{/* OTP verification section temporarily disabled
|
||||||
{showOTP && (
|
{showOTP && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 animate-in slide-in-from-top-2">
|
||||||
<label className="text-sm font-bold text-foreground flex justify-between">
|
<label className="text-sm font-bold text-foreground flex justify-between">
|
||||||
<span>Verification Code</span>
|
<span>Verification Code</span>
|
||||||
<span className="text-primary text-xs">Resend in 30s</span>
|
<span className="text-primary text-xs">Resend in 30s</span>
|
||||||
@@ -224,7 +224,7 @@ export default function SignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 animate-in fade-in slide-in-from-right-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-foreground">Target Exam</label>
|
<label className="text-sm font-bold text-foreground">Target Exam</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
|
||||||
@@ -21,7 +21,7 @@ export default function UpgradePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-7xl mx-auto min-h-screen">
|
<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">
|
<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>
|
<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>
|
<p className="text-xl text-muted-foreground">Unlock the full power of PragatiVeer to master JEE & NEET.</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -32,10 +32,14 @@ const NEW_USER_PLAN: PlanItem[] = [
|
|||||||
export function AdaptiveStudyPlan() {
|
export function AdaptiveStudyPlan() {
|
||||||
const { user } = useActiveUser();
|
const { user } = useActiveUser();
|
||||||
const isDemo = isDemoUser(user?.id);
|
const isDemo = isDemoUser(user?.id);
|
||||||
const [items, setItems] = useState<PlanItem[]>(() => isDemo ? DEMO_PLAN : NEW_USER_PLAN);
|
const [items, setItems] = useState<PlanItem[]>(DEMO_PLAN);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setItems(isDemo ? DEMO_PLAN : NEW_USER_PLAN);
|
if (isDemo) {
|
||||||
|
setItems(DEMO_PLAN);
|
||||||
|
} else {
|
||||||
|
setItems(NEW_USER_PLAN);
|
||||||
|
}
|
||||||
}, [isDemo]);
|
}, [isDemo]);
|
||||||
|
|
||||||
const updateStatus = (id: string, newStatus: PlanState) => {
|
const updateStatus = (id: string, newStatus: PlanState) => {
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const ZERO_HEATMAP_DATA = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function MistakeHeatmap() {
|
export function MistakeHeatmap() {
|
||||||
const { userId, user } = useActiveUser();
|
const { userId } = useActiveUser();
|
||||||
const isDemo = isDemoUser(user?.id || userId);
|
const isDemo = isDemoUser(userId);
|
||||||
|
|
||||||
const getColor = (errorRate: number) => {
|
const getColor = (errorRate: number) => {
|
||||||
if (errorRate === 0) return 'bg-green-600/50 border-green-600/80 text-green-100';
|
if (errorRate === 0) return 'bg-green-600/50 border-green-600/80 text-green-100';
|
||||||
@@ -71,7 +71,7 @@ export function MistakeHeatmap() {
|
|||||||
{row.attempts.map((errRate, i) => (
|
{row.attempts.map((errRate, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-8 sm:h-9 rounded-sm sm:rounded-md border flex items-center justify-center cursor-help ${getColor(errRate)}`}
|
className={`h-8 sm:h-9 rounded-sm sm:rounded-md border transition-all duration-300 group-hover:scale-105 flex items-center justify-center cursor-help ${getColor(errRate)}`}
|
||||||
title={`Error Rate: ${errRate}%`}
|
title={`Error Rate: ${errRate}%`}
|
||||||
>
|
>
|
||||||
<span className="text-[8px] sm:text-xs font-black text-foreground/90">{errRate}%</span>
|
<span className="text-[8px] sm:text-xs font-black text-foreground/90">{errRate}%</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { fetchWithAuth } from "@/lib/apiClient";
|
import { fetchWithAuth } from "@/lib/apiClient";
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
@@ -103,6 +103,7 @@ export function ReadinessGauge() {
|
|||||||
strokeDasharray={circumference}
|
strokeDasharray={circumference}
|
||||||
strokeDashoffset={isZero ? circumference : strokeDashoffset}
|
strokeDashoffset={isZero ? circumference : strokeDashoffset}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
className="transition-all duration-1500 ease-out"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center mt-1">
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-center mt-1">
|
||||||
@@ -155,7 +156,7 @@ export function ReadinessGauge() {
|
|||||||
<span className="text-xs font-bold text-muted-foreground w-24 shrink-0 truncate">{sub.name}</span>
|
<span className="text-xs font-bold text-muted-foreground w-24 shrink-0 truncate">{sub.name}</span>
|
||||||
<div className="flex-1 h-2 rounded-full bg-white/10 overflow-hidden">
|
<div className="flex-1 h-2 rounded-full bg-white/10 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full"
|
className="h-full rounded-full transition-all duration-1000"
|
||||||
style={{ width: `${sub.val}%`, background: sub.color }}
|
style={{ width: `${sub.val}%`, background: sub.color }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function ReferralCard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-gradient-to-r from-primary to-teal-400 rounded-full"
|
className="h-full bg-gradient-to-r from-primary to-teal-400 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${Math.min((referralCount / nextTier) * 100, 100)}%` }}
|
style={{ width: `${Math.min((referralCount / nextTier) * 100, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function StudyPlanCard() {
|
|||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="h-[3px] bg-[var(--cyan-ai-10)] w-full">
|
<div className="h-[3px] bg-[var(--cyan-ai-10)] w-full">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-gradient-to-r from-[var(--cyan-ai)] to-teal-400 rounded-r-[2px]"
|
className="h-full bg-gradient-to-r from-[var(--cyan-ai)] to-teal-400 rounded-r-[2px] transition-all duration-500"
|
||||||
style={{ width: `${progressPercent}%` }}
|
style={{ width: `${progressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Flame, Star, Clock, TrendingUp, Sparkles } from "lucide-react"
|
import { Flame, Star, Clock, TrendingUp, Sparkles } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
@@ -92,7 +92,7 @@ export function StudyTimeOptimizationCard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`w-full rounded-t-md ${
|
className={`w-full rounded-t-md transition-all duration-300 ${
|
||||||
slot.peak
|
slot.peak
|
||||||
? "bg-gradient-to-t from-yellow-600/80 to-yellow-400 shadow-[0_0_14px_rgba(234,179,8,0.5)]"
|
? "bg-gradient-to-t from-yellow-600/80 to-yellow-400 shadow-[0_0_14px_rgba(234,179,8,0.5)]"
|
||||||
: hovered === i
|
: hovered === i
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { TrendingUp, TrendingDown, Brain, Zap, Target, Clock } from "lucide-react"
|
import { TrendingUp, TrendingDown, Brain, Zap, Target, Clock } from "lucide-react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -97,7 +97,7 @@ export function WeeklyProgressReport() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`w-full rounded-t-md ${
|
className={`w-full rounded-t-md transition-all duration-700 ${
|
||||||
!isDemo
|
!isDemo
|
||||||
? "bg-white/5 border border-white/10"
|
? "bg-white/5 border border-white/10"
|
||||||
: i === 6
|
: i === 6
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export function Calculator({ isOpen, onClose }: CalculatorProps) {
|
|||||||
const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1));
|
const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<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">
|
<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">
|
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
|
||||||
<h3 className="font-bold">Scientific Calculator</h3>
|
<h3 className="font-bold">Scientific Calculator</h3>
|
||||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function ExamResult({ payload, attemptId, examType, onRetake }: ExamResul
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col items-center py-12 px-4 sm:px-6">
|
<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="max-w-4xl w-full space-y-8">
|
||||||
|
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md">
|
<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 flex flex-col w-[95vw] max-w-[1400px] h-[95vh]">
|
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col w-[95vw] max-w-[1400px] h-[95vh]">
|
||||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/80">
|
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/80">
|
||||||
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
|
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ export function Scratchpad({ isOpen, onClose }: ScratchpadProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-8">
|
<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 flex flex-col w-full max-w-6xl h-full max-h-[800px]">
|
<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">
|
<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>
|
<h3 className="font-bold flex items-center gap-2"><Pen size={18}/> Split Workspace: Draw & Type</h3>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -1,317 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from "react"
|
|
||||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X, Sparkles, Check } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export interface CalendarFilterProps {
|
|
||||||
selectedDate: string // Format: "YYYY-MM-DD" or ""
|
|
||||||
onSelectDate: (date: string) => void
|
|
||||||
availableDates?: string[] // Optional array of "YYYY-MM-DD"
|
|
||||||
placeholder?: string
|
|
||||||
className?: string
|
|
||||||
align?: "left" | "right"
|
|
||||||
}
|
|
||||||
|
|
||||||
import { getISTDateString } from "@/lib/dateUtils"
|
|
||||||
|
|
||||||
/** Helper to format any date safely in IST (Asia/Kolkata) as "YYYY-MM-DD" */
|
|
||||||
export function getLocalDateString(dateInput: string | Date | null | undefined): string {
|
|
||||||
return getISTDateString(dateInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarFilter({
|
|
||||||
selectedDate,
|
|
||||||
onSelectDate,
|
|
||||||
availableDates = [],
|
|
||||||
placeholder = "Filter by Date",
|
|
||||||
className = "",
|
|
||||||
align = "left",
|
|
||||||
}: CalendarFilterProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Current view year & month for calendar navigation
|
|
||||||
const today = new Date()
|
|
||||||
const [viewYear, setViewYear] = useState(() => {
|
|
||||||
if (selectedDate) {
|
|
||||||
const parts = selectedDate.split("-").map(Number)
|
|
||||||
if (parts[0]) return parts[0]
|
|
||||||
}
|
|
||||||
return today.getFullYear()
|
|
||||||
})
|
|
||||||
|
|
||||||
const [viewMonth, setViewMonth] = useState(() => {
|
|
||||||
if (selectedDate) {
|
|
||||||
const parts = selectedDate.split("-").map(Number)
|
|
||||||
if (parts[1]) return parts[1] - 1
|
|
||||||
}
|
|
||||||
return today.getMonth()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync view when selectedDate changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedDate) {
|
|
||||||
const parts = selectedDate.split("-").map(Number)
|
|
||||||
if (parts[0] && parts[1]) {
|
|
||||||
setViewYear(parts[0])
|
|
||||||
setViewMonth(parts[1] - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedDate])
|
|
||||||
|
|
||||||
// Click outside listener
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const prevMonth = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (viewMonth === 0) {
|
|
||||||
setViewMonth(11)
|
|
||||||
setViewYear((y) => y - 1)
|
|
||||||
} else {
|
|
||||||
setViewMonth((m) => m - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextMonth = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (viewMonth === 11) {
|
|
||||||
setViewMonth(0)
|
|
||||||
setViewYear((y) => y + 1)
|
|
||||||
} else {
|
|
||||||
setViewMonth((m) => m + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectDay = (day: number) => {
|
|
||||||
const formatted = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
|
||||||
if (selectedDate === formatted) {
|
|
||||||
onSelectDate("")
|
|
||||||
} else {
|
|
||||||
onSelectDate(formatted)
|
|
||||||
}
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setQuickDate = (dateStr: string) => {
|
|
||||||
onSelectDate(dateStr)
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Days calculations
|
|
||||||
const firstDayOfWeek = new Date(viewYear, viewMonth, 1).getDay() // 0 = Sun
|
|
||||||
const totalDaysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
|
|
||||||
const monthNames = [
|
|
||||||
"January", "February", "March", "April", "May", "June",
|
|
||||||
"July", "August", "September", "October", "November", "December"
|
|
||||||
]
|
|
||||||
|
|
||||||
const todayStr = getLocalDateString(today)
|
|
||||||
const tomorrowStr = getLocalDateString(new Date(Date.now() + 86400000))
|
|
||||||
|
|
||||||
const formatDisplay = (dateStr: string) => {
|
|
||||||
if (!dateStr) return placeholder
|
|
||||||
const parts = dateStr.split("-").map(Number)
|
|
||||||
if (parts.length === 3) {
|
|
||||||
const d = new Date(parts[0], parts[1] - 1, parts[2])
|
|
||||||
if (dateStr === todayStr) {
|
|
||||||
return `Today (${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })})`
|
|
||||||
}
|
|
||||||
if (dateStr === tomorrowStr) {
|
|
||||||
return `Tomorrow (${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })})`
|
|
||||||
}
|
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
||||||
}
|
|
||||||
return dateStr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available dates set for quick lookup
|
|
||||||
const availableSet = new Set(availableDates.map((d) => d.split("T")[0]))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative inline-block ${className}`} ref={containerRef}>
|
|
||||||
{/* Trigger Button */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all border shadow-sm ${
|
|
||||||
selectedDate
|
|
||||||
? "bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] border-[var(--cyan-ai-30)] shadow-[0_0_12px_rgba(12,96,96,0.15)]"
|
|
||||||
: "bg-background text-muted-foreground border-border hover:border-primary/40 hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="p-1 rounded-md bg-secondary/80 text-[var(--cyan-ai)]">
|
|
||||||
<CalendarIcon size={14} />
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold">{formatDisplay(selectedDate)}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{selectedDate && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onSelectDate("")
|
|
||||||
}}
|
|
||||||
title="Clear date filter"
|
|
||||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Popover Calendar */}
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className={`absolute ${
|
|
||||||
align === "right" ? "right-0" : "left-0"
|
|
||||||
} top-full mt-2 w-72 sm:w-80 p-4 bg-card border border-border/80 rounded-2xl shadow-2xl shadow-black/30 z-50 origin-top backdrop-blur-xl`}
|
|
||||||
>
|
|
||||||
{/* Quick presets */}
|
|
||||||
<div className="flex items-center gap-1 mb-3.5 pb-3 border-b border-border/60 overflow-x-auto custom-scrollbar">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuickDate("")}
|
|
||||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-bold shrink-0 transition-colors ${
|
|
||||||
!selectedDate
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-secondary/60 text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All Dates
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuickDate(todayStr)}
|
|
||||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-bold shrink-0 transition-colors ${
|
|
||||||
selectedDate === todayStr
|
|
||||||
? "bg-[var(--cyan-ai)] text-black"
|
|
||||||
: "bg-secondary/60 text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuickDate(tomorrowStr)}
|
|
||||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-bold shrink-0 transition-colors ${
|
|
||||||
selectedDate === tomorrowStr
|
|
||||||
? "bg-[var(--cyan-ai)] text-black"
|
|
||||||
: "bg-secondary/60 text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Tomorrow
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Month / Year Navigation */}
|
|
||||||
<div className="flex items-center justify-between mb-3 px-1">
|
|
||||||
<h4 className="font-extrabold text-sm text-foreground">
|
|
||||||
{monthNames[viewMonth]} {viewYear}
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={prevMonth}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Previous Month"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={nextMonth}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Next Month"
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Days of Week Header */}
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center mb-1">
|
|
||||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
|
||||||
<span key={d} className="text-[10px] font-black uppercase text-muted-foreground/70 py-1">
|
|
||||||
{d}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Days Grid */}
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
|
||||||
{Array.from({ length: firstDayOfWeek }).map((_, i) => (
|
|
||||||
<div key={`empty-${i}`} className="h-8 w-8" />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{Array.from({ length: totalDaysInMonth }).map((_, i) => {
|
|
||||||
const day = i + 1
|
|
||||||
const currentDayStr = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
|
||||||
const isSelected = selectedDate === currentDayStr
|
|
||||||
const isToday = currentDayStr === todayStr
|
|
||||||
const hasItems = availableSet.has(currentDayStr)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectDay(day)}
|
|
||||||
className={`h-8 w-8 mx-auto rounded-lg text-xs font-bold relative flex flex-col items-center justify-center transition-all ${
|
|
||||||
isSelected
|
|
||||||
? "bg-[var(--cyan-ai)] text-black font-black shadow-md scale-105"
|
|
||||||
: isToday
|
|
||||||
? "border border-[var(--cyan-ai)] text-[var(--cyan-ai)] hover:bg-secondary"
|
|
||||||
: "text-foreground hover:bg-secondary/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>{day}</span>
|
|
||||||
{hasItems && (
|
|
||||||
<span
|
|
||||||
className={`absolute bottom-0.5 w-1 h-1 rounded-full ${
|
|
||||||
isSelected ? "bg-black" : "bg-[var(--cyan-ai)] shadow-[0_0_4px_var(--cyan-ai)]"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer stats if availableDates exists */}
|
|
||||||
{availableDates.length > 0 && (
|
|
||||||
<div className="mt-3.5 pt-2.5 border-t border-border/50 flex items-center justify-between text-[11px] text-muted-foreground px-1">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--cyan-ai)] inline-block"></span>
|
|
||||||
Scheduled Tests Available
|
|
||||||
</span>
|
|
||||||
{selectedDate && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSelectDate("")
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
className="font-bold text-[var(--cyan-ai)] hover:underline"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -129,12 +129,12 @@ export function StreakOnboardingModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-md"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-md animate-in fade-in duration-300"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
>
|
>
|
||||||
{/* Centered Modal Card */}
|
{/* Centered Modal Card */}
|
||||||
<div
|
<div
|
||||||
className="bg-background border border-border shadow-[0_0_60px_-10px_rgba(12,96,96,0.25)] rounded-3xl w-[90vw] max-w-5xl h-[85vh] max-h-[800px] relative overflow-hidden z-20 pointer-events-auto mx-4 flex flex-col justify-start"
|
className="bg-background border border-border shadow-[0_0_60px_-10px_rgba(12,96,96,0.25)] rounded-3xl w-[90vw] max-w-5xl h-[85vh] max-h-[800px] relative overflow-hidden animate-in zoom-in-95 slide-in-from-bottom-4 duration-300 z-20 pointer-events-auto mx-4 flex flex-col justify-start"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Wrench } from "lucide-react";
|
import { Wrench } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
export default function MaintenanceScreen() {
|
export default function MaintenanceScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||||
<div className="max-w-md w-full bg-secondary/20 border border-border rounded-2xl p-8 text-center space-y-6">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="max-w-md w-full bg-secondary/20 border border-border rounded-2xl p-8 text-center space-y-6"
|
||||||
|
>
|
||||||
<div className="mx-auto w-16 h-16 bg-yellow-500/10 text-yellow-500 rounded-full flex items-center justify-center mb-6">
|
<div className="mx-auto w-16 h-16 bg-yellow-500/10 text-yellow-500 rounded-full flex items-center justify-center mb-6">
|
||||||
<Wrench size={32} />
|
<Wrench size={32} />
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +26,7 @@ export default function MaintenanceScreen() {
|
|||||||
We'll be back shortly. Thank you for your patience!
|
We'll be back shortly. Thank you for your patience!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ interface PremiumGateProps {
|
|||||||
export function PremiumGate({ featureKey, children }: PremiumGateProps) {
|
export function PremiumGate({ featureKey, children }: PremiumGateProps) {
|
||||||
const { hasAccessTo, loading, user } = useActiveUser();
|
const { hasAccessTo, loading, user } = useActiveUser();
|
||||||
|
|
||||||
// Allow children to render immediately without a blocking center spinner
|
|
||||||
// so that pages mount instantly on route transition.
|
|
||||||
if (loading || !user) {
|
if (loading || !user) {
|
||||||
return <>{children}</>;
|
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)) {
|
if (!hasAccessTo(featureKey)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Flame } from "lucide-react"
|
import { Flame } from "lucide-react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
interface StreakCounterProps {
|
interface StreakCounterProps {
|
||||||
streak: number
|
streak: number
|
||||||
@@ -8,11 +9,15 @@ interface StreakCounterProps {
|
|||||||
|
|
||||||
export function StreakCounter({ streak }: StreakCounterProps) {
|
export function StreakCounter({ streak }: StreakCounterProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.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 hover:scale-105 transition-transform"
|
initial={{ scale: 0.5, opacity: 0, y: -10 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 15 }}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 border border-orange-500/20 text-orange-500 font-bold shadow-[0_0_10px_-2px_rgba(249,115,22,0.5)] cursor-default"
|
||||||
>
|
>
|
||||||
<Flame size={18} className="animate-pulse" />
|
<Flame size={18} className="animate-pulse" />
|
||||||
<span>{streak}</span>
|
<span>{streak}</span>
|
||||||
</div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-all duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
|
"bg-primary text-primary-foreground hover:bg-primary/90 hover:scale-[1.02] active:scale-[0.98]": variant === "default",
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
|
||||||
"border border-border bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
|
"border border-border bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
|
||||||
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
|
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-2xl border border-border bg-background text-foreground",
|
"rounded-2xl border border-border bg-background text-foreground transition-all duration-300",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -28,30 +28,17 @@ export const DEMO_USERS = {
|
|||||||
AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "AI Coach - JEE" },
|
AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "AI Coach - JEE" },
|
||||||
AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "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: "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: "Free - JEE (New/Raw)" },
|
FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Free - JEE" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Showcase accounts that have pre-filled mock activity data for demonstration
|
// IDs considered "demo" accounts — demo switcher only shows for these
|
||||||
export const SHOWCASE_DEMO_IDS = new Set([
|
|
||||||
DEMO_USERS.AICOACH_JEE.id,
|
|
||||||
DEMO_USERS.AICOACH_NEET.id,
|
|
||||||
DEMO_USERS.AICOACH_JEE_NEET.id,
|
|
||||||
"usr_01", "usr_02", "usr_03"
|
|
||||||
]);
|
|
||||||
|
|
||||||
// IDs allowed in the demo switcher bar
|
|
||||||
export const DEMO_IDS = new Set([
|
export const DEMO_IDS = new Set([
|
||||||
...Object.values(DEMO_USERS).map(u => u.id),
|
...Object.values(DEMO_USERS).map(u => u.id),
|
||||||
"usr_01", "usr_02", "usr_03", "usr_04", "usr_05"
|
"usr_01", "usr_02", "usr_03", "usr_04", "usr_05"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Returns true if the userId is a showcase account with pre-filled mock activity data */
|
/** Returns true if the userId matches a known demo account */
|
||||||
export function isDemoUser(userId?: string | null): boolean {
|
export function isDemoUser(userId?: string | null): boolean {
|
||||||
return !!userId && SHOWCASE_DEMO_IDS.has(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true if the userId is eligible for demo switcher dropdown */
|
|
||||||
export function isDemoAccount(userId?: string | null): boolean {
|
|
||||||
return !!userId && DEMO_IDS.has(userId);
|
return !!userId && DEMO_IDS.has(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,88 +134,6 @@ async function fetchSettings(): Promise<any> {
|
|||||||
return cachedSettings ?? null;
|
return cachedSettings ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedUserProfile: UserProfile | null = null;
|
|
||||||
|
|
||||||
export function getKnownDemoProfile(id: string): UserProfile | null {
|
|
||||||
if (id === DEMO_USERS.AICOACH_JEE.id) {
|
|
||||||
return {
|
|
||||||
id: DEMO_USERS.AICOACH_JEE.id,
|
|
||||||
name: "Aarav",
|
|
||||||
email: "aarav.jee@pragativeer.com",
|
|
||||||
phone: "9999999991",
|
|
||||||
class: "12th",
|
|
||||||
target_exam: "JEE_Main",
|
|
||||||
medium: "English",
|
|
||||||
school: "Delhi Public School",
|
|
||||||
city: "New Delhi",
|
|
||||||
state: "Delhi",
|
|
||||||
tier: "AI Coach",
|
|
||||||
streak_count: 14,
|
|
||||||
longest_streak: 21,
|
|
||||||
created_at: new Date(Date.now() - 30 * 86400000).toISOString(),
|
|
||||||
trial_end: new Date(Date.now() + 60 * 86400000).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (id === DEMO_USERS.AICOACH_NEET.id) {
|
|
||||||
return {
|
|
||||||
id: DEMO_USERS.AICOACH_NEET.id,
|
|
||||||
name: "Neha Verma",
|
|
||||||
email: "neha.neet@pragativeer.com",
|
|
||||||
phone: "9999999992",
|
|
||||||
class: "12th",
|
|
||||||
target_exam: "NEET",
|
|
||||||
medium: "English",
|
|
||||||
school: "Kendriya Vidyalaya",
|
|
||||||
city: "Kota",
|
|
||||||
state: "Rajasthan",
|
|
||||||
tier: "AI Coach",
|
|
||||||
streak_count: 18,
|
|
||||||
longest_streak: 25,
|
|
||||||
created_at: new Date(Date.now() - 30 * 86400000).toISOString(),
|
|
||||||
trial_end: new Date(Date.now() + 60 * 86400000).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (id === DEMO_USERS.AICOACH_JEE_NEET.id) {
|
|
||||||
return {
|
|
||||||
id: DEMO_USERS.AICOACH_JEE_NEET.id,
|
|
||||||
name: "Rahul Verma",
|
|
||||||
email: "rahul.jeeneet@pragativeer.com",
|
|
||||||
phone: "9999999993",
|
|
||||||
class: "12th",
|
|
||||||
target_exam: "JEE_NEET",
|
|
||||||
medium: "English",
|
|
||||||
school: "Modern School",
|
|
||||||
city: "Mumbai",
|
|
||||||
state: "Maharashtra",
|
|
||||||
tier: "AI Coach",
|
|
||||||
streak_count: 12,
|
|
||||||
longest_streak: 15,
|
|
||||||
created_at: new Date(Date.now() - 30 * 86400000).toISOString(),
|
|
||||||
trial_end: new Date(Date.now() + 60 * 86400000).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (id === DEMO_USERS.FREE_JEE.id) {
|
|
||||||
return {
|
|
||||||
id: DEMO_USERS.FREE_JEE.id,
|
|
||||||
name: "New Student",
|
|
||||||
email: "new.student@pragativeer.com",
|
|
||||||
phone: "9999999994",
|
|
||||||
class: "11th",
|
|
||||||
target_exam: "JEE_Main",
|
|
||||||
medium: "English",
|
|
||||||
school: "Army Public School",
|
|
||||||
city: "Jaipur",
|
|
||||||
state: "Rajasthan",
|
|
||||||
tier: "Free",
|
|
||||||
streak_count: 0,
|
|
||||||
longest_streak: 0,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
trial_end: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useActiveUser() {
|
export function useActiveUser() {
|
||||||
const [userId, setUserIdState] = useState<string>(() => {
|
const [userId, setUserIdState] = useState<string>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -237,32 +142,9 @@ export function useActiveUser() {
|
|||||||
return DEFAULT_USER_ID;
|
return DEFAULT_USER_ID;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [user, setUser] = useState<UserProfile | null>(() => {
|
const [user, setUser] = useState<UserProfile | null>(null);
|
||||||
const currentId = (typeof window !== "undefined" ? localStorage.getItem("activeUserId") : null) || DEFAULT_USER_ID;
|
const [globalSettings, setGlobalSettings] = useState<any>(null);
|
||||||
if (cachedUserProfile && cachedUserProfile.id === currentId) {
|
const [loading, setLoading] = useState(true);
|
||||||
return cachedUserProfile;
|
|
||||||
}
|
|
||||||
const demo = getKnownDemoProfile(currentId);
|
|
||||||
if (demo) {
|
|
||||||
cachedUserProfile = demo;
|
|
||||||
return demo;
|
|
||||||
}
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem("pragativeer_cached_user");
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored);
|
|
||||||
if (parsed && parsed.id === currentId) {
|
|
||||||
cachedUserProfile = parsed;
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
const [globalSettings, setGlobalSettings] = useState<any>(() => cachedSettings);
|
|
||||||
const [loading, setLoading] = useState(() => !cachedUserProfile);
|
|
||||||
const [switching, setSwitching] = useState(false);
|
const [switching, setSwitching] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Use a ref for fail count so it doesn't cause re-renders
|
// Use a ref for fail count so it doesn't cause re-renders
|
||||||
@@ -310,35 +192,13 @@ export function useActiveUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedUserProfile = data;
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try { localStorage.setItem("pragativeer_cached_user", JSON.stringify(data)); } catch {}
|
|
||||||
}
|
|
||||||
setUser(data);
|
setUser(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
failCountRef.current += 1;
|
failCountRef.current += 1;
|
||||||
if (!isPoll) {
|
if (!isPoll) {
|
||||||
console.warn("Backend unreachable, using local demo profile for offline testing:", err.message);
|
console.warn("Backend unreachable, will retry:", err.message);
|
||||||
|
setError("Backend unreachable — retrying...");
|
||||||
}
|
}
|
||||||
const fallback = getKnownDemoProfile(id) || {
|
|
||||||
id: id || DEFAULT_USER_ID,
|
|
||||||
name: "Aarav",
|
|
||||||
email: "demo@pragativeer.com",
|
|
||||||
phone: "9999999991",
|
|
||||||
class: "12th",
|
|
||||||
target_exam: "JEE_Main",
|
|
||||||
medium: "English",
|
|
||||||
school: "Delhi Public School",
|
|
||||||
city: "New Delhi",
|
|
||||||
state: "Delhi",
|
|
||||||
tier: "AI Coach",
|
|
||||||
streak_count: 14,
|
|
||||||
longest_streak: 21,
|
|
||||||
created_at: new Date(Date.now() - 5 * 86400000).toISOString(),
|
|
||||||
trial_end: new Date(Date.now() + 10 * 86400000).toISOString(),
|
|
||||||
};
|
|
||||||
if (!cachedUserProfile) cachedUserProfile = fallback;
|
|
||||||
setUser((prev) => prev || fallback);
|
|
||||||
// Silently swallow polling errors so the UI doesn't crash
|
// Silently swallow polling errors so the UI doesn't crash
|
||||||
} finally {
|
} finally {
|
||||||
if (!isPoll) setLoading(false);
|
if (!isPoll) setLoading(false);
|
||||||
@@ -361,23 +221,7 @@ export function useActiveUser() {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const handleUpdate = () => fetchUserProfile(activeUserIdRef.current);
|
const handleUpdate = () => fetchUserProfile(activeUserIdRef.current);
|
||||||
const handleSwitched = (e: any) => {
|
|
||||||
const switchedId = e.detail?.userId;
|
|
||||||
const switchedUser = e.detail?.user;
|
|
||||||
if (switchedId) {
|
|
||||||
activeUserIdRef.current = switchedId;
|
|
||||||
setUserIdState(switchedId);
|
|
||||||
if (switchedUser) {
|
|
||||||
cachedUserProfile = switchedUser;
|
|
||||||
setUser(switchedUser);
|
|
||||||
} else {
|
|
||||||
fetchUserProfile(switchedId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("pragativeer-user-updated", handleUpdate);
|
window.addEventListener("pragativeer-user-updated", handleUpdate);
|
||||||
window.addEventListener("pragativeer-user-switched", handleSwitched);
|
|
||||||
|
|
||||||
// Poll every 30s (less aggressive, kinder on backend)
|
// Poll every 30s (less aggressive, kinder on backend)
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -386,7 +230,6 @@ export function useActiveUser() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pragativeer-user-updated", handleUpdate);
|
window.removeEventListener("pragativeer-user-updated", handleUpdate);
|
||||||
window.removeEventListener("pragativeer-user-switched", handleSwitched);
|
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -417,13 +260,11 @@ export function useActiveUser() {
|
|||||||
// Update refs and state before awaiting network
|
// Update refs and state before awaiting network
|
||||||
activeUserIdRef.current = actualUserId;
|
activeUserIdRef.current = actualUserId;
|
||||||
setUserIdState(actualUserId);
|
setUserIdState(actualUserId);
|
||||||
const demo = getKnownDemoProfile(actualUserId);
|
setUser(null); // Clear old user immediately so stale data isn't shown
|
||||||
cachedUserProfile = demo;
|
|
||||||
setUser(demo);
|
|
||||||
|
|
||||||
// Notify listeners immediately of account switch
|
// Notify listeners immediately of account switch
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: actualUserId, user: demo } }));
|
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: actualUserId } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the new user's profile immediately using the new token
|
// Fetch the new user's profile immediately using the new token
|
||||||
@@ -431,10 +272,6 @@ export function useActiveUser() {
|
|||||||
|
|
||||||
if (newUserRes && newUserRes.ok) {
|
if (newUserRes && newUserRes.ok) {
|
||||||
const data = await newUserRes.json();
|
const data = await newUserRes.json();
|
||||||
cachedUserProfile = data;
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try { localStorage.setItem("pragativeer_cached_user", JSON.stringify(data)); } catch {}
|
|
||||||
}
|
|
||||||
setUser(data);
|
setUser(data);
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: actualUserId, user: data } }));
|
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: actualUserId, user: data } }));
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Date & Time utilities configured for Indian Standard Time (IST, Asia/Kolkata).
|
|
||||||
* Pragativeer is an Indian EdTech platform where all test and DPP scheduling,
|
|
||||||
* calendar bounds, and student-facing timestamps operate strictly in IST.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const TIMEZONE_IST = "Asia/Kolkata";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely parse any date input (string, Date, or ISO string) into a Date object.
|
|
||||||
* If the string lacks an explicit timezone offset or Zulu indicator, it is
|
|
||||||
* treated as an Indian Standard Time (IST) timestamp (+05:30) so that server
|
|
||||||
* environments running in UTC do not shift the time by 5h 30m.
|
|
||||||
*/
|
|
||||||
export function parseAsISTDate(dateInput: string | Date | null | undefined): Date | null {
|
|
||||||
if (!dateInput) return null;
|
|
||||||
if (dateInput instanceof Date) {
|
|
||||||
return isNaN(dateInput.getTime()) ? null : dateInput;
|
|
||||||
}
|
|
||||||
let str = String(dateInput).trim();
|
|
||||||
if (!str) return null;
|
|
||||||
|
|
||||||
// If string has no timezone indicator (no 'Z' and no explicit +HH:MM or -HH:MM):
|
|
||||||
// e.g. "2026-10-24T05:30:00" or "2026-10-24 05:30:00"
|
|
||||||
if (!str.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(str)) {
|
|
||||||
str = str.replace(" ", "T");
|
|
||||||
str = `${str}+05:30`;
|
|
||||||
}
|
|
||||||
const d = new Date(str);
|
|
||||||
return isNaN(d.getTime()) ? null : d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a date in IST as "Oct 24, 2026, 5:30 AM"
|
|
||||||
*/
|
|
||||||
export function formatISTDateTime(dateInput: string | Date | null | undefined): string {
|
|
||||||
const d = parseAsISTDate(dateInput);
|
|
||||||
if (!d) return "";
|
|
||||||
return d.toLocaleString("en-US", {
|
|
||||||
timeZone: TIMEZONE_IST,
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a date in IST as "Oct 24, 2026"
|
|
||||||
*/
|
|
||||||
export function formatISTDate(dateInput: string | Date | null | undefined): string {
|
|
||||||
const d = parseAsISTDate(dateInput);
|
|
||||||
if (!d) return "";
|
|
||||||
return d.toLocaleDateString("en-US", {
|
|
||||||
timeZone: TIMEZONE_IST,
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a date in IST as "5:30 AM" or "5:30 PM"
|
|
||||||
*/
|
|
||||||
export function formatISTTime(dateInput: string | Date | null | undefined): string {
|
|
||||||
const d = parseAsISTDate(dateInput);
|
|
||||||
if (!d) return "";
|
|
||||||
return d.toLocaleTimeString("en-US", {
|
|
||||||
timeZone: TIMEZONE_IST,
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a "YYYY-MM-DD" date string evaluated in Indian Standard Time (Asia/Kolkata).
|
|
||||||
* Useful for CalendarFilter matching and date grouping without UTC day boundary shifts.
|
|
||||||
*/
|
|
||||||
export function getISTDateString(dateInput: string | Date | null | undefined): string {
|
|
||||||
const d = parseAsISTDate(dateInput);
|
|
||||||
if (!d) return "";
|
|
||||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: TIMEZONE_IST,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
});
|
|
||||||
return fmt.format(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Date object picked in DatePicker into an ISO-8601 string
|
|
||||||
* explicitly tagged with the IST offset (+05:30) so the backend and database
|
|
||||||
* preserve the exact hour and minute selected by the admin.
|
|
||||||
*/
|
|
||||||
export function toISTISOString(date: Date | null | undefined): string | null {
|
|
||||||
if (!date || isNaN(date.getTime())) return null;
|
|
||||||
const y = date.getFullYear();
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
|
||||||
const hh = String(date.getHours()).padStart(2, "0");
|
|
||||||
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
const ss = String(date.getSeconds()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${d}T${hh}:${mm}:${ss}+05:30`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user