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")
|
||||
|
||||
connect_args = {"options": "-c timezone=Asia/Kolkata"} if "postgresql" in DATABASE_URL else {}
|
||||
# Using the standard SQLModel engine config for PostgreSQL with IST timezone session
|
||||
engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args)
|
||||
# Using the standard SQLModel engine config for PostgreSQL
|
||||
engine = create_engine(DATABASE_URL, echo=True)
|
||||
|
||||
def init_db():
|
||||
# Automatically generates tables based on the classes imported from models.py
|
||||
|
||||
139
backend/main.py
139
backend/main.py
@@ -76,7 +76,16 @@ from fastapi.security import OAuth2PasswordBearer
|
||||
import jwt
|
||||
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"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 30
|
||||
|
||||
@@ -90,7 +99,7 @@ def create_access_token(data: dict):
|
||||
return encoded_jwt
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
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 ---
|
||||
|
||||
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.
|
||||
"""
|
||||
def tier_checker(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)):
|
||||
if token == "admin":
|
||||
return models.User(id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH)
|
||||
if token == ADMIN_TOKEN:
|
||||
return models.User(id=uuid.UUID(ADMIN_FALLBACK_ID), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
@@ -208,9 +238,9 @@ class ChangePasswordRequest(BaseModel):
|
||||
|
||||
@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)):
|
||||
# Check demo account
|
||||
# Check demo account (reads the demo password from env, not hardcoded source)
|
||||
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")
|
||||
# Don't actually change demo password
|
||||
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"}
|
||||
|
||||
@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)
|
||||
content = await file.read()
|
||||
decoded = content.decode('utf-8-sig')
|
||||
@@ -297,7 +327,7 @@ class DeleteUsersRequest(BaseModel):
|
||||
user_ids: List[UUID]
|
||||
|
||||
@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:
|
||||
user = session.get(models.User, uid)
|
||||
if user:
|
||||
@@ -312,7 +342,7 @@ def bulk_delete_students(payload: DeleteUsersRequest, session: Session = Depends
|
||||
return {"success": True}
|
||||
|
||||
@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)
|
||||
if not user:
|
||||
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
|
||||
# demo@pragativeer.com → Aarav (AI Coach demo account)
|
||||
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
|
||||
demo_user = ensure_demo_user(_uuid.UUID(DEMO_UUID), session)
|
||||
token_id = str(demo_user.id) if demo_user else DEMO_UUID
|
||||
access_token = create_access_token(data={"sub": 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@":
|
||||
return {"success": True, "role": "admin", "token": "admin", "user_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
|
||||
if payload.email == "admin@pragativeer.com":
|
||||
# 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()
|
||||
if not user:
|
||||
@@ -422,7 +462,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
|
||||
|
||||
# Handle legacy mock_hash from initial seed
|
||||
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')
|
||||
session.add(user)
|
||||
session.commit()
|
||||
@@ -899,8 +939,8 @@ DEMO_DEFINITIONS = {
|
||||
"state": "Bihar",
|
||||
"school": "Loyola High School",
|
||||
"tier": models.TierEnum.FREE,
|
||||
"streak_count": 0,
|
||||
"longest_streak": 0,
|
||||
"streak_count": 1,
|
||||
"longest_streak": 1,
|
||||
# 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(
|
||||
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,
|
||||
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
|
||||
@@ -1110,6 +1150,7 @@ DEMO_USER_UUIDS = {
|
||||
"11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE)
|
||||
"22222222-2222-2222-2222-222222222222", # Neha (AI Coach - 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"])
|
||||
@@ -1269,7 +1310,7 @@ def get_leaderboard(
|
||||
# --- ADMIN ROUTES ---
|
||||
|
||||
@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")
|
||||
if not settings:
|
||||
settings = models.SystemSettings()
|
||||
@@ -1286,7 +1327,7 @@ class SettingsUpdate(BaseModel):
|
||||
trial_tier: Optional[models.TierEnum] = None
|
||||
|
||||
@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")
|
||||
if not settings:
|
||||
settings = models.SystemSettings()
|
||||
@@ -1304,7 +1345,7 @@ def update_system_settings(payload: SettingsUpdate, session: Session = Depends(g
|
||||
return settings
|
||||
|
||||
@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()
|
||||
return tests
|
||||
|
||||
@@ -1342,10 +1383,9 @@ async def upload_admin_test(
|
||||
exam_pattern: str = Form(...),
|
||||
time_limit: int = Form(...),
|
||||
scheduled_date: Optional[datetime] = Form(None),
|
||||
end_date: Optional[datetime] = Form(None),
|
||||
admin_id: Optional[UUID] = Form(None),
|
||||
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).
|
||||
@@ -1388,7 +1428,6 @@ async def upload_admin_test(
|
||||
total_time=time_limit,
|
||||
difficulty="Medium",
|
||||
scheduled_date=scheduled_date,
|
||||
end_date=end_date,
|
||||
is_active=True,
|
||||
created_by=effective_admin_id,
|
||||
pdf_url=url
|
||||
@@ -1406,7 +1445,7 @@ async def upload_admin_test(
|
||||
@app.post("/api/admin/tests/create_manual", tags=["Admin"])
|
||||
def create_manual_test(
|
||||
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
|
||||
|
||||
@@ -1524,7 +1563,7 @@ def create_manual_test(
|
||||
return {"message": "Test created successfully", "test_id": test_obj.id}
|
||||
|
||||
@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)
|
||||
if not test_obj:
|
||||
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}
|
||||
|
||||
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"])
|
||||
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
|
||||
test_obj = session.get(models.Test, test_id)
|
||||
if not test_obj:
|
||||
@@ -1653,12 +1656,12 @@ class CouponUpdate(BaseModel):
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
@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()
|
||||
return coupons
|
||||
|
||||
@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())
|
||||
session.add(coupon)
|
||||
session.commit()
|
||||
@@ -1666,7 +1669,7 @@ def create_coupon(payload: CouponCreate, session: Session = Depends(get_session)
|
||||
return coupon
|
||||
|
||||
@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)
|
||||
if not coupon:
|
||||
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
|
||||
|
||||
@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)
|
||||
if not coupon:
|
||||
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)
|
||||
|
||||
@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."""
|
||||
users = session.exec(select(models.User)).all()
|
||||
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"])
|
||||
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."""
|
||||
user = session.get(models.User, user_id)
|
||||
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"])
|
||||
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."""
|
||||
user = session.get(models.User, user_id)
|
||||
if not user:
|
||||
|
||||
@@ -12,7 +12,6 @@ services:
|
||||
POSTGRES_USER: pragativeer_admin
|
||||
POSTGRES_PASSWORD: local_secure_password_2026
|
||||
POSTGRES_DB: pragativeer_prod
|
||||
TZ: Asia/Kolkata
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
@@ -80,7 +79,6 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- TZ=Asia/Kolkata
|
||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- ENV=development
|
||||
@@ -110,7 +108,6 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
environment:
|
||||
- TZ=Asia/Kolkata
|
||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- MINIO_ENDPOINT=minio:9000
|
||||
@@ -138,7 +135,6 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
environment:
|
||||
- TZ=Asia/Kolkata
|
||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
@@ -164,7 +160,6 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- TZ=Asia/Kolkata
|
||||
- NEXT_PUBLIC_API_URL=http://backend:8000
|
||||
- NEXT_PUBLIC_PAYMENT_GATEWAY=razorpay
|
||||
depends_on:
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function AboutPage() {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<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>
|
||||
<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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
export default function AgentsPage() {
|
||||
@@ -10,7 +10,7 @@ export default function AgentsPage() {
|
||||
]
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function CouponsAdmin() {
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Terminal, Users, Database, Activity, RefreshCw } from "lucide-react"
|
||||
|
||||
export default function AdminDashboard() {
|
||||
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">
|
||||
<div>
|
||||
<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 { Search, Plus, Filter, MoreHorizontal, Edit, Trash } from "lucide-react"
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function QuestionsPage() {
|
||||
]
|
||||
|
||||
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>
|
||||
<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>;
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function StudentKundliPage() {
|
||||
)
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="text-muted-foreground hover:text-foreground">
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function StudentsPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Student Directory</h1>
|
||||
@@ -263,7 +263,7 @@ export default function StudentsPage() {
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
||||
<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}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
"use client"
|
||||
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Loader2, Upload, FileText, Clock, Calendar as CalendarIcon,
|
||||
CheckCircle2, AlertCircle, X, Trash2, CalendarRange,
|
||||
SlidersHorizontal, Check, RefreshCw, Zap, ShieldAlert
|
||||
} from "lucide-react"
|
||||
import { Loader2, Upload, FileText, Clock, CalendarIcon, CheckCircle2, AlertCircle, X, Trash2 } from "lucide-react"
|
||||
import DatePicker from "react-datepicker"
|
||||
import "react-datepicker/dist/react-datepicker.css"
|
||||
import { z } from "zod"
|
||||
|
||||
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
|
||||
const QuestionSchema = z.preprocess((val: any) => {
|
||||
@@ -125,18 +119,6 @@ export default function AdminTestsPage() {
|
||||
const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([])
|
||||
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 () => {
|
||||
try {
|
||||
const res = await fetchWithAuth("/api/admin/tests")
|
||||
@@ -152,6 +134,7 @@ export default function AdminTestsPage() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
@@ -343,8 +254,7 @@ export default function AdminTestsPage() {
|
||||
formData.append("exam_pattern", examPattern)
|
||||
formData.append("time_limit", timeLimit)
|
||||
formData.append("admin_id", effectiveAdminId)
|
||||
if (scheduledDate) formData.append("scheduled_date", toISTISOString(scheduledDate)!)
|
||||
if (endDate) formData.append("end_date", toISTISOString(endDate)!)
|
||||
if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString())
|
||||
|
||||
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
||||
method: "POST",
|
||||
@@ -353,8 +263,6 @@ export default function AdminTestsPage() {
|
||||
if (res.ok) {
|
||||
alert("PDF uploaded successfully to Minio and connected!")
|
||||
setTitle("")
|
||||
setScheduledDate(null)
|
||||
setEndDate(null)
|
||||
removeFile()
|
||||
fetchTests()
|
||||
} else {
|
||||
@@ -383,8 +291,8 @@ export default function AdminTestsPage() {
|
||||
time_limit: parseInt(timeLimit),
|
||||
admin_id: effectiveAdminId,
|
||||
questions_json: validQuestions,
|
||||
scheduled_date: toISTISOString(scheduledDate),
|
||||
end_date: toISTISOString(endDate),
|
||||
scheduled_date: scheduledDate ? scheduledDate.toISOString() : null,
|
||||
end_date: endDate ? endDate.toISOString() : null,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -397,8 +305,6 @@ export default function AdminTestsPage() {
|
||||
if (res.ok) {
|
||||
alert("Test created successfully!")
|
||||
setTitle("")
|
||||
setScheduledDate(null)
|
||||
setEndDate(null)
|
||||
removeFile()
|
||||
fetchTests()
|
||||
} 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 (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs Manager</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Date-wise calendar scheduling, live time-bound controllers, and examination repositories.
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage CBT examinations and daily practice sheets</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
Create / Schedule Test
|
||||
Create Manual Test
|
||||
</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>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpload} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Test / DPP Title</label>
|
||||
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Physics Ch-4 DPP (Electrostatics)" />
|
||||
<label className="text-sm font-medium">Test Title</label>
|
||||
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. JEE Main Mock Test 1" />
|
||||
</div>
|
||||
|
||||
{/* TEST TYPE */}
|
||||
{/* TEST TYPE — KEY DIFFERENTIATOR */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
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>
|
||||
<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"
|
||||
value={testType}
|
||||
onChange={(e) => setTestType(e.target.value)}
|
||||
>
|
||||
<option value="Mock">🎯 CBT Test (JSON) — Mock Tests Section</option>
|
||||
<option value="DPS">🤖 Prep Pilot Test (CBT JSON) — AI Coach</option>
|
||||
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Daily Practice Section</option>
|
||||
<option value="Mock">🎯 CBT Test (JSON) — appears in Mock Tests section</option>
|
||||
<option value="DPS">🤖 Prep Pilot Test (as CBT JSON) — appears in AI Coach</option>
|
||||
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Class/Plan aligned</option>
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{testType === 'Mock' && '✅ Appears in Student Dashboard → Mock Tests (CBT).'}
|
||||
{testType === 'DPS' && '✅ Appears in Student Dashboard → AI Coach → PrepPilot AI.'}
|
||||
{testType === 'DPP_PDF' && '✅ Appears in Student Dashboard → Daily Practice (DPP).'}
|
||||
{testType === 'Mock' && '✅ Purely for CBT. Appears under Dashboard → Mock Tests.'}
|
||||
{testType === 'DPS' && '✅ Purely for CBT. Appears under AI Coach → PrepPilot AI.'}
|
||||
{testType === 'DPP_PDF' && '✅ Uploads a PDF to Minio storage for Class/Plan aligned DPP.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -501,7 +376,6 @@ export default function AdminTestsPage() {
|
||||
<option value="JEE_Main">JEE Main</option>
|
||||
<option value="JEE_Advanced">JEE Advanced</option>
|
||||
<option value="NEET">NEET</option>
|
||||
<option value="JEE_NEET">JEE & NEET (Combined)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -510,48 +384,43 @@ export default function AdminTestsPage() {
|
||||
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* Schedule Boundation Controller */}
|
||||
<div className="p-3.5 bg-secondary/20 rounded-xl border border-border/80 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<label className="text-sm font-medium">Scheduled Start</label>
|
||||
<DatePicker
|
||||
selected={scheduledDate}
|
||||
onChange={(date: Date | null) => setScheduledDate(date)}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
timeCaption="Time"
|
||||
dateFormat="MMM 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"
|
||||
placeholderText="Starts Immediately"
|
||||
timeCaption="time"
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
placeholderText="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 flex flex-col">
|
||||
<label className="text-[11px] font-bold text-muted-foreground">Scheduled End</label>
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<label className="text-sm font-medium">Scheduled End</label>
|
||||
<DatePicker
|
||||
selected={endDate}
|
||||
onChange={(date: Date | null) => setEndDate(date)}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
timeCaption="Time"
|
||||
dateFormat="MMM 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"
|
||||
placeholderText="No Expiry"
|
||||
timeCaption="time"
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
placeholderText="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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' ? (
|
||||
<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
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
@@ -606,6 +475,9 @@ export default function AdminTestsPage() {
|
||||
<span>{err.message}</span>
|
||||
</li>
|
||||
))}
|
||||
{validationErrors.length > 50 && (
|
||||
<li className="text-xs text-muted-foreground italic mt-2">...and {validationErrors.length - 50} more errors</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -613,285 +485,75 @@ export default function AdminTestsPage() {
|
||||
|
||||
<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 ? "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>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Existing Tests List & Calendar Controller */}
|
||||
{/* Existing Tests List */}
|
||||
<Card className="lg:col-span-2 border-muted bg-card shadow-sm h-fit">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarRange className="w-5 h-5 text-primary" />
|
||||
Scheduled Tests & DPPs Controller
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
Test Repository
|
||||
</CardTitle>
|
||||
<CardDescription>Filter by date, adjust start/end time windows, and manage visibility.</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>
|
||||
<CardDescription>Manage visibility and schedule for parsed tests.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>
|
||||
) : filteredTests.length === 0 ? (
|
||||
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-xl">
|
||||
No tests or DPPs match the selected filter criteria.
|
||||
) : tests.length === 0 ? (
|
||||
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-lg">
|
||||
No tests uploaded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{filteredTests.map(test => {
|
||||
const now = new Date()
|
||||
const startTime = parseAsISTDate(test.scheduled_date)
|
||||
const endTime = parseAsISTDate(test.end_date)
|
||||
const isUpcoming = startTime ? startTime > now : false
|
||||
const isExpired = endTime ? now > endTime : false
|
||||
const isLive = !isUpcoming && !isExpired
|
||||
|
||||
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"}
|
||||
<div className="space-y-4">
|
||||
{tests.map(test => (
|
||||
<div key={test.id} className="flex items-center justify-between p-4 border rounded-lg bg-background">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{test.title}</h3>
|
||||
<Badge variant="outline">{test.exam_pattern?.replace("_", " ") || "All"}</Badge>
|
||||
<Badge variant={test.is_active ? "default" : "secondary"}>
|
||||
{test.is_active ? "Active" : "Inactive / Processing"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{test.total_time} mins
|
||||
</div>
|
||||
|
||||
{test.scheduled_date ? (
|
||||
<div className="flex items-center gap-1 font-semibold text-foreground">
|
||||
<span className="text-muted-foreground font-normal">Starts:</span> {formatScheduleDateTime(test.scheduled_date)}
|
||||
</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)}
|
||||
{test.scheduled_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
{new Date(test.scheduled_date).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2.5 shrink-0 self-end sm:self-center">
|
||||
<Button
|
||||
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 className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
|
||||
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDelete(test.id)}
|
||||
title="Delete test"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Scratchpad } from "@/components/CBT/Scratchpad"
|
||||
import { PeriodicTable } from "@/components/CBT/PeriodicTable"
|
||||
import { ExamResult } from "@/components/CBT/ExamResult"
|
||||
import { fetchWithAuth } from "@/lib/apiClient"
|
||||
import { parseAsISTDate, formatISTDateTime } from "@/lib/dateUtils"
|
||||
|
||||
// Dummy data for testing the CBT client engine
|
||||
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");
|
||||
const testData = await testRes.json();
|
||||
|
||||
// Strict boundary checking (IST aware)
|
||||
// Strict boundary checking
|
||||
const now = new Date();
|
||||
const scheduledTime = parseAsISTDate(testData.scheduled_date);
|
||||
const endTime = parseAsISTDate(testData.end_date);
|
||||
if (scheduledTime && scheduledTime > now) {
|
||||
setAccessError(`This exam is scheduled for ${formatISTDateTime(testData.scheduled_date)}. You cannot start it yet.`);
|
||||
if (testData.scheduled_date && new Date(testData.scheduled_date) > now) {
|
||||
setAccessError(`This exam is scheduled for ${new Date(testData.scheduled_date).toLocaleString()}. You cannot start it yet.`);
|
||||
setMounted(true);
|
||||
return;
|
||||
}
|
||||
if (endTime && endTime < now) {
|
||||
setAccessError(`This exam expired on ${formatISTDateTime(testData.end_date)} and is no longer available.`);
|
||||
if (testData.end_date && new Date(testData.end_date) < now) {
|
||||
setAccessError(`This exam expired on ${new Date(testData.end_date).toLocaleString()} and is no longer available.`);
|
||||
setMounted(true);
|
||||
return;
|
||||
}
|
||||
@@ -285,7 +282,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
|
||||
if (status === 'NOT_STARTED') {
|
||||
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">
|
||||
<h1 className="text-3xl font-bold mb-4 flex items-center justify-center gap-3">
|
||||
<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">
|
||||
{/* Warning Modal */}
|
||||
{showWarning && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<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="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-background border border-red-500 shadow-[0_0_50px_-10px_rgba(239,68,68,0.5)] rounded-2xl w-[400px] overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="p-6 text-center space-y-4">
|
||||
<AlertTriangle size={64} className="text-red-500 mx-auto animate-pulse" />
|
||||
<h3 className="text-2xl font-bold text-red-500">Warning!</h3>
|
||||
@@ -740,7 +737,7 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
||||
|
||||
{showSubmitConfirm && (
|
||||
<div className="absolute inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl p-6 max-w-md w-full">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl p-6 max-w-md w-full animate-in fade-in zoom-in duration-200">
|
||||
<h2 className="text-2xl font-bold mb-2">Submit Exam?</h2>
|
||||
<p className="text-muted-foreground mb-6">Are you sure you want to submit your exam? You will not be able to change your answers after submission.</p>
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ function CheckoutContent() {
|
||||
|
||||
if (success) {
|
||||
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">
|
||||
<CheckCircle2 size={80} className="text-[var(--cyan-ai)] mx-auto mb-6" />
|
||||
<h2 className="text-3xl font-black mb-2">Payment Successful!</h2>
|
||||
@@ -198,7 +198,7 @@ function CheckoutContent() {
|
||||
<ArrowLeft size={16} /> Back to Plans
|
||||
</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 */}
|
||||
<div className="space-y-6">
|
||||
@@ -259,7 +259,7 @@ function CheckoutContent() {
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function ContactPage() {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<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>
|
||||
<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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
@@ -148,7 +148,7 @@ export default function AIComCBTDPPPage() {
|
||||
</div>
|
||||
|
||||
{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 gap-3">
|
||||
<CheckCircle2 size={32} className="text-emerald-400" />
|
||||
@@ -216,7 +216,7 @@ export default function AIComCBTDPPPage() {
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<p>{sampleQuestions[activeQuestion].explanation}</p>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,9 @@ import { Button } from "@/components/ui/button"
|
||||
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 { useEffect, useState, useMemo } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||
import { formatISTDateTime, formatISTTime, parseAsISTDate } from "@/lib/dateUtils"
|
||||
|
||||
export default function DPPPage() {
|
||||
const { user, userId, hasAccessTo } = useActiveUser()
|
||||
@@ -82,27 +80,21 @@ export default function DPPPage() {
|
||||
return acc;
|
||||
}, {} 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 => {
|
||||
// Only show CBT DPPs here (no PDF)
|
||||
if (dpp.pdf_url) return false;
|
||||
|
||||
if (dateFilter) {
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
||||
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const getPdfUrl = (title: string) => {
|
||||
const t = title.toLowerCase();
|
||||
@@ -117,7 +109,7 @@ export default function DPPPage() {
|
||||
const cleanTitle = (title: string) => title.replace(/\s*\(ExamEnum\.[^)]+\)/, '');
|
||||
|
||||
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>
|
||||
<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 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-3 flex-wrap">
|
||||
<CalendarFilter
|
||||
selectedDate={dateFilter}
|
||||
onSelectDate={setDateFilter}
|
||||
availableDates={scheduledDates}
|
||||
placeholder="Filter by Schedule Date"
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
onChange={(e) => setDateFilter(e.target.value)}
|
||||
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-xs font-semibold text-muted-foreground bg-secondary/60 px-2.5 py-1 rounded-lg">
|
||||
Showing for {dateFilter}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-bold text-muted-foreground mr-2"><Filter size={16} className="inline mr-1"/> Difficulty:</span>
|
||||
{['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => (
|
||||
<button key={diff} className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${diff === 'Mixed' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary/50'}`}>
|
||||
{diff}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-1 ml-2 text-xs text-muted-foreground bg-secondary/50 px-2 py-1 rounded-md">
|
||||
<Lock size={12}/> Pro
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -175,32 +171,22 @@ export default function DPPPage() {
|
||||
const attempt = attemptsByTest[dpp.id];
|
||||
const isAttempted = !!attempt;
|
||||
|
||||
// Date & Time logic (IST aware)
|
||||
// Date logic
|
||||
const now = new Date();
|
||||
const scheduledTime = parseAsISTDate(dpp.scheduled_date);
|
||||
const endTime = parseAsISTDate(dpp.end_date);
|
||||
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
||||
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now;
|
||||
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
||||
const isUpcoming = scheduledTime > now;
|
||||
const isExpired = endTime ? now > endTime : false;
|
||||
const isLive = !isUpcoming && !isExpired;
|
||||
|
||||
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">
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
{/* Clean Status Icon — No Selectable Circle Checkboxes */}
|
||||
<div className="mt-1 flex-shrink-0">
|
||||
{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 className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
|
||||
{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>}
|
||||
</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)]">
|
||||
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
||||
</span>
|
||||
@@ -209,30 +195,14 @@ export default function DPPPage() {
|
||||
{isUpcoming ? (
|
||||
<>
|
||||
<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">
|
||||
🔒 Upcoming
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase text-yellow-500 bg-yellow-500/10 px-1.5 py-0.5 rounded">Upcoming</span>
|
||||
</>
|
||||
) : isExpired ? (
|
||||
<>
|
||||
<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">
|
||||
⏳ Expired
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">Expired</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
||||
{cleanTitle(dpp.title)}
|
||||
@@ -241,25 +211,10 @@ export default function DPPPage() {
|
||||
</span>
|
||||
</h3>
|
||||
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
||||
<span>{count} Questions • {dpp.total_time} mins</span>
|
||||
{isUpcoming && scheduledTime && (
|
||||
<span className="ml-2 border-l border-border pl-2 text-amber-500 font-semibold">
|
||||
Opens on {formatDateTime(dpp.scheduled_date)}
|
||||
</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)}
|
||||
{count} Questions • {dpp.total_time} mins
|
||||
{dpp.scheduled_date && (
|
||||
<span className="ml-2 border-l border-border pl-2">
|
||||
{isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -267,13 +222,9 @@ export default function DPPPage() {
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
||||
{isUpcoming ? (
|
||||
<Button disabled variant="outline" size="sm" className="font-bold text-amber-500 border-amber-500/30 bg-amber-500/5">
|
||||
Opens at {scheduledTime ? formatTime(dpp.scheduled_date) : "Scheduled Time"}
|
||||
</Button>
|
||||
<Button disabled variant="outline" size="sm" className="font-bold">Starts Soon</Button>
|
||||
) : isExpired && !isAttempted ? (
|
||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50 text-muted-foreground">
|
||||
Expired
|
||||
</Button>
|
||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
|
||||
) : isAttempted ? (
|
||||
<>
|
||||
<Link href={`/cbt/${dpp.id}/result`}>
|
||||
@@ -305,8 +256,7 @@ export default function DPPPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
)})}
|
||||
</div>
|
||||
|
||||
{/* ── PROTECTED PDF READER MODAL (FREE TIER) ── */}
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function CollegePredictorPage() {
|
||||
const trendData = isDemo ? DEMO_TREND_DATA : ZERO_TREND_DATA
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<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 : []
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<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`
|
||||
|
||||
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 ── */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-2">
|
||||
@@ -108,7 +108,7 @@ export default function AICoachDashboard() {
|
||||
</div>
|
||||
|
||||
{/* ── DAILY CHALLENGE BANNER ── */}
|
||||
<div className=""><DailyChallengeBanner /></div>
|
||||
<DailyChallengeBanner />
|
||||
|
||||
{/* ── QUICK STATS ── */}
|
||||
<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: "AI Sessions", value: aiSessions, color: "text-purple-400", bg: "bg-purple-400/5", border: "border-purple-400/20", icon: <Bot size={32} /> },
|
||||
].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">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import { ReadinessGauge } from "@/components/AI/ReadinessGauge"
|
||||
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))
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<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: "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" /> },
|
||||
].map((kpi) => (
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
@@ -126,7 +126,7 @@ export default function ReadinessPage() {
|
||||
<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>
|
||||
<div
|
||||
className={`w-full rounded-t-md ${
|
||||
className={`w-full rounded-t-md transition-all duration-700 ${
|
||||
!isDemo
|
||||
? "bg-white/5 border border-white/10"
|
||||
: i === scoreHistory.length - 1
|
||||
@@ -182,7 +182,7 @@ export default function ReadinessPage() {
|
||||
<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">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import { ExamStrategyCard } from "@/components/AI/ExamStrategyCard"
|
||||
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
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<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">
|
||||
{[
|
||||
{ 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: "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) => (
|
||||
@@ -101,7 +101,7 @@ export default function StrategyPage() {
|
||||
<div className="text-[11px] text-muted-foreground font-semibold">Attempted</div>
|
||||
</div>
|
||||
<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>
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import { StudyPlanCard } from "@/components/AI/StudyPlanCard"
|
||||
import { WeeklyProgressReport } from "@/components/AI/WeeklyProgressReport"
|
||||
@@ -12,7 +12,7 @@ export default function StudyPlanPage() {
|
||||
const streakCount = user?.streak_count || (isDemo ? 12 : 1)
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -99,7 +99,7 @@ export default function StudyPlanPage() {
|
||||
</div>
|
||||
<div className="h-2.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function TimeOptimizationPage() {
|
||||
]
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<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 { BrainCircuit, Sparkles } from "lucide-react"
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function WeeklyProgressPage() {
|
||||
const syllabusProgress = isDemo ? DEMO_SYLLABUS_PROGRESS : ZERO_SYLLABUS_PROGRESS
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -108,7 +108,7 @@ export default function WeeklyProgressPage() {
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} rounded-full`}
|
||||
className={`h-full ${item.color} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function AnalyticsPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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;
|
||||
|
||||
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">
|
||||
<ArrowLeft size={16} className="mr-2" /> {test?.type === 'DPS' ? 'Back to PrepPilot AI' : 'Back to Tests'}
|
||||
</Link>
|
||||
|
||||
@@ -10,10 +10,6 @@ import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-r
|
||||
import { useActiveUser } from "@/hooks/useActiveUser";
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -95,29 +91,22 @@ export default function CBTPage() {
|
||||
loadTests();
|
||||
}, [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 matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase());
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
if (dateFilter) {
|
||||
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;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
||||
const formatDate = (dateStr: string) => formatISTDate(dateStr);
|
||||
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// Get the highest score for each test if attempted multiple times
|
||||
const attemptsByTest = attempts.reduce((acc, curr) => {
|
||||
@@ -141,7 +130,7 @@ export default function CBTPage() {
|
||||
|
||||
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>
|
||||
<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")}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3">
|
||||
<CalendarFilter
|
||||
selectedDate={dateFilter}
|
||||
onSelectDate={setDateFilter}
|
||||
availableDates={scheduledDates}
|
||||
placeholder="Filter by Exam Date"
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative w-full sm:w-48">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
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">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<input
|
||||
@@ -212,8 +203,8 @@ export default function CBTPage() {
|
||||
|
||||
// Date logic
|
||||
const now = new Date();
|
||||
const scheduledTime = parseAsISTDate(exam.scheduled_date) || now;
|
||||
const endTime = parseAsISTDate(exam.end_date);
|
||||
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now;
|
||||
const endTime = exam.end_date ? new Date(exam.end_date) : null;
|
||||
const isUpcoming = scheduledTime > now;
|
||||
const isExpired = endTime ? now > endTime : false;
|
||||
|
||||
@@ -250,32 +241,14 @@ export default function CBTPage() {
|
||||
</h3>
|
||||
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
||||
<div className="font-medium text-foreground">
|
||||
{isUpcoming && exam.scheduled_date ? (
|
||||
<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>
|
||||
)}
|
||||
{isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'}
|
||||
</div>
|
||||
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
||||
</div>
|
||||
|
||||
{isUpcoming ? (
|
||||
<Button disabled className="w-full gap-2 rounded-xl bg-amber-500/10 text-amber-500 border border-amber-500/30 font-bold">
|
||||
<Clock size={16} /> Opens at {exam.scheduled_date ? formatTime(exam.scheduled_date) : "Scheduled Time"}
|
||||
<Button disabled className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground font-bold">
|
||||
<Clock size={16} /> Starts Soon
|
||||
</Button>
|
||||
) : isLocked ? (
|
||||
<Button
|
||||
@@ -365,7 +338,7 @@ export default function CBTPage() {
|
||||
|
||||
{showUpgradeModal && (
|
||||
<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">
|
||||
<Lock size={32} />
|
||||
</div>
|
||||
|
||||
@@ -84,45 +84,38 @@ export default function CommunityPage() {
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
|
||||
if (!hasAccessTo("doubt-resolution-community") && !isDemo) {
|
||||
if (loading) {
|
||||
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)]">
|
||||
<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 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 Doubt Forum...</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">
|
||||
)
|
||||
}
|
||||
|
||||
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} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Premium Feature</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<h2 className="text-3xl font-bold mb-4">Premium Feature</h2>
|
||||
<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.
|
||||
</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)]"
|
||||
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'}
|
||||
>
|
||||
Upgrade Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<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 className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground animate-pulse">Loading doubts...</div>
|
||||
) : (<>{filteredDoubts.map((doubt) => (
|
||||
{filteredDoubts.map((doubt) => (
|
||||
<Card key={doubt.id} className="border-border shadow-sm hover:border-[var(--cyan-ai-10)] transition-colors cursor-pointer">
|
||||
<CardContent className="p-0 flex">
|
||||
{/* Upvote Sidebar */}
|
||||
@@ -268,12 +259,11 @@ export default function CommunityPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
{/* Ask Doubt Modal */}
|
||||
{isAskModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<MessageSquare className="text-[var(--cyan-ai)]" /> Ask a Doubt
|
||||
@@ -341,7 +331,7 @@ export default function CommunityPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</PremiumGate>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,9 @@ import { Button } from "@/components/ui/button"
|
||||
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 { useEffect, useState, useMemo } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||
import { formatISTDateTime, formatISTTime, parseAsISTDate } from "@/lib/dateUtils"
|
||||
|
||||
export default function AIDailyPracticePage() {
|
||||
const { user, userId, hasAccessTo } = useActiveUser()
|
||||
@@ -83,28 +81,22 @@ export default function AIDailyPracticePage() {
|
||||
return acc;
|
||||
}, {} 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 => {
|
||||
// Only show PDF DPPs here
|
||||
if (!dpp.pdf_url) return false;
|
||||
|
||||
if (dateFilter) {
|
||||
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;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
||||
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const getPdfUrl = (title: string) => {
|
||||
const t = title.toLowerCase();
|
||||
@@ -119,7 +111,7 @@ export default function AIDailyPracticePage() {
|
||||
const cleanTitle = (title: string) => title.replace(/\s*\(ExamEnum\.[^)]+\)/, '');
|
||||
|
||||
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 */}
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -130,39 +122,33 @@ export default function AIDailyPracticePage() {
|
||||
Daily Practice (DPP)
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Filter & Controller Bar */}
|
||||
<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-3 flex-wrap">
|
||||
<CalendarFilter
|
||||
selectedDate={dateFilter}
|
||||
onSelectDate={setDateFilter}
|
||||
availableDates={scheduledDates}
|
||||
placeholder="Filter by Schedule Date"
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
onChange={(e) => setDateFilter(e.target.value)}
|
||||
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-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>
|
||||
<Button variant="outline" className="gap-2 rounded-xl text-sm font-bold">
|
||||
<Download size={16} /> Export as PDF
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{loading ? (
|
||||
<div className="col-span-2 text-center py-12 text-muted-foreground">Loading AI-generated DPPs...</div>
|
||||
) : filteredDpps.length === 0 ? (
|
||||
<div className="col-span-2 text-center py-12 text-muted-foreground border border-dashed rounded-xl">
|
||||
No DPPs found for the selected schedule criteria.
|
||||
</div>
|
||||
<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>
|
||||
) : filteredDpps.map((dpp, i) => {
|
||||
// Calculate total questions dynamically from sections
|
||||
let count = 0;
|
||||
@@ -173,32 +159,22 @@ export default function AIDailyPracticePage() {
|
||||
const attempt = attemptsByTest[dpp.id];
|
||||
const isAttempted = !!attempt;
|
||||
|
||||
// Accurate Date & Time logic (IST aware)
|
||||
// Date logic
|
||||
const now = new Date();
|
||||
const scheduledTime = parseAsISTDate(dpp.scheduled_date);
|
||||
const endTime = parseAsISTDate(dpp.end_date);
|
||||
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
||||
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now;
|
||||
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
||||
const isUpcoming = scheduledTime > now;
|
||||
const isExpired = endTime ? now > endTime : false;
|
||||
const isLive = !isUpcoming && !isExpired;
|
||||
|
||||
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">
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
{/* Clean Status Icon — No Selectable Circle Checkboxes */}
|
||||
<div className="mt-1 flex-shrink-0">
|
||||
{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 className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
|
||||
{isAttempted ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground flex items-center justify-center opacity-50"></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)]">
|
||||
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
||||
</span>
|
||||
@@ -207,30 +183,14 @@ export default function AIDailyPracticePage() {
|
||||
{isUpcoming ? (
|
||||
<>
|
||||
<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">
|
||||
🔒 Upcoming
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase text-yellow-500 bg-yellow-500/10 px-1.5 py-0.5 rounded">Upcoming</span>
|
||||
</>
|
||||
) : isExpired ? (
|
||||
<>
|
||||
<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">
|
||||
⏳ Expired
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">Expired</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
||||
{cleanTitle(dpp.title)}
|
||||
@@ -241,25 +201,10 @@ export default function AIDailyPracticePage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
||||
<span>{count} Questions • {dpp.total_time} mins</span>
|
||||
{isUpcoming && scheduledTime && (
|
||||
<span className="ml-2 border-l border-border pl-2 text-amber-500 font-semibold">
|
||||
Opens on {formatDateTime(dpp.scheduled_date)}
|
||||
</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)}
|
||||
{count} Questions • {dpp.total_time} mins
|
||||
{dpp.scheduled_date && (
|
||||
<span className="ml-2 border-l border-border pl-2">
|
||||
{isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -267,13 +212,9 @@ export default function AIDailyPracticePage() {
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
||||
{isUpcoming ? (
|
||||
<Button disabled variant="outline" size="sm" className="font-bold text-amber-500 border-amber-500/30 bg-amber-500/5">
|
||||
Opens at {scheduledTime ? formatTime(dpp.scheduled_date) : "Scheduled Time"}
|
||||
</Button>
|
||||
<Button disabled variant="outline" size="sm" className="font-bold">Starts Soon</Button>
|
||||
) : isExpired && !isAttempted ? (
|
||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50 text-muted-foreground">
|
||||
Expired
|
||||
</Button>
|
||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
|
||||
) : (
|
||||
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)]">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ThemeToggle } from "@/components/ThemeToggle"
|
||||
import { TierBadge } from "@/components/TierBadge"
|
||||
import { StreakCounter } from "@/components/StreakCounter"
|
||||
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 { Badge } from "@/components/ui/badge"
|
||||
import MaintenanceScreen from "@/components/MaintenanceScreen"
|
||||
@@ -309,7 +309,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
{/* Functional User Dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{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">
|
||||
<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>
|
||||
@@ -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">
|
||||
<Lock size={16} /> Change Password
|
||||
</Link>
|
||||
{isDemoAccount(userId) && (
|
||||
{isDemoUser(userId) && (
|
||||
<button
|
||||
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"
|
||||
@@ -391,7 +391,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Demo Switcher — only visible when logged in as a known demo account */}
|
||||
{isDemoAccount(userId) && (
|
||||
{isDemoUser(userId) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{switching && (
|
||||
<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 */}
|
||||
<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}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function LeaderboardPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<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 { Card, CardContent } from "@/components/ui/card"
|
||||
@@ -32,7 +32,7 @@ export default function MaterialsPage() {
|
||||
};
|
||||
|
||||
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>
|
||||
<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 { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
||||
import { AdaptiveStudyPlan } from "@/components/AI/AdaptiveStudyPlan"
|
||||
import { useActiveUser, isDemoUser, DEFAULT_USER_ID } from "@/hooks/useActiveUser"
|
||||
import { formatISTDate } from "@/lib/dateUtils"
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
|
||||
const [userStats, setUserStats] = useState<any>(() => {
|
||||
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 [userStats, setUserStats] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dpps, setDpps] = useState<any[]>([])
|
||||
const [cbts, setCbts] = useState<any[]>([])
|
||||
@@ -45,7 +22,8 @@ export default function DashboardPage() {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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) {
|
||||
setLoading(false)
|
||||
return
|
||||
@@ -70,7 +48,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
fetchStatsAndTests()
|
||||
}, [user?.id, userId, user?.target_exam])
|
||||
}, [userLoading, user?.id, userId])
|
||||
|
||||
const getShareUrl = () => {
|
||||
const score = isNew ? "0 / 0" : `${userStats?.today_score || 0} / ${userStats?.today_max_score || 60}`
|
||||
@@ -130,15 +108,24 @@ export default function DashboardPage() {
|
||||
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 isNeet = user?.target_exam === "NEET"
|
||||
|
||||
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 */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
{/* Basic Performance Dashboard (F-05) */}
|
||||
<section>
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
|
||||
<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)]">
|
||||
@@ -173,11 +160,9 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Stat Cards */}
|
||||
<div ref={progressRef} className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div ref={progressRef} className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 -mx-4 bg-background rounded-2xl">
|
||||
{/* Today's Score */}
|
||||
<Card className="border-border shadow-sm">
|
||||
<Card>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Overall Accuracy */}
|
||||
<Card className="border-border shadow-sm">
|
||||
<Card>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Qs Attempted */}
|
||||
<Card className="border-border shadow-sm">
|
||||
<Card>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
@@ -239,23 +224,21 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tests & DPPs 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 mb-8">
|
||||
{/* Dynamic DPP Modules */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xl font-semibold tracking-tight flex items-center gap-2">
|
||||
Today's DPPs
|
||||
</h2>
|
||||
<Link href="/dashboard/dpp">
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{dpps.length > 0 ? (
|
||||
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">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="p-3 bg-secondary rounded-xl text-foreground">
|
||||
@@ -274,27 +257,8 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</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">No DPPs scheduled for today.</p>
|
||||
</Card>
|
||||
<p className="text-muted-foreground text-sm py-4">No DPPs scheduled for today.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -305,9 +269,6 @@ export default function DashboardPage() {
|
||||
<h2 className="text-xl font-semibold tracking-tight flex items-center gap-2">
|
||||
Upcoming CBTs
|
||||
</h2>
|
||||
<Link href="/dashboard/cbt">
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{cbts.length > 0 ? (
|
||||
@@ -317,7 +278,7 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -330,14 +291,6 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</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">
|
||||
<p className="text-muted-foreground text-sm">No upcoming CBTs at the moment.</p>
|
||||
@@ -347,14 +300,13 @@ export default function DashboardPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* AI Intelligence Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<section>
|
||||
<AdaptiveStudyPlan />
|
||||
</div>
|
||||
<div>
|
||||
</section>
|
||||
<section>
|
||||
<MistakeHeatmap />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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 { PremiumGate } from "@/components/PremiumGate"
|
||||
|
||||
@@ -134,26 +134,25 @@ const ZERO_SUBJECTS: Subject[] = [
|
||||
// Math/Bio dynamic subjects handled below
|
||||
]
|
||||
|
||||
export default function ProgressTrackerPage() {
|
||||
const { userId, user } = useActiveUser()
|
||||
const isDemo = isDemoUser(userId)
|
||||
const [subjects, setSubjects] = useState<Subject[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const targetExam = user?.target_exam || "JEE_Main";
|
||||
const isNeet = targetExam === "NEET";
|
||||
|
||||
const mathDemoSubject: Subject = {
|
||||
name: "Mathematics",
|
||||
completionPct: 28,
|
||||
completionPct: 22,
|
||||
chapters: [
|
||||
{
|
||||
name: "Calculus",
|
||||
isExpanded: true,
|
||||
topics: [
|
||||
{ name: "Limits", status: "In Progress", accuracy: 60, questionsAttempted: 45 },
|
||||
{ name: "Derivatives", status: "Practiced", accuracy: 72, questionsAttempted: 35 },
|
||||
{ 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 },
|
||||
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -170,14 +169,6 @@ const mathDemoSubject: Subject = {
|
||||
{ name: "Cell Wall", status: "Practiced", accuracy: 80, questionsAttempted: 60 },
|
||||
{ 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: [
|
||||
{ name: "Limits", 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: "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 isNeet = targetExam === "NEET";
|
||||
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>
|
||||
);
|
||||
}
|
||||
const base = isDemo ? [...DEMO_SUBJECTS] : [...ZERO_SUBJECTS];
|
||||
base.push(isDemo ? (isNeet ? bioDemoSubject : mathDemoSubject) : (isNeet ? bioZeroSubject : mathZeroSubject));
|
||||
|
||||
setSubjects(base);
|
||||
}, [isDemo, user?.target_exam]);
|
||||
|
||||
const toggleChapter = (subjectIndex: number, chapterIndex: number) => {
|
||||
const newSubjects = [...subjects]
|
||||
@@ -317,8 +216,8 @@ export default function ProgressTrackerPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
||||
<PremiumGate featureKey="chapter-wise-progress-tracking">
|
||||
<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>
|
||||
<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 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) => (
|
||||
<Card key={i} className="border-border shadow-sm">
|
||||
<CardContent className="p-6 text-center">
|
||||
@@ -408,6 +307,6 @@ export default function ProgressTrackerPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</PremiumGate>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function RankPredictorPage() {
|
||||
]
|
||||
|
||||
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 */}
|
||||
<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()}`}
|
||||
</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)]">
|
||||
<CheckCircle2 size={13} /> {totalScore === 0 ? "Calibrating <EFBFBD> Adjust Sliders" : "High Accuracy Confidence"}
|
||||
<CheckCircle2 size={13} /> {totalScore === 0 ? "Calibrating · Adjust Sliders" : "High Accuracy Confidence"}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<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 { useState } from "react";
|
||||
@@ -45,7 +45,7 @@ export default function PasswordPage() {
|
||||
};
|
||||
|
||||
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>
|
||||
<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 { useState, useEffect } from "react"
|
||||
@@ -46,7 +46,7 @@ export default function StreaksPage() {
|
||||
];
|
||||
|
||||
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>
|
||||
<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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -46,7 +46,7 @@ export default function ForgotPasswordPage() {
|
||||
<ArrowLeft size={16} /> Back to Login
|
||||
</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">
|
||||
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
||||
</div>
|
||||
|
||||
@@ -116,40 +116,11 @@ body {
|
||||
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 {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.bg-grid {
|
||||
background-size: 40px 40px;
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
@@ -202,4 +173,3 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ export default function LoginPage() {
|
||||
if (data.success) {
|
||||
if (typeof window !== "undefined") {
|
||||
// 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`;
|
||||
|
||||
localStorage.setItem("activeUserId", data.user_id || data.token);
|
||||
@@ -40,7 +42,7 @@ export default function LoginPage() {
|
||||
const sessionToken = data.token;
|
||||
localStorage.setItem("active_session_token", sessionToken);
|
||||
|
||||
if (data.token && data.token !== "admin") {
|
||||
if (data.token) {
|
||||
try {
|
||||
await fetchWithAuth(`/api/users/${data.user_id}/session`, {
|
||||
method: 'POST',
|
||||
@@ -62,17 +64,7 @@ export default function LoginPage() {
|
||||
setError(data.error || "Invalid email or password.")
|
||||
}
|
||||
} catch (err) {
|
||||
// Local dev offline fallback: if backend is offline on localhost, allow demo login to test frontend
|
||||
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.");
|
||||
setError("An error occurred. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +74,7 @@ export default function LoginPage() {
|
||||
<ArrowLeft size={16} /> Back to Home
|
||||
</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">
|
||||
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Zap,
|
||||
@@ -40,6 +41,7 @@ export default function OnboardingPage() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("chosen_plan", plan);
|
||||
}
|
||||
// Small UX delay so the button animation feels intentional
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
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>
|
||||
|
||||
<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 */}
|
||||
<div className="flex justify-center">
|
||||
<Logo href="/" className="w-56" imageClassName="object-contain" height={64} />
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Sparkles size={14} className="animate-pulse" />
|
||||
Account Created Successfully!
|
||||
@@ -78,12 +85,16 @@ export default function OnboardingPage() {
|
||||
<Clock size={16} />
|
||||
Your 15-day free trial with all features starts now — Day 1 of 15
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Plan Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
{/* 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
|
||||
className="relative rounded-[2rem] p-[1px] h-full group cursor-pointer"
|
||||
style={{
|
||||
@@ -92,7 +103,7 @@ export default function OnboardingPage() {
|
||||
}}
|
||||
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 */}
|
||||
<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">
|
||||
@@ -124,7 +135,7 @@ export default function OnboardingPage() {
|
||||
if (!selecting) handleSelect("Pro");
|
||||
}}
|
||||
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" ? (
|
||||
<>
|
||||
@@ -140,10 +151,14 @@ export default function OnboardingPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 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
|
||||
className="relative rounded-[2rem] p-[1px] h-full group cursor-pointer"
|
||||
style={{
|
||||
@@ -157,7 +172,7 @@ export default function OnboardingPage() {
|
||||
<Sparkles size={12} /> Most Popular
|
||||
</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 */}
|
||||
<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">
|
||||
@@ -189,7 +204,7 @@ export default function OnboardingPage() {
|
||||
if (!selecting) handleSelect("AI Coach");
|
||||
}}
|
||||
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" ? (
|
||||
<>
|
||||
@@ -205,13 +220,18 @@ export default function OnboardingPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
</p>
|
||||
</motion.p>
|
||||
</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 { 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 Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { motion, useScroll, useTransform, Variants } from "framer-motion"
|
||||
import { useRef, useEffect } from "react"
|
||||
import { ThemeToggle } from "@/components/ThemeToggle"
|
||||
|
||||
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" 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>
|
||||
|
||||
{/* Ambient Glows */}
|
||||
<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" />
|
||||
<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" />
|
||||
{/* Floating Orbs (Using Framer Motion) */}
|
||||
<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"
|
||||
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 */}
|
||||
<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">
|
||||
{/* Hero Section */}
|
||||
<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">
|
||||
The Next Evolution of Prep
|
||||
</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" />
|
||||
<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.
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
@@ -238,7 +303,7 @@ export default function MarketingPage() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Infinite Trust Bar (commented out)
|
||||
@@ -269,13 +334,25 @@ export default function MarketingPage() {
|
||||
|
||||
{/* Animated DPP Engine Section */}
|
||||
<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>
|
||||
<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">
|
||||
<div>
|
||||
<motion.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">
|
||||
<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">
|
||||
@@ -290,9 +367,9 @@ export default function MarketingPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</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">
|
||||
<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">
|
||||
@@ -307,9 +384,9 @@ export default function MarketingPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</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">
|
||||
<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">
|
||||
@@ -324,22 +401,56 @@ export default function MarketingPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
{/* Animated Pricing */}
|
||||
<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="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>
|
||||
<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">
|
||||
{/* 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 */}
|
||||
<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">
|
||||
<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>
|
||||
@@ -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>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
@@ -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>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<p className="text-center mt-8 text-sm text-muted-foreground font-medium w-full">
|
||||
* All above prices are inclusive of GST.
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function SignupPage() {
|
||||
<ArrowLeft size={16} /> Back to Home
|
||||
</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">
|
||||
<Logo href="/" className="w-72" imageClassName="object-contain" height={80} />
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@ export default function SignupPage() {
|
||||
<CardContent className="space-y-6 pt-4">
|
||||
|
||||
{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="space-y-2">
|
||||
<label className="text-sm font-bold text-foreground">First Name</label>
|
||||
@@ -185,7 +185,7 @@ export default function SignupPage() {
|
||||
|
||||
{/* OTP verification section temporarily disabled
|
||||
{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">
|
||||
<span>Verification Code</span>
|
||||
<span className="text-primary text-xs">Resend in 30s</span>
|
||||
@@ -224,7 +224,7 @@ export default function SignupPage() {
|
||||
</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">
|
||||
<label className="text-sm font-bold text-foreground">Target Exam</label>
|
||||
<select
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
|
||||
@@ -21,7 +21,7 @@ export default function UpgradePage() {
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -32,10 +32,14 @@ const NEW_USER_PLAN: PlanItem[] = [
|
||||
export function AdaptiveStudyPlan() {
|
||||
const { user } = useActiveUser();
|
||||
const isDemo = isDemoUser(user?.id);
|
||||
const [items, setItems] = useState<PlanItem[]>(() => isDemo ? DEMO_PLAN : NEW_USER_PLAN);
|
||||
const [items, setItems] = useState<PlanItem[]>(DEMO_PLAN);
|
||||
|
||||
useEffect(() => {
|
||||
setItems(isDemo ? DEMO_PLAN : NEW_USER_PLAN);
|
||||
if (isDemo) {
|
||||
setItems(DEMO_PLAN);
|
||||
} else {
|
||||
setItems(NEW_USER_PLAN);
|
||||
}
|
||||
}, [isDemo]);
|
||||
|
||||
const updateStatus = (id: string, newStatus: PlanState) => {
|
||||
|
||||
@@ -20,8 +20,8 @@ const ZERO_HEATMAP_DATA = [
|
||||
];
|
||||
|
||||
export function MistakeHeatmap() {
|
||||
const { userId, user } = useActiveUser();
|
||||
const isDemo = isDemoUser(user?.id || userId);
|
||||
const { userId } = useActiveUser();
|
||||
const isDemo = isDemoUser(userId);
|
||||
|
||||
const getColor = (errorRate: number) => {
|
||||
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) => (
|
||||
<div
|
||||
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}%`}
|
||||
>
|
||||
<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 { useEffect, useState } from "react"
|
||||
@@ -103,6 +103,7 @@ export function ReadinessGauge() {
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={isZero ? circumference : strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1500 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
<div className="flex-1 h-2 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
className="h-full rounded-full transition-all duration-1000"
|
||||
style={{ width: `${sub.val}%`, background: sub.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ReferralCard() {
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function StudyPlanCard() {
|
||||
{/* Progress bar */}
|
||||
<div className="h-[3px] bg-[var(--cyan-ai-10)] w-full">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Flame, Star, Clock, TrendingUp, Sparkles } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
@@ -92,7 +92,7 @@ export function StudyTimeOptimizationCard() {
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`w-full rounded-t-md ${
|
||||
className={`w-full rounded-t-md transition-all duration-300 ${
|
||||
slot.peak
|
||||
? "bg-gradient-to-t from-yellow-600/80 to-yellow-400 shadow-[0_0_14px_rgba(234,179,8,0.5)]"
|
||||
: hovered === i
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import { TrendingUp, TrendingDown, Brain, Zap, Target, Clock } from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
@@ -97,7 +97,7 @@ export function WeeklyProgressReport() {
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`w-full rounded-t-md ${
|
||||
className={`w-full rounded-t-md transition-all duration-700 ${
|
||||
!isDemo
|
||||
? "bg-white/5 border border-white/10"
|
||||
: i === 6
|
||||
|
||||
@@ -64,8 +64,8 @@ export function Calculator({ isOpen, onClose }: CalculatorProps) {
|
||||
const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[400px] overflow-hidden">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[400px] overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
|
||||
<h3 className="font-bold">Scientific Calculator</h3>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ExamResult({ payload, attemptId, examType, onRetake }: ExamResul
|
||||
};
|
||||
|
||||
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="text-center space-y-4">
|
||||
|
||||
@@ -13,8 +13,8 @@ export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md">
|
||||
<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="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col w-[95vw] max-w-[1400px] h-[95vh]">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/80">
|
||||
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -123,8 +123,8 @@ export function Scratchpad({ isOpen, onClose }: ScratchpadProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm 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="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200 p-8">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col w-full max-w-6xl h-full max-h-[800px]">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
|
||||
<h3 className="font-bold flex items-center gap-2"><Pen size={18}/> Split Workspace: Draw & Type</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
>
|
||||
{/* Centered Modal Card */}
|
||||
<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()}
|
||||
>
|
||||
{/* Close button */}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Wrench } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function MaintenanceScreen() {
|
||||
return (
|
||||
<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">
|
||||
<Wrench size={32} />
|
||||
</div>
|
||||
@@ -21,7 +26,7 @@ export default function MaintenanceScreen() {
|
||||
We'll be back shortly. Thank you for your patience!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ interface PremiumGateProps {
|
||||
export function PremiumGate({ featureKey, children }: PremiumGateProps) {
|
||||
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) {
|
||||
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)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Flame } from "lucide-react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface StreakCounterProps {
|
||||
streak: number
|
||||
@@ -8,11 +9,15 @@ interface StreakCounterProps {
|
||||
|
||||
export function StreakCounter({ streak }: StreakCounterProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 border border-orange-500/20 text-orange-500 font-bold shadow-[0_0_10px_-2px_rgba(249,115,22,0.5)] cursor-default hover:scale-105 transition-transform"
|
||||
<motion.div
|
||||
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" />
|
||||
<span>{streak}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
<button
|
||||
ref={ref}
|
||||
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",
|
||||
"border border-border bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
|
||||
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
|
||||
|
||||
@@ -6,7 +6,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
<div
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -28,30 +28,17 @@ export const DEMO_USERS = {
|
||||
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_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
|
||||
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
|
||||
// IDs considered "demo" accounts — demo switcher only shows for these
|
||||
export const DEMO_IDS = new Set([
|
||||
...Object.values(DEMO_USERS).map(u => u.id),
|
||||
"usr_01", "usr_02", "usr_03", "usr_04", "usr_05"
|
||||
]);
|
||||
|
||||
/** Returns true if the userId 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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -147,88 +134,6 @@ async function fetchSettings(): Promise<any> {
|
||||
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() {
|
||||
const [userId, setUserIdState] = useState<string>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -237,32 +142,9 @@ export function useActiveUser() {
|
||||
return DEFAULT_USER_ID;
|
||||
});
|
||||
|
||||
const [user, setUser] = useState<UserProfile | null>(() => {
|
||||
const currentId = (typeof window !== "undefined" ? localStorage.getItem("activeUserId") : null) || DEFAULT_USER_ID;
|
||||
if (cachedUserProfile && cachedUserProfile.id === currentId) {
|
||||
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 [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [globalSettings, setGlobalSettings] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 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);
|
||||
} catch (err: any) {
|
||||
failCountRef.current += 1;
|
||||
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
|
||||
} finally {
|
||||
if (!isPoll) setLoading(false);
|
||||
@@ -361,23 +221,7 @@ export function useActiveUser() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
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-switched", handleSwitched);
|
||||
|
||||
// Poll every 30s (less aggressive, kinder on backend)
|
||||
const interval = setInterval(() => {
|
||||
@@ -386,7 +230,6 @@ export function useActiveUser() {
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pragativeer-user-updated", handleUpdate);
|
||||
window.removeEventListener("pragativeer-user-switched", handleSwitched);
|
||||
clearInterval(interval);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -417,13 +260,11 @@ export function useActiveUser() {
|
||||
// Update refs and state before awaiting network
|
||||
activeUserIdRef.current = actualUserId;
|
||||
setUserIdState(actualUserId);
|
||||
const demo = getKnownDemoProfile(actualUserId);
|
||||
cachedUserProfile = demo;
|
||||
setUser(demo);
|
||||
setUser(null); // Clear old user immediately so stale data isn't shown
|
||||
|
||||
// Notify listeners immediately of account switch
|
||||
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
|
||||
@@ -431,10 +272,6 @@ export function useActiveUser() {
|
||||
|
||||
if (newUserRes && newUserRes.ok) {
|
||||
const data = await newUserRes.json();
|
||||
cachedUserProfile = data;
|
||||
if (typeof window !== "undefined") {
|
||||
try { localStorage.setItem("pragativeer_cached_user", JSON.stringify(data)); } catch {}
|
||||
}
|
||||
setUser(data);
|
||||
if (typeof window !== "undefined") {
|
||||
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