mirror of
https://github.com/th30d4y/OpenLearnX.git
synced 2026-05-26 19:26:33 +00:00
update error
This commit is contained in:
+998
-209
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class UserStats(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
total_points: int = 0
|
||||||
|
current_streak: int = 0
|
||||||
|
longest_streak: int = 0
|
||||||
|
rank: int = 999
|
||||||
|
total_courses: int = 0
|
||||||
|
completed_courses: int = 0
|
||||||
|
total_quizzes: int = 0
|
||||||
|
total_coding_challenges: int = 0
|
||||||
|
created_at: datetime = datetime.now()
|
||||||
|
updated_at: datetime = datetime.now()
|
||||||
|
|
||||||
|
class UserProfile(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
bio: str = ""
|
||||||
|
profile_pic: str = "/default-avatar.png"
|
||||||
|
join_date: datetime = datetime.now()
|
||||||
|
badges: List[str] = []
|
||||||
|
social_links: Dict[str, str] = {}
|
||||||
|
|
||||||
|
class ActivityRecord(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
activity_type: str # 'course', 'quiz', 'coding', 'achievement'
|
||||||
|
title: str
|
||||||
|
score: Optional[int] = None
|
||||||
|
points_earned: int = 0
|
||||||
|
date: datetime = datetime.now()
|
||||||
|
blockchain_hash: Optional[str] = None
|
||||||
|
|
||||||
|
class BlockchainTransaction(BaseModel):
|
||||||
|
tx_hash: str
|
||||||
|
amount: float
|
||||||
|
transaction_type: str # 'reward', 'certificate', 'achievement'
|
||||||
|
description: str
|
||||||
|
timestamp: datetime = datetime.now()
|
||||||
|
confirmed: bool = False
|
||||||
+138
-68
@@ -1,90 +1,160 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify
|
||||||
import jwt
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import secrets
|
from pymongo import MongoClient
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import jwt
|
||||||
|
import logging
|
||||||
|
from eth_account.messages import encode_defunct
|
||||||
|
from web3 import Web3
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__)
|
bp = Blueprint('auth', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Store nonces temporarily (in production, use Redis or database)
|
# MongoDB connection
|
||||||
nonces = {}
|
mongo_uri = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/')
|
||||||
|
client = MongoClient(mongo_uri)
|
||||||
|
db = client.openlearnx
|
||||||
|
|
||||||
@bp.route("/nonce", methods=["POST"])
|
# JWT secret
|
||||||
|
JWT_SECRET = os.getenv('JWT_SECRET', 'your-secret-key-here')
|
||||||
|
|
||||||
|
@bp.route('/nonce', methods=['POST', 'OPTIONS'])
|
||||||
def get_nonce():
|
def get_nonce():
|
||||||
data = request.get_json()
|
"""Generate nonce for MetaMask authentication"""
|
||||||
wallet_address = data.get("wallet_address")
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
if not wallet_address:
|
|
||||||
return jsonify({"error": "wallet_address is required"}), 400
|
|
||||||
|
|
||||||
# Generate nonce
|
|
||||||
nonce = secrets.token_hex(16)
|
|
||||||
message = f"Sign this message to authenticate with OpenLearnX: {nonce}"
|
|
||||||
|
|
||||||
# Store nonce for this wallet address
|
|
||||||
nonces[wallet_address.lower()] = nonce
|
|
||||||
|
|
||||||
return jsonify({"nonce": nonce, "message": message})
|
|
||||||
|
|
||||||
@bp.route("/verify", methods=["POST"])
|
|
||||||
def verify_signature():
|
|
||||||
data = request.get_json()
|
|
||||||
wallet_address = data.get("wallet_address", "").lower()
|
|
||||||
signature = data.get("signature")
|
|
||||||
message = data.get("message")
|
|
||||||
|
|
||||||
if not all([wallet_address, signature, message]):
|
|
||||||
return jsonify({"error": "Missing required fields"}), 400
|
|
||||||
|
|
||||||
# Verify nonce
|
|
||||||
stored_nonce = nonces.get(wallet_address)
|
|
||||||
if not stored_nonce or stored_nonce not in message:
|
|
||||||
return jsonify({"error": "Invalid nonce"}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
web3_service = current_app.config["WEB3_SERVICE"]
|
data = request.get_json()
|
||||||
|
wallet_address = data.get('wallet_address')
|
||||||
|
|
||||||
# Verify signature
|
if not wallet_address:
|
||||||
if not web3_service.verify_signature(wallet_address, message, signature):
|
return jsonify({
|
||||||
return jsonify({"error": "Invalid signature"}), 401
|
"success": False,
|
||||||
|
"error": "Wallet address required"
|
||||||
|
}), 400
|
||||||
|
|
||||||
# For now, create a mock user without database operations
|
# Generate unique nonce
|
||||||
# This bypasses the async MongoDB issues entirely
|
nonce = str(uuid.uuid4())
|
||||||
user = {
|
timestamp = datetime.now().isoformat()
|
||||||
"_id": f"user_{wallet_address}",
|
|
||||||
"wallet_address": wallet_address,
|
|
||||||
"created_at": datetime.utcnow(),
|
|
||||||
"total_tests": 0,
|
|
||||||
"certificates": []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create JWT token
|
# Create message to sign
|
||||||
|
message = f"Sign this message to authenticate with OpenLearnX:\n\nNonce: {nonce}\nTimestamp: {timestamp}\nAddress: {wallet_address}"
|
||||||
|
|
||||||
|
logger.info(f"🔐 Generated nonce for wallet: {wallet_address}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"nonce": nonce,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error generating nonce: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@bp.route('/verify', methods=['POST', 'OPTIONS'])
|
||||||
|
def verify_signature():
|
||||||
|
"""Verify MetaMask signature and authenticate user"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
wallet_address = data.get('wallet_address')
|
||||||
|
signature = data.get('signature')
|
||||||
|
message = data.get('message')
|
||||||
|
|
||||||
|
if not all([wallet_address, signature, message]):
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Wallet address, signature, and message are required"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Verify the signature
|
||||||
|
try:
|
||||||
|
# Create the message hash that was signed
|
||||||
|
message_hash = encode_defunct(text=message)
|
||||||
|
|
||||||
|
# Recover the address from the signature
|
||||||
|
w3 = Web3()
|
||||||
|
recovered_address = w3.eth.account.recover_message(message_hash, signature=signature)
|
||||||
|
|
||||||
|
# Check if recovered address matches the claimed address
|
||||||
|
if recovered_address.lower() != wallet_address.lower():
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Signature verification failed"
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Signature verification error: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Invalid signature"
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
# Check if user exists, create if not
|
||||||
|
user = db.users.find_one({"wallet_address": wallet_address.lower()})
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# Create new user
|
||||||
|
user = {
|
||||||
|
"wallet_address": wallet_address.lower(),
|
||||||
|
"created_at": datetime.now(),
|
||||||
|
"last_login": datetime.now(),
|
||||||
|
"login_count": 1
|
||||||
|
}
|
||||||
|
result = db.users.insert_one(user)
|
||||||
|
user["_id"] = str(result.inserted_id)
|
||||||
|
logger.info(f"✅ Created new user: {wallet_address}")
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
db.users.update_one(
|
||||||
|
{"wallet_address": wallet_address.lower()},
|
||||||
|
{
|
||||||
|
"$set": {"last_login": datetime.now()},
|
||||||
|
"$inc": {"login_count": 1}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user["_id"] = str(user["_id"])
|
||||||
|
logger.info(f"✅ Updated existing user: {wallet_address}")
|
||||||
|
|
||||||
|
# Generate JWT token
|
||||||
token_payload = {
|
token_payload = {
|
||||||
"user_id": str(user["_id"]),
|
"user_id": user["wallet_address"],
|
||||||
"wallet_address": wallet_address,
|
"wallet_address": user["wallet_address"],
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
"exp": datetime.utcnow() + timedelta(days=7)
|
"exp": datetime.utcnow() + timedelta(days=7)
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
token = jwt.encode(token_payload, JWT_SECRET, algorithm="HS256")
|
||||||
token_payload,
|
|
||||||
current_app.config["SECRET_KEY"],
|
|
||||||
algorithm="HS256"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clean up nonce
|
# Prepare user data for response
|
||||||
if wallet_address in nonces:
|
user_response = {
|
||||||
del nonces[wallet_address]
|
"id": user["wallet_address"],
|
||||||
|
"wallet_address": user["wallet_address"],
|
||||||
|
"created_at": user["created_at"].isoformat() if isinstance(user["created_at"], datetime) else str(user["created_at"]),
|
||||||
|
"last_login": user["last_login"].isoformat() if isinstance(user["last_login"], datetime) else str(user["last_login"])
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"✅ Authentication successful for: {wallet_address}")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": {
|
"user": user_response,
|
||||||
"id": str(user["_id"]),
|
"message": "Authentication successful"
|
||||||
"wallet_address": user["wallet_address"],
|
|
||||||
"total_tests": user.get("total_tests", 0),
|
|
||||||
"certificates": len(user.get("certificates", []))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Authentication error: {str(e)}")
|
logger.error(f"❌ Error verifying signature: {str(e)}")
|
||||||
return jsonify({"error": "Authentication failed"}), 500
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|||||||
+816
-29
@@ -1,40 +1,827 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify
|
||||||
import jwt
|
from datetime import datetime, timedelta
|
||||||
|
from pymongo import MongoClient
|
||||||
|
import os
|
||||||
|
from bson import ObjectId
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import jwt # ✅ Add proper JWT import at top level
|
||||||
|
|
||||||
bp = Blueprint('dashboard', __name__)
|
bp = Blueprint('dashboard', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# MongoDB connection
|
||||||
|
mongo_uri = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/')
|
||||||
|
client = MongoClient(mongo_uri)
|
||||||
|
db = client.openlearnx
|
||||||
|
|
||||||
|
def verify_wallet_authentication():
|
||||||
|
"""✅ FIXED: Verify MetaMask wallet authentication with proper JWT handling"""
|
||||||
|
user_id = None
|
||||||
|
wallet_address = None
|
||||||
|
|
||||||
|
# ✅ Try JWT token first with proper algorithm specification
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
try:
|
||||||
|
token = auth_header.split(' ')[1]
|
||||||
|
# ✅ FIXED: Add algorithms parameter to fix JWT decode error
|
||||||
|
decoded = jwt.decode(
|
||||||
|
token,
|
||||||
|
options={"verify_signature": False}, # For development
|
||||||
|
algorithms=["HS256", "RS256"] # This fixes the JWT error
|
||||||
|
)
|
||||||
|
user_id = decoded.get('sub') or decoded.get('user_id') or decoded.get('uid') or decoded.get('wallet_address')
|
||||||
|
wallet_address = decoded.get('wallet_address') or user_id
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
logger.info(f"✅ JWT authentication verified: {user_id}")
|
||||||
|
return user_id, wallet_address
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ JWT decode failed: {e}")
|
||||||
|
|
||||||
|
# ✅ Enhanced fallback: Try multiple header sources and request data
|
||||||
|
wallet_address = (
|
||||||
|
request.headers.get('X-Wallet-Address') or
|
||||||
|
request.headers.get('X-User-ID') or
|
||||||
|
request.args.get('wallet_address') or
|
||||||
|
(request.json.get('wallet_address') if request.is_json else None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if wallet_address:
|
||||||
|
user_id = wallet_address
|
||||||
|
logger.info(f"✅ Wallet address authentication verified: {user_id}")
|
||||||
|
return user_id, wallet_address
|
||||||
|
|
||||||
|
# ✅ Enhanced debug logging for troubleshooting
|
||||||
|
logger.error("❌ No MetaMask wallet authentication found")
|
||||||
|
logger.debug(f"Auth header: {auth_header[:50]}...")
|
||||||
|
logger.debug(f"Headers: X-Wallet-Address={request.headers.get('X-Wallet-Address')}, X-User-ID={request.headers.get('X-User-ID')}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
@bp.route('/comprehensive-stats', methods=['GET', 'OPTIONS'])
|
||||||
|
def get_comprehensive_stats():
|
||||||
|
"""Get ONLY REAL data from MongoDB - NO FAKE/DEMO DATA"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
def get_user_from_token(token):
|
|
||||||
"""Extract user from JWT token"""
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
# ✅ VERIFY WALLET AUTHENTICATION
|
||||||
token,
|
user_id, wallet_address = verify_wallet_authentication()
|
||||||
current_app.config['SECRET_KEY'],
|
if not user_id:
|
||||||
algorithms=['HS256']
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "MetaMask wallet authentication required",
|
||||||
|
"auth_required": True,
|
||||||
|
"debug_hint": "Ensure JWT token is sent in Authorization header or wallet address in X-Wallet-Address header"
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
logger.info(f"📊 Fetching REAL MongoDB data for wallet: {user_id}")
|
||||||
|
|
||||||
|
# Database connection check
|
||||||
|
try:
|
||||||
|
db.command('ping')
|
||||||
|
logger.info("✅ Database connection verified")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Database connection failed: {e}")
|
||||||
|
raise Exception("Database connection failed")
|
||||||
|
|
||||||
|
# ✅ GET USER PROFILE (REAL DATA ONLY)
|
||||||
|
user_profile = db.user_profiles.find_one({"user_id": user_id})
|
||||||
|
if not user_profile:
|
||||||
|
# ✅ Create basic profile for new users to prevent loops
|
||||||
|
basic_profile = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"wallet_address": wallet_address,
|
||||||
|
"display_name": None,
|
||||||
|
"username_set": False,
|
||||||
|
"avatar_url": f"https://api.dicebear.com/7.x/avataaars/svg?seed={user_id}",
|
||||||
|
"created_at": datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.user_profiles.insert_one(basic_profile.copy())
|
||||||
|
logger.info(f"✅ Created basic profile for new user: {user_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Failed to create user profile: {e}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"username_required": True,
|
||||||
|
"user_profile": basic_profile,
|
||||||
|
"message": "Please set your username to continue",
|
||||||
|
"user_id": user_id,
|
||||||
|
"wallet_address": wallet_address
|
||||||
|
})
|
||||||
|
|
||||||
|
# Convert ObjectId to string for JSON serialization
|
||||||
|
if '_id' in user_profile:
|
||||||
|
user_profile['_id'] = str(user_profile['_id'])
|
||||||
|
|
||||||
|
# Check if username is set
|
||||||
|
if not user_profile.get('display_name') or not user_profile.get('username_set', False):
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"username_required": True,
|
||||||
|
"user_profile": user_profile,
|
||||||
|
"message": "Please set your username to continue",
|
||||||
|
"user_id": user_id,
|
||||||
|
"wallet_address": wallet_address
|
||||||
|
})
|
||||||
|
|
||||||
|
# ✅ FETCH ONLY REAL DATA FROM MONGODB
|
||||||
|
user_stats = db.user_stats.find_one({"user_id": user_id})
|
||||||
|
courses = list(db.user_courses.find({"user_id": user_id}))
|
||||||
|
quizzes = list(db.user_quizzes.find({"user_id": user_id}))
|
||||||
|
coding_submissions = list(db.user_submissions.find({"user_id": user_id}))
|
||||||
|
blockchain_data = db.user_blockchain.find_one({"user_id": user_id})
|
||||||
|
achievements = list(db.user_achievements.find({"user_id": user_id}))
|
||||||
|
|
||||||
|
# Convert ObjectIds to strings for JSON serialization
|
||||||
|
for collection in [courses, quizzes, coding_submissions, achievements]:
|
||||||
|
for item in collection:
|
||||||
|
if '_id' in item:
|
||||||
|
item['_id'] = str(item['_id'])
|
||||||
|
|
||||||
|
if user_stats and '_id' in user_stats:
|
||||||
|
user_stats['_id'] = str(user_stats['_id'])
|
||||||
|
if blockchain_data and '_id' in blockchain_data:
|
||||||
|
blockchain_data['_id'] = str(blockchain_data['_id'])
|
||||||
|
|
||||||
|
logger.info(f"📊 REAL MongoDB data found:")
|
||||||
|
logger.info(f" - User stats: {'✅' if user_stats else '❌'}")
|
||||||
|
logger.info(f" - Courses: {len(courses)}")
|
||||||
|
logger.info(f" - Quizzes: {len(quizzes)}")
|
||||||
|
logger.info(f" - Coding submissions: {len(coding_submissions)}")
|
||||||
|
logger.info(f" - Achievements: {len(achievements)}")
|
||||||
|
|
||||||
|
# ✅ IF NO REAL DATA EXISTS, RETURN EMPTY STATE (NO FAKE DATA)
|
||||||
|
if not user_stats and not courses and not quizzes and not coding_submissions and not achievements:
|
||||||
|
logger.info(f"📊 No real learning data found for wallet {user_id}")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": get_empty_stats(wallet_address),
|
||||||
|
"user_profile": user_profile,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"user_id": user_id,
|
||||||
|
"wallet_address": wallet_address,
|
||||||
|
"data_source": "empty_real_data",
|
||||||
|
"message": "No learning data found. Start learning to see your real progress!"
|
||||||
|
})
|
||||||
|
|
||||||
|
# ✅ CALCULATE STATISTICS FROM ONLY REAL DATA
|
||||||
|
current_time = datetime.now()
|
||||||
|
|
||||||
|
# Real calculations (no fake data)
|
||||||
|
total_xp = calculate_real_total_xp(courses, quizzes, coding_submissions, achievements)
|
||||||
|
courses_completed = len([c for c in courses if c.get('completed', False)])
|
||||||
|
coding_problems_solved = len(coding_submissions)
|
||||||
|
quiz_accuracy = calculate_real_quiz_accuracy(quizzes)
|
||||||
|
coding_streak = calculate_real_coding_streak(coding_submissions)
|
||||||
|
longest_streak = user_stats.get('longest_streak', coding_streak) if user_stats else coding_streak
|
||||||
|
weekly_activity = calculate_real_weekly_activity(courses, quizzes, coding_submissions)
|
||||||
|
skill_levels = calculate_real_skill_levels(courses, quizzes, coding_submissions)
|
||||||
|
|
||||||
|
# ✅ REAL COMPREHENSIVE STATISTICS
|
||||||
|
comprehensive_stats = {
|
||||||
|
"total_xp": total_xp,
|
||||||
|
"courses_completed": courses_completed,
|
||||||
|
"coding_problems_solved": coding_problems_solved,
|
||||||
|
"quiz_accuracy": quiz_accuracy,
|
||||||
|
"coding_streak": coding_streak,
|
||||||
|
"longest_streak": max(longest_streak, coding_streak),
|
||||||
|
"total_courses": len(courses),
|
||||||
|
"total_quizzes": len(quizzes),
|
||||||
|
"global_rank": calculate_real_global_rank(user_stats, user_id) if user_stats else 0,
|
||||||
|
"weekly_activity": weekly_activity,
|
||||||
|
"monthly_goals": {
|
||||||
|
"target": user_stats.get('monthly_target', 0) if user_stats else 0,
|
||||||
|
"completed": calculate_real_monthly_completed(courses, quizzes, coding_submissions, current_time)
|
||||||
|
},
|
||||||
|
"blockchain": {
|
||||||
|
"wallet_connected": True,
|
||||||
|
"wallet_address": wallet_address,
|
||||||
|
"total_earned": blockchain_data.get('total_earned', 0) if blockchain_data else 0,
|
||||||
|
"transactions": len(blockchain_data.get('transactions', [])) if blockchain_data else 0,
|
||||||
|
"certificates": len([a for a in achievements if a.get('type') == 'certificate']),
|
||||||
|
"verified_achievements": len([a for a in achievements if a.get('blockchain_verified', False)])
|
||||||
|
},
|
||||||
|
"learning_analytics": {
|
||||||
|
"time_spent_hours": calculate_real_time_spent(courses, quizzes, coding_submissions),
|
||||||
|
"average_session_minutes": user_stats.get('avg_session_minutes', 0) if user_stats else 0,
|
||||||
|
"completion_rate": calculate_real_completion_rate(courses, quizzes),
|
||||||
|
"favorite_topics": calculate_real_favorite_topics(courses, quizzes),
|
||||||
|
"skill_levels": skill_levels
|
||||||
|
},
|
||||||
|
"recent_achievements": [
|
||||||
|
{
|
||||||
|
"id": str(a.get('_id', uuid.uuid4())),
|
||||||
|
"title": a.get('title', ''),
|
||||||
|
"description": a.get('description', ''),
|
||||||
|
"earned_at": a.get('earned_at', current_time).isoformat() if isinstance(a.get('earned_at'), datetime) else str(a.get('earned_at', current_time.isoformat())),
|
||||||
|
"points": a.get('points', 0),
|
||||||
|
"rarity": a.get('rarity', 'common')
|
||||||
|
} for a in achievements[-5:] # Only last 5 REAL achievements
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"✅ REAL statistics calculated for wallet {user_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": comprehensive_stats,
|
||||||
|
"user_profile": user_profile,
|
||||||
|
"username_required": False,
|
||||||
|
"timestamp": current_time.isoformat(),
|
||||||
|
"user_id": user_id,
|
||||||
|
"wallet_address": wallet_address,
|
||||||
|
"data_source": "pure_mongodb_data", # Indicates real data only
|
||||||
|
"collections_count": {
|
||||||
|
"courses": len(courses),
|
||||||
|
"quizzes": len(quizzes),
|
||||||
|
"coding_submissions": len(coding_submissions),
|
||||||
|
"achievements": len(achievements)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching real stats: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"❌ Full traceback: {traceback.format_exc()}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"data_source": "error"
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@bp.route('/recent-activity', methods=['GET', 'OPTIONS'])
|
||||||
|
def get_recent_activity():
|
||||||
|
"""Get ONLY REAL recent activity from MongoDB"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id, wallet_address = verify_wallet_authentication()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "MetaMask wallet authentication required",
|
||||||
|
"auth_required": True
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
logger.info(f"📋 Fetching REAL activity for wallet: {user_id}")
|
||||||
|
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
# ✅ ONLY REAL ACTIVITY SOURCES
|
||||||
|
activity_sources = [
|
||||||
|
(db.user_courses, "course", "Course Activity", "completed_at"),
|
||||||
|
(db.user_quizzes, "quiz", "Quiz Activity", "completed_at"),
|
||||||
|
(db.user_submissions, "coding", "Coding Challenge", "submitted_at"),
|
||||||
|
(db.user_achievements, "achievement", "Achievement", "earned_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for collection, activity_type, default_title, date_field in activity_sources:
|
||||||
|
try:
|
||||||
|
# Get ONLY real MongoDB data
|
||||||
|
recent_items = list(collection.find(
|
||||||
|
{"user_id": user_id}
|
||||||
|
).sort(date_field, -1).limit(20))
|
||||||
|
|
||||||
|
for item in recent_items:
|
||||||
|
completed_at = item.get(date_field, datetime.now())
|
||||||
|
|
||||||
|
if isinstance(completed_at, str):
|
||||||
|
try:
|
||||||
|
completed_at = datetime.fromisoformat(completed_at)
|
||||||
|
except:
|
||||||
|
completed_at = datetime.now()
|
||||||
|
elif not isinstance(completed_at, datetime):
|
||||||
|
completed_at = datetime.now()
|
||||||
|
|
||||||
|
activities.append({
|
||||||
|
"id": str(item.get('_id', uuid.uuid4())),
|
||||||
|
"type": activity_type,
|
||||||
|
"title": item.get('title', item.get('name', default_title)),
|
||||||
|
"description": format_real_activity_description(item, activity_type),
|
||||||
|
"completed_at": completed_at.isoformat(),
|
||||||
|
"points_earned": item.get('points', item.get('points_earned', 0)),
|
||||||
|
"success_rate": item.get('score', item.get('completion_percentage', 0)),
|
||||||
|
"difficulty": item.get('difficulty', ''),
|
||||||
|
"blockchain_verified": item.get('blockchain_verified', False)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Failed to fetch {activity_type} activities: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by completion date
|
||||||
|
activities.sort(key=lambda x: x['completed_at'], reverse=True)
|
||||||
|
|
||||||
|
logger.info(f"✅ Found {len(activities)} REAL activities for wallet {user_id}")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": activities,
|
||||||
|
"total_count": len(activities),
|
||||||
|
"data_source": "pure_mongodb_data"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching real activity: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@bp.route('/global-leaderboard', methods=['GET', 'OPTIONS'])
|
||||||
|
def get_global_leaderboard():
|
||||||
|
"""Get ONLY REAL global leaderboard from MongoDB - NO AUTH REQUIRED"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("🏆 Fetching REAL global leaderboard from MongoDB")
|
||||||
|
|
||||||
|
# ✅ GET ONLY REAL USER STATS - NO AUTH REQUIRED FOR LEADERBOARD
|
||||||
|
user_stats_cursor = db.user_stats.find({}).sort("total_xp", -1).limit(100)
|
||||||
|
user_stats_list = list(user_stats_cursor)
|
||||||
|
|
||||||
|
logger.info(f"📊 Found {len(user_stats_list)} REAL users in MongoDB")
|
||||||
|
|
||||||
|
if not user_stats_list:
|
||||||
|
logger.info("📊 No real users found in MongoDB")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": [],
|
||||||
|
"message": "No users found. Be the first to start learning!"
|
||||||
|
})
|
||||||
|
|
||||||
|
leaderboard = []
|
||||||
|
for rank, user_stat in enumerate(user_stats_list, 1):
|
||||||
|
# Get real user profile
|
||||||
|
user_profile = db.user_profiles.find_one({"user_id": user_stat["user_id"]})
|
||||||
|
|
||||||
|
# Real user data only
|
||||||
|
username = "Anonymous User"
|
||||||
|
avatar = f"https://api.dicebear.com/7.x/avataaars/svg?seed={user_stat['user_id']}"
|
||||||
|
display_name = None
|
||||||
|
badges = []
|
||||||
|
|
||||||
|
if user_profile:
|
||||||
|
display_name = user_profile.get("display_name")
|
||||||
|
username = display_name or f"User_{user_stat['user_id'][-6:]}"
|
||||||
|
avatar = user_profile.get("avatar_url", avatar)
|
||||||
|
badges = user_profile.get("badges", [])
|
||||||
|
|
||||||
|
leaderboard.append({
|
||||||
|
"rank": rank,
|
||||||
|
"user_id": user_stat["user_id"],
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"total_xp": user_stat.get("total_xp", 0),
|
||||||
|
"streak": user_stat.get("current_streak", 0),
|
||||||
|
"avatar": avatar,
|
||||||
|
"badges": badges,
|
||||||
|
"wallet_address": user_profile.get("wallet_address") if user_profile else None
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"✅ REAL leaderboard generated with {len(leaderboard)} users")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": leaderboard,
|
||||||
|
"data_source": "pure_mongodb_data"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching real leaderboard: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# ✅ Add username setup endpoints to prevent frontend errors
|
||||||
|
@bp.route('/set-username', methods=['POST', 'OPTIONS'])
|
||||||
|
def set_username():
|
||||||
|
"""Set username for authenticated user"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id, wallet_address = verify_wallet_authentication()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Authentication required",
|
||||||
|
"auth_required": True
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
|
||||||
|
if not username or len(username) < 3:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Username must be at least 3 characters long"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Check if username already exists
|
||||||
|
existing_user = db.user_profiles.find_one({
|
||||||
|
"display_name": username,
|
||||||
|
"user_id": {"$ne": user_id}
|
||||||
|
})
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Username already taken"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Update or create user profile
|
||||||
|
profile_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"wallet_address": wallet_address,
|
||||||
|
"display_name": username,
|
||||||
|
"username_set": True,
|
||||||
|
"updated_at": datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
result = db.user_profiles.update_one(
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"$set": profile_data, "$setOnInsert": {"created_at": datetime.now()}},
|
||||||
|
upsert=True
|
||||||
)
|
)
|
||||||
return payload['user_id']
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@bp.route('/student/<user_id>', methods=['GET'])
|
# Get updated profile
|
||||||
async def get_student_dashboard(user_id):
|
updated_profile = db.user_profiles.find_one({"user_id": user_id})
|
||||||
"""Get comprehensive student dashboard"""
|
if updated_profile and '_id' in updated_profile:
|
||||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
updated_profile['_id'] = str(updated_profile['_id'])
|
||||||
token_user_id = get_user_from_token(token)
|
|
||||||
|
|
||||||
if not token_user_id or token_user_id != user_id:
|
logger.info(f"✅ Username set for user {user_id}: {username}")
|
||||||
return jsonify({"error": "Unauthorized"}), 403
|
|
||||||
|
|
||||||
mongo_service = current_app.config['MONGO_SERVICE']
|
return jsonify({
|
||||||
analytics = await mongo_service.get_user_analytics(user_id)
|
"success": True,
|
||||||
|
"message": f"Username '{username}' set successfully",
|
||||||
|
"profile": updated_profile
|
||||||
|
})
|
||||||
|
|
||||||
return jsonify(analytics or {
|
except Exception as e:
|
||||||
"user_info": {"id": user_id},
|
logger.error(f"❌ Error setting username: {str(e)}")
|
||||||
"overview": {
|
return jsonify({
|
||||||
"total_tests": 0,
|
"success": False,
|
||||||
"completed_tests": 0,
|
"error": str(e)
|
||||||
"average_score": 0,
|
}), 500
|
||||||
"certificates_earned": 0
|
|
||||||
|
@bp.route('/update-profile', methods=['POST', 'OPTIONS'])
|
||||||
|
def update_profile():
|
||||||
|
"""Update user profile (fallback for username setup)"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id, wallet_address = verify_wallet_authentication()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Authentication required",
|
||||||
|
"auth_required": True
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
display_name = data.get('display_name', '').strip()
|
||||||
|
|
||||||
|
if not display_name:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Display name is required"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Update profile
|
||||||
|
profile_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"wallet_address": wallet_address,
|
||||||
|
"display_name": display_name,
|
||||||
|
"username_set": True,
|
||||||
|
"updated_at": datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
db.user_profiles.update_one(
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"$set": profile_data, "$setOnInsert": {"created_at": datetime.now()}},
|
||||||
|
upsert=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get updated profile
|
||||||
|
updated_profile = db.user_profiles.find_one({"user_id": user_id})
|
||||||
|
if updated_profile and '_id' in updated_profile:
|
||||||
|
updated_profile['_id'] = str(updated_profile['_id'])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": "Profile updated successfully",
|
||||||
|
"profile": updated_profile
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating profile: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# ✅ REAL DATA CALCULATION FUNCTIONS (NO FAKE DATA)
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def get_empty_stats(wallet_address=None):
|
||||||
|
"""Return empty stats structure for users with no data"""
|
||||||
|
return {
|
||||||
|
"total_xp": 0,
|
||||||
|
"courses_completed": 0,
|
||||||
|
"coding_problems_solved": 0,
|
||||||
|
"quiz_accuracy": 0,
|
||||||
|
"coding_streak": 0,
|
||||||
|
"longest_streak": 0,
|
||||||
|
"total_courses": 0,
|
||||||
|
"total_quizzes": 0,
|
||||||
|
"global_rank": 0,
|
||||||
|
"weekly_activity": [0, 0, 0, 0, 0, 0, 0],
|
||||||
|
"monthly_goals": {"target": 0, "completed": 0},
|
||||||
|
"blockchain": {
|
||||||
|
"wallet_connected": True,
|
||||||
|
"wallet_address": wallet_address,
|
||||||
|
"total_earned": 0,
|
||||||
|
"transactions": 0,
|
||||||
|
"certificates": 0,
|
||||||
|
"verified_achievements": 0
|
||||||
},
|
},
|
||||||
"subject_breakdown": {},
|
"learning_analytics": {
|
||||||
"recent_activity": []
|
"time_spent_hours": 0,
|
||||||
|
"average_session_minutes": 0,
|
||||||
|
"completion_rate": 0,
|
||||||
|
"favorite_topics": [],
|
||||||
|
"skill_levels": {
|
||||||
|
'Frontend': 0,
|
||||||
|
'Backend': 0,
|
||||||
|
'Blockchain': 0,
|
||||||
|
'AI/ML': 0,
|
||||||
|
'DevOps': 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recent_achievements": []
|
||||||
|
}
|
||||||
|
|
||||||
|
def calculate_real_total_xp(courses, quizzes, submissions, achievements):
|
||||||
|
"""Calculate total XP from ONLY real MongoDB data"""
|
||||||
|
course_xp = sum([c.get('points', 0) for c in courses if c.get('completed', False)])
|
||||||
|
quiz_xp = sum([q.get('points', 0) for q in quizzes])
|
||||||
|
coding_xp = sum([s.get('points_earned', 0) for s in submissions])
|
||||||
|
achievement_xp = sum([a.get('points', 0) for a in achievements])
|
||||||
|
|
||||||
|
total = course_xp + quiz_xp + coding_xp + achievement_xp
|
||||||
|
logger.info(f"📊 Real XP calculation: courses={course_xp}, quizzes={quiz_xp}, coding={coding_xp}, achievements={achievement_xp}, total={total}")
|
||||||
|
return total
|
||||||
|
|
||||||
|
def calculate_real_coding_streak(submissions):
|
||||||
|
"""Calculate coding streak from ONLY real submissions"""
|
||||||
|
if not submissions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sorted_submissions = sorted(submissions, key=lambda x: x.get('submitted_at', datetime.min), reverse=True)
|
||||||
|
current_date = datetime.now().date()
|
||||||
|
streak = 0
|
||||||
|
checked_dates = set()
|
||||||
|
|
||||||
|
for submission in sorted_submissions:
|
||||||
|
submission_date = submission.get('submitted_at')
|
||||||
|
if isinstance(submission_date, str):
|
||||||
|
try:
|
||||||
|
submission_date = datetime.fromisoformat(submission_date).date()
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
elif isinstance(submission_date, datetime):
|
||||||
|
submission_date = submission_date.date()
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if submission_date in checked_dates:
|
||||||
|
continue
|
||||||
|
checked_dates.add(submission_date)
|
||||||
|
|
||||||
|
expected_date = current_date - timedelta(days=streak)
|
||||||
|
if submission_date == expected_date:
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"📊 Real coding streak calculated: {streak} days from {len(submissions)} submissions")
|
||||||
|
return streak
|
||||||
|
|
||||||
|
def calculate_real_weekly_activity(courses, quizzes, submissions):
|
||||||
|
"""Calculate weekly activity from ONLY real MongoDB data"""
|
||||||
|
current_date = datetime.now()
|
||||||
|
weekly_activity = []
|
||||||
|
|
||||||
|
for i in range(7):
|
||||||
|
day_start = current_date - timedelta(days=6-i)
|
||||||
|
day_start = day_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
activity_count = 0
|
||||||
|
|
||||||
|
# Count ONLY real activities
|
||||||
|
for course in courses:
|
||||||
|
completed_at = course.get('completed_at')
|
||||||
|
if completed_at and isinstance(completed_at, datetime) and day_start <= completed_at < day_end:
|
||||||
|
activity_count += 1
|
||||||
|
|
||||||
|
for quiz in quizzes:
|
||||||
|
completed_at = quiz.get('completed_at')
|
||||||
|
if completed_at and isinstance(completed_at, datetime) and day_start <= completed_at < day_end:
|
||||||
|
activity_count += 1
|
||||||
|
|
||||||
|
for submission in submissions:
|
||||||
|
submitted_at = submission.get('submitted_at')
|
||||||
|
if submitted_at and isinstance(submitted_at, datetime) and day_start <= submitted_at < day_end:
|
||||||
|
activity_count += 1
|
||||||
|
|
||||||
|
weekly_activity.append(activity_count)
|
||||||
|
|
||||||
|
logger.info(f"📊 Real weekly activity: {weekly_activity}")
|
||||||
|
return weekly_activity
|
||||||
|
|
||||||
|
def calculate_real_quiz_accuracy(quizzes):
|
||||||
|
"""Calculate quiz accuracy from ONLY real quiz data"""
|
||||||
|
if not quizzes:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
scores = [q.get('score', 0) for q in quizzes if q.get('score') is not None]
|
||||||
|
accuracy = sum(scores) / len(scores) if scores else 0
|
||||||
|
logger.info(f"📊 Real quiz accuracy: {accuracy}% from {len(quizzes)} quizzes")
|
||||||
|
return accuracy
|
||||||
|
|
||||||
|
def calculate_real_global_rank(user_stats, user_id):
|
||||||
|
"""Calculate global rank from ONLY real MongoDB data"""
|
||||||
|
if not user_stats:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
user_xp = user_stats.get('total_xp', 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
higher_ranked = db.user_stats.count_documents({"total_xp": {"$gt": user_xp}})
|
||||||
|
rank = higher_ranked + 1
|
||||||
|
logger.info(f"📊 Real global rank: {rank} (XP: {user_xp})")
|
||||||
|
return rank
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating real global rank: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def calculate_real_monthly_completed(courses, quizzes, submissions, current_time):
|
||||||
|
"""Calculate monthly completions from ONLY real data"""
|
||||||
|
current_month = current_time.month
|
||||||
|
current_year = current_time.year
|
||||||
|
completed = 0
|
||||||
|
|
||||||
|
for course in courses:
|
||||||
|
completed_at = course.get('completed_at')
|
||||||
|
if (completed_at and isinstance(completed_at, datetime) and
|
||||||
|
completed_at.month == current_month and completed_at.year == current_year):
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
for quiz in quizzes:
|
||||||
|
completed_at = quiz.get('completed_at')
|
||||||
|
if (completed_at and isinstance(completed_at, datetime) and
|
||||||
|
completed_at.month == current_month and completed_at.year == current_year):
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
for submission in submissions:
|
||||||
|
submitted_at = submission.get('submitted_at')
|
||||||
|
if (submitted_at and isinstance(submitted_at, datetime) and
|
||||||
|
submitted_at.month == current_month and submitted_at.year == current_year):
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
logger.info(f"📊 Real monthly completed: {completed} this month")
|
||||||
|
return completed
|
||||||
|
|
||||||
|
def calculate_real_skill_levels(courses, quizzes, submissions):
|
||||||
|
"""Calculate skill levels from ONLY real MongoDB data"""
|
||||||
|
skills = {'Frontend': 0, 'Backend': 0, 'Blockchain': 0, 'AI/ML': 0, 'DevOps': 0}
|
||||||
|
|
||||||
|
# Calculate from ONLY real course data
|
||||||
|
for course in courses:
|
||||||
|
if not course.get('completed', False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
topic = course.get('topic', '').lower()
|
||||||
|
points = course.get('points', 0)
|
||||||
|
|
||||||
|
if any(keyword in topic for keyword in ['react', 'frontend', 'css', 'html', 'javascript']):
|
||||||
|
skills['Frontend'] += points * 0.1
|
||||||
|
elif any(keyword in topic for keyword in ['backend', 'api', 'server', 'node', 'python']):
|
||||||
|
skills['Backend'] += points * 0.1
|
||||||
|
elif any(keyword in topic for keyword in ['blockchain', 'web3', 'smart', 'solidity']):
|
||||||
|
skills['Blockchain'] += points * 0.1
|
||||||
|
elif any(keyword in topic for keyword in ['ai', 'ml', 'machine', 'learning']):
|
||||||
|
skills['AI/ML'] += points * 0.1
|
||||||
|
elif any(keyword in topic for keyword in ['devops', 'docker', 'deploy']):
|
||||||
|
skills['DevOps'] += points * 0.1
|
||||||
|
|
||||||
|
# Calculate from ONLY real coding submissions
|
||||||
|
for submission in submissions:
|
||||||
|
language = submission.get('language', '').lower()
|
||||||
|
points = submission.get('points_earned', 0)
|
||||||
|
|
||||||
|
if language in ['javascript', 'typescript']:
|
||||||
|
skills['Frontend'] += points * 0.05
|
||||||
|
elif language in ['python', 'java']:
|
||||||
|
skills['Backend'] += points * 0.05
|
||||||
|
elif language in ['solidity']:
|
||||||
|
skills['Blockchain'] += points * 0.05
|
||||||
|
|
||||||
|
# Normalize to 0-100 scale
|
||||||
|
max_skill = max(skills.values()) if any(skills.values()) else 1
|
||||||
|
for skill in skills:
|
||||||
|
skills[skill] = min(100, int((skills[skill] / max_skill) * 100)) if max_skill > 0 else 0
|
||||||
|
|
||||||
|
logger.info(f"📊 Real skill levels: {skills}")
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def calculate_real_time_spent(courses, quizzes, submissions):
|
||||||
|
"""Calculate time spent from ONLY real data"""
|
||||||
|
completed_courses = [c for c in courses if c.get('completed', False)]
|
||||||
|
total_time = len(completed_courses) * 2 + len(quizzes) * 0.5 + len(submissions) * 1
|
||||||
|
logger.info(f"📊 Real time spent: {int(total_time)} hours")
|
||||||
|
return int(total_time)
|
||||||
|
|
||||||
|
def calculate_real_completion_rate(courses, quizzes):
|
||||||
|
"""Calculate completion rate from ONLY real data"""
|
||||||
|
total_started = len(courses)
|
||||||
|
if total_started == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
completed_courses = len([c for c in courses if c.get('completed', False)])
|
||||||
|
rate = (completed_courses / total_started * 100) if total_started > 0 else 0
|
||||||
|
logger.info(f"📊 Real completion rate: {rate}% ({completed_courses}/{total_started})")
|
||||||
|
return rate
|
||||||
|
|
||||||
|
def calculate_real_favorite_topics(courses, quizzes):
|
||||||
|
"""Calculate favorite topics from ONLY real data"""
|
||||||
|
topics = {}
|
||||||
|
|
||||||
|
for course in courses:
|
||||||
|
topic = course.get('topic', 'General')
|
||||||
|
if topic and topic != 'General':
|
||||||
|
weight = 2 if course.get('completed', False) else 1
|
||||||
|
topics[topic] = topics.get(topic, 0) + weight
|
||||||
|
|
||||||
|
for quiz in quizzes:
|
||||||
|
topic = quiz.get('topic', 'General')
|
||||||
|
if topic and topic != 'General':
|
||||||
|
topics[topic] = topics.get(topic, 0) + 1
|
||||||
|
|
||||||
|
sorted_topics = sorted(topics.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
favorite_topics = [topic for topic, count in sorted_topics[:3] if count > 0]
|
||||||
|
logger.info(f"📊 Real favorite topics: {favorite_topics}")
|
||||||
|
return favorite_topics
|
||||||
|
|
||||||
|
def format_real_activity_description(item, activity_type):
|
||||||
|
"""Format activity description from real data"""
|
||||||
|
if activity_type == "course":
|
||||||
|
return f"Completed: {item.get('description', 'Course module')}"
|
||||||
|
elif activity_type == "quiz":
|
||||||
|
score = item.get('score', 0)
|
||||||
|
return f"Quiz score: {score}%"
|
||||||
|
elif activity_type == "coding":
|
||||||
|
language = item.get('language', 'Unknown')
|
||||||
|
return f"Solved in {language}"
|
||||||
|
elif activity_type == "achievement":
|
||||||
|
return item.get('description', 'Achievement unlocked')
|
||||||
|
else:
|
||||||
|
return "Activity completed"
|
||||||
|
|
||||||
|
# ✅ Root route
|
||||||
|
@bp.route('/', methods=['GET'])
|
||||||
|
def dashboard_root():
|
||||||
|
"""MongoDB-Only Dashboard API"""
|
||||||
|
return jsonify({
|
||||||
|
"message": "OpenLearnX MongoDB-Only Dashboard API",
|
||||||
|
"version": "4.1.0-fixed-auth",
|
||||||
|
"features": [
|
||||||
|
"🎯 ONLY Real MongoDB Data",
|
||||||
|
"❌ NO Fake/Demo/Temp Data",
|
||||||
|
"🦊 MetaMask Wallet Authentication",
|
||||||
|
"👤 Real User Profiles with Custom Names",
|
||||||
|
"📊 Authentic Learning Analytics",
|
||||||
|
"🏆 Real Achievement System",
|
||||||
|
"🔗 Blockchain Verification",
|
||||||
|
"📈 Pure Progress Tracking",
|
||||||
|
"✅ Fixed JWT Authentication"
|
||||||
|
],
|
||||||
|
"data_policy": "100% Real MongoDB Data Only - No Artificial Content",
|
||||||
|
"endpoints": [
|
||||||
|
"/api/dashboard/comprehensive-stats",
|
||||||
|
"/api/dashboard/recent-activity",
|
||||||
|
"/api/dashboard/global-leaderboard",
|
||||||
|
"/api/dashboard/set-username",
|
||||||
|
"/api/dashboard/update-profile"
|
||||||
|
],
|
||||||
|
"authentication": "JWT Token in Authorization header OR Wallet address in X-Wallet-Address header"
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class DashboardService:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def calculate_user_rank(self, user_id: str) -> int:
|
||||||
|
"""Calculate user's global rank based on total points"""
|
||||||
|
try:
|
||||||
|
user_stats = self.db.user_stats.find_one({"user_id": user_id})
|
||||||
|
user_points = user_stats.get("total_points", 0) if user_stats else 0
|
||||||
|
|
||||||
|
# Count users with higher points
|
||||||
|
higher_ranked = self.db.user_stats.count_documents({
|
||||||
|
"total_points": {"$gt": user_points}
|
||||||
|
})
|
||||||
|
|
||||||
|
return higher_ranked + 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating user rank: {e}")
|
||||||
|
return 999
|
||||||
|
|
||||||
|
async def update_user_points(self, user_id: str, points_to_add: int, activity_type: str):
|
||||||
|
"""Add points to user's total and update rank"""
|
||||||
|
try:
|
||||||
|
# Update user stats
|
||||||
|
self.db.user_stats.update_one(
|
||||||
|
{"user_id": user_id},
|
||||||
|
{
|
||||||
|
"$inc": {"total_points": points_to_add},
|
||||||
|
"$set": {"last_updated": datetime.now()}
|
||||||
|
},
|
||||||
|
upsert=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recalculate rank
|
||||||
|
new_rank = await self.calculate_user_rank(user_id)
|
||||||
|
self.db.user_stats.update_one(
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"$set": {"rank": new_rank}}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Added {points_to_add} points to user {user_id} for {activity_type}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating user points: {e}")
|
||||||
|
|
||||||
|
def get_leaderboard(self, limit: int = 100) -> List[Dict]:
|
||||||
|
"""Get global leaderboard"""
|
||||||
|
try:
|
||||||
|
return list(self.db.user_stats.find(
|
||||||
|
{},
|
||||||
|
{"user_id": 1, "total_points": 1, "rank": 1}
|
||||||
|
).sort("total_points", -1).limit(limit))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching leaderboard: {e}")
|
||||||
|
return []
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useAuth } from "@/context/auth-context"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Wallet, Mail, Lock, Loader2, Shield, CheckCircle2, AlertCircle } from "lucide-react"
|
||||||
|
import { toast } from "react-hot-toast"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const {
|
||||||
|
connectWallet,
|
||||||
|
loginWithEmail,
|
||||||
|
isLoadingAuth,
|
||||||
|
walletConnected,
|
||||||
|
walletAddress,
|
||||||
|
firebaseUser,
|
||||||
|
authMethod
|
||||||
|
} = useAuth()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [isEmailLogin, setIsEmailLogin] = useState(false)
|
||||||
|
const [isConnectingWallet, setIsConnectingWallet] = useState(false)
|
||||||
|
const [isSubmittingEmail, setIsSubmittingEmail] = useState(false)
|
||||||
|
const hasRedirected = useRef(false)
|
||||||
|
|
||||||
|
// ✅ Check for existing authentication
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasRedirected.current || isLoadingAuth) return
|
||||||
|
|
||||||
|
const checkAuth = setTimeout(() => {
|
||||||
|
if (isLoadingAuth) return
|
||||||
|
|
||||||
|
const isAuthenticated = (walletConnected && walletAddress) || firebaseUser
|
||||||
|
|
||||||
|
if (isAuthenticated && !hasRedirected.current) {
|
||||||
|
console.log('✅ User already authenticated, redirecting to dashboard...')
|
||||||
|
hasRedirected.current = true
|
||||||
|
router.replace("/dashboard")
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => clearTimeout(checkAuth)
|
||||||
|
}, [isLoadingAuth, walletConnected, walletAddress, firebaseUser, router])
|
||||||
|
|
||||||
|
// ✅ Handle MetaMask connection
|
||||||
|
const handleWalletConnect = async () => {
|
||||||
|
if (isConnectingWallet || isLoadingAuth) return
|
||||||
|
|
||||||
|
setIsConnectingWallet(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🦊 Starting MetaMask connection...')
|
||||||
|
|
||||||
|
// Check if MetaMask is installed
|
||||||
|
if (typeof window !== 'undefined' && !window.ethereum) {
|
||||||
|
toast.error("MetaMask not detected. Please install MetaMask extension.")
|
||||||
|
window.open('https://metamask.io/download/', '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await connectWallet()
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('✅ MetaMask connection successful')
|
||||||
|
// Redirect will be handled by useEffect
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Wallet connection error:', error)
|
||||||
|
} finally {
|
||||||
|
setIsConnectingWallet(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Handle email login
|
||||||
|
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (isSubmittingEmail || isLoadingAuth) return
|
||||||
|
|
||||||
|
if (!email.trim() || !password.trim()) {
|
||||||
|
toast.error("Please enter both email and password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmittingEmail(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginWithEmail(email, password)
|
||||||
|
// Redirect will be handled by useEffect
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Email login failed:', error)
|
||||||
|
toast.error(error.message || "Login failed. Please check your credentials.")
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingEmail(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show connected state
|
||||||
|
if ((walletConnected && walletAddress) || firebaseUser) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-2xl">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-green-600 mx-auto mb-4" />
|
||||||
|
<CardTitle className="text-xl font-bold text-green-600">
|
||||||
|
{walletConnected ? "MetaMask Connected! 🦊" : "Email Login Successful! 📧"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center space-y-4">
|
||||||
|
<Alert className="border-green-200 bg-green-50">
|
||||||
|
<AlertDescription className="text-green-700">
|
||||||
|
{walletConnected
|
||||||
|
? `🦊 ${walletAddress?.slice(0, 6)}...${walletAddress?.slice(-4)}`
|
||||||
|
: `📧 ${firebaseUser?.email}`
|
||||||
|
}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasRedirected.current) {
|
||||||
|
hasRedirected.current = true
|
||||||
|
router.replace("/dashboard")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading while initializing
|
||||||
|
if (isLoadingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4" />
|
||||||
|
<p>Initializing...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-2xl">
|
||||||
|
<CardHeader className="text-center space-y-4">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-purple-500 to-blue-600 rounded-full flex items-center justify-center">
|
||||||
|
<Shield className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||||
|
Welcome to OpenLearnX! 🎓
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* MetaMask Login */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleWalletConnect}
|
||||||
|
disabled={isConnectingWallet || isLoadingAuth || isSubmittingEmail}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white py-3"
|
||||||
|
>
|
||||||
|
{isConnectingWallet ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Connecting MetaMask...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wallet className="w-5 h-5 mr-2" />
|
||||||
|
Connect MetaMask Wallet 🦊
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
|
||||||
|
<p className="text-xs text-purple-700 dark:text-purple-300 text-center">
|
||||||
|
✨ Recommended: Get Web3 features and blockchain verification!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Email Login */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsEmailLogin(!isEmailLogin)}
|
||||||
|
disabled={isConnectingWallet || isSubmittingEmail}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
{isEmailLogin ? 'Hide Email Login' : 'Login with Email'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isEmailLogin && (
|
||||||
|
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
disabled={isSubmittingEmail || isConnectingWallet}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
disabled={isSubmittingEmail || isConnectingWallet}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmittingEmail || isConnectingWallet || !email.trim() || !password.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isSubmittingEmail ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Logging in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock className="w-4 h-4 mr-2" />
|
||||||
|
Login with Email
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MetaMask Installation Help */}
|
||||||
|
{typeof window !== 'undefined' && !window.ethereum && (
|
||||||
|
<Alert className="border-orange-200 bg-orange-50">
|
||||||
|
<AlertCircle className="w-4 h-4 text-orange-600" />
|
||||||
|
<AlertDescription className="text-orange-700">
|
||||||
|
MetaMask not detected.
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="p-0 h-auto font-semibold text-orange-600 ml-1"
|
||||||
|
onClick={() => window.open('https://metamask.io/download/', '_blank')}
|
||||||
|
>
|
||||||
|
Install MetaMask →
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,121 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useAuth } from "@/context/auth-context"
|
||||||
import { DashboardStatsOverview } from "@/components/dashboard-stats"
|
import { DashboardStatsOverview } from "@/components/dashboard-stats"
|
||||||
|
import { Loader2, AlertCircle } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { isLoadingAuth, walletConnected, walletAddress, firebaseUser, authMethod } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [showDashboard, setShowDashboard] = useState(false)
|
||||||
|
const [debugInfo, setDebugInfo] = useState<any>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Debug authentication state
|
||||||
|
const authState = {
|
||||||
|
isLoadingAuth,
|
||||||
|
walletConnected,
|
||||||
|
walletAddress: !!walletAddress,
|
||||||
|
firebaseUser: !!firebaseUser,
|
||||||
|
authMethod,
|
||||||
|
localStorage: {
|
||||||
|
token: !!localStorage.getItem('openlearnx_jwt_token'),
|
||||||
|
wallet: !!localStorage.getItem('openlearnx_wallet'),
|
||||||
|
user: !!localStorage.getItem('openlearnx_user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDebugInfo(authState)
|
||||||
|
console.log('📊 Dashboard auth state:', authState)
|
||||||
|
|
||||||
|
// Give auth some time to initialize
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const isAuthenticated = (walletConnected && walletAddress) || firebaseUser
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
console.log('✅ User authenticated, showing dashboard')
|
||||||
|
setShowDashboard(true)
|
||||||
|
} else if (!isLoadingAuth) {
|
||||||
|
console.log('❌ User not authenticated, redirecting to login')
|
||||||
|
router.replace("/auth/login")
|
||||||
|
}
|
||||||
|
}, 2000) // Wait 2 seconds for auth to stabilize
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isLoadingAuth, walletConnected, walletAddress, firebaseUser, authMethod, router])
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (isLoadingAuth || !showDashboard) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
|
||||||
|
<div className="text-center space-y-4 max-w-md mx-auto p-6">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin mx-auto text-purple-600" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Loading Dashboard...
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{walletConnected ? `Connected to ${walletAddress?.slice(0, 6)}...${walletAddress?.slice(-4)}` :
|
||||||
|
firebaseUser ? `Logged in as ${firebaseUser.email}` :
|
||||||
|
'Verifying authentication...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug info in development */}
|
||||||
|
{process.env.NODE_ENV === 'development' && debugInfo && (
|
||||||
|
<details className="text-left text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 p-2 rounded mt-4">
|
||||||
|
<summary>Debug Info</summary>
|
||||||
|
<pre>{JSON.stringify(debugInfo, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error state if no auth after loading
|
||||||
|
if (!walletConnected && !firebaseUser && !isLoadingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
|
||||||
|
<div className="text-center space-y-4 max-w-md mx-auto p-6">
|
||||||
|
<AlertCircle className="w-16 h-16 text-red-500 mx-auto" />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Authentication Required
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Please log in to access your dashboard.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button onClick={() => router.push("/auth/login")} className="w-full">
|
||||||
|
Go to Login
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.clear()
|
||||||
|
window.location.href = "/auth/login"
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Clear Data & Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug info */}
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<details className="text-left text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 p-2 rounded mt-4">
|
||||||
|
<summary>Debug Info</summary>
|
||||||
|
<pre>{JSON.stringify(debugInfo, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show dashboard if authenticated
|
||||||
return <DashboardStatsOverview />
|
return <DashboardStatsOverview />
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -10,9 +10,10 @@ import { ThemeProvider } from "@/components/theme-provider"
|
|||||||
const inter = Inter({ subsets: ["latin"] })
|
const inter = Inter({ subsets: ["latin"] })
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OpenLearnX - Decentralized Adaptive Learning",
|
title: "OpenLearnX - Comprehensive Learning Dashboard",
|
||||||
description: "AI-powered adaptive testing with blockchain-secured credentials.",
|
description: "AI-powered adaptive learning with blockchain integration, real-time analytics, and professional progress tracking.",
|
||||||
generator: 'v0.dev'
|
keywords: "learning, coding, blockchain, AI, analytics, professional development",
|
||||||
|
generator: 'OpenLearnX v2.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -25,9 +26,22 @@ export default function RootLayout({
|
|||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Navbar />
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
|
||||||
<main>{children}</main>
|
<Navbar />
|
||||||
<Toaster position="top-right" />
|
<main className="transition-all duration-300">{children}</main>
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: 'rgba(17, 24, 39, 0.95)',
|
||||||
|
color: '#fff',
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
border: '1px solid rgba(75, 85, 99, 0.3)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useAuth } from "@/context/auth-context"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Wallet, Mail, Lock, Loader2, Shield, AlertCircle, CheckCircle2 } from "lucide-react"
|
||||||
|
import { toast } from "react-hot-toast"
|
||||||
|
|
||||||
|
export function LoginComponent() {
|
||||||
|
const {
|
||||||
|
connectWallet,
|
||||||
|
loginWithEmail,
|
||||||
|
isLoadingAuth,
|
||||||
|
walletConnected,
|
||||||
|
walletAddress,
|
||||||
|
user,
|
||||||
|
firebaseUser,
|
||||||
|
authMethod
|
||||||
|
} = useAuth()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [isEmailLogin, setIsEmailLogin] = useState(false)
|
||||||
|
const [isConnectingWallet, setIsConnectingWallet] = useState(false)
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle')
|
||||||
|
|
||||||
|
// ✅ Check if user is already authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingAuth) {
|
||||||
|
if (walletConnected && walletAddress) {
|
||||||
|
console.log('✅ MetaMask already connected:', walletAddress)
|
||||||
|
setConnectionStatus('connected')
|
||||||
|
toast.success("Already connected to MetaMask!")
|
||||||
|
router.push("/dashboard")
|
||||||
|
} else if (firebaseUser) {
|
||||||
|
console.log('✅ Firebase user already logged in:', firebaseUser.email)
|
||||||
|
router.push("/dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isLoadingAuth, walletConnected, walletAddress, firebaseUser, router])
|
||||||
|
|
||||||
|
const handleWalletConnect = async () => {
|
||||||
|
setIsConnectingWallet(true)
|
||||||
|
setConnectionStatus('connecting')
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🦊 Starting MetaMask connection...')
|
||||||
|
|
||||||
|
// Check if MetaMask is installed
|
||||||
|
if (typeof window !== 'undefined' && !window.ethereum) {
|
||||||
|
toast.error("MetaMask not detected. Please install MetaMask extension.")
|
||||||
|
setConnectionStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await connectWallet()
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setConnectionStatus('connected')
|
||||||
|
console.log('✅ MetaMask connection successful')
|
||||||
|
toast.success("MetaMask connected successfully! 🦊")
|
||||||
|
|
||||||
|
// Small delay to ensure state is updated
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/dashboard")
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('error')
|
||||||
|
toast.error("Failed to connect MetaMask. Please try again.")
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Wallet connection error:', error)
|
||||||
|
setConnectionStatus('error')
|
||||||
|
|
||||||
|
if (error.message?.includes('User rejected')) {
|
||||||
|
toast.error("Connection cancelled by user.")
|
||||||
|
} else if (error.message?.includes('MetaMask not detected')) {
|
||||||
|
toast.error("Please install MetaMask extension.")
|
||||||
|
} else {
|
||||||
|
toast.error("MetaMask connection failed. Please try again.")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsConnectingWallet(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!email.trim() || !password.trim()) {
|
||||||
|
toast.error("Please enter both email and password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.includes('@')) {
|
||||||
|
toast.error("Please enter a valid email address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('📧 Attempting email login for:', email)
|
||||||
|
await loginWithEmail(email, password)
|
||||||
|
toast.success("Logged in successfully!")
|
||||||
|
router.push("/dashboard")
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Email login failed:', error)
|
||||||
|
toast.error(error.message || "Login failed. Please check your credentials.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Show connected state if already authenticated
|
||||||
|
if (connectionStatus === 'connected' || (walletConnected && walletAddress)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-2xl border-0 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl font-bold text-green-600">
|
||||||
|
MetaMask Connected! 🦊
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center space-y-4">
|
||||||
|
<Alert className="border-green-200 bg-green-50 dark:bg-green-900/20">
|
||||||
|
<Wallet className="w-4 h-4 text-green-600" />
|
||||||
|
<AlertDescription className="text-green-700 dark:text-green-300">
|
||||||
|
🦊 {walletAddress?.slice(0, 6)}...{walletAddress?.slice(-4)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button onClick={() => router.push("/dashboard")} className="w-full">
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Connect Different Wallet
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-2xl border-0 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm">
|
||||||
|
<CardHeader className="text-center space-y-4">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-purple-500 to-blue-600 rounded-full flex items-center justify-center">
|
||||||
|
<Shield className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||||
|
Welcome to OpenLearnX! 🎓
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
Connect your MetaMask wallet or login with email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* MetaMask Login - Primary Option */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleWalletConnect}
|
||||||
|
disabled={isConnectingWallet || isLoadingAuth || connectionStatus === 'connecting'}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white py-3 transition-all duration-200"
|
||||||
|
>
|
||||||
|
{isConnectingWallet || connectionStatus === 'connecting' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Connecting MetaMask...
|
||||||
|
</>
|
||||||
|
) : connectionStatus === 'error' ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-5 h-5 mr-2" />
|
||||||
|
Retry MetaMask Connection
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wallet className="w-5 h-5 mr-2" />
|
||||||
|
Connect MetaMask Wallet 🦊
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
|
||||||
|
<p className="text-xs text-purple-700 dark:text-purple-300 text-center">
|
||||||
|
✨ Recommended: Get Web3 features, blockchain verification, and token rewards!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Separator />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="bg-white dark:bg-gray-800 px-3 text-sm text-gray-500">
|
||||||
|
or
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Login - Alternative Option */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsEmailLogin(!isEmailLogin)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
{isEmailLogin ? 'Hide Email Login' : 'Login with Email'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isEmailLogin && (
|
||||||
|
<form onSubmit={handleEmailLogin} className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
disabled={isLoadingAuth}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
disabled={isLoadingAuth}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoadingAuth || !email.trim() || !password.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoadingAuth ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Logging in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock className="w-4 h-4 mr-2" />
|
||||||
|
Login with Email
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MetaMask Installation Help */}
|
||||||
|
{typeof window !== 'undefined' && !window.ethereum && (
|
||||||
|
<Alert className="border-orange-200 bg-orange-50 dark:bg-orange-900/20">
|
||||||
|
<AlertCircle className="w-4 h-4 text-orange-600" />
|
||||||
|
<AlertDescription className="text-orange-700 dark:text-orange-300">
|
||||||
|
MetaMask not detected.
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="p-0 h-auto font-semibold text-orange-600 ml-1"
|
||||||
|
onClick={() => window.open('https://metamask.io/download/', '_blank')}
|
||||||
|
>
|
||||||
|
Install MetaMask →
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connection Status */}
|
||||||
|
{connectionStatus === 'error' && (
|
||||||
|
<Alert className="border-red-200 bg-red-50 dark:bg-red-900/20">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
|
<AlertDescription className="text-red-700 dark:text-red-300">
|
||||||
|
Connection failed. Please make sure MetaMask is unlocked and try again.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
New to OpenLearnX? Your account will be created automatically upon first login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import {
|
||||||
|
User, Wallet, CheckCircle2, AlertCircle,
|
||||||
|
Loader2, Sparkles, Shield
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "react-hot-toast"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
|
||||||
|
interface UsernameSetupProps {
|
||||||
|
userProfile: {
|
||||||
|
user_id: string
|
||||||
|
wallet_address?: string
|
||||||
|
display_name?: string
|
||||||
|
username_set?: boolean
|
||||||
|
avatar_url?: string
|
||||||
|
}
|
||||||
|
onUsernameSet: (profile: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsernameSetup({ userProfile, onUsernameSet }: UsernameSetupProps) {
|
||||||
|
const [username, setUsername] = useState("")
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmitUsername = async () => {
|
||||||
|
if (!username.trim()) {
|
||||||
|
toast.error("Please enter a username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
toast.error("Username must be at least 3 characters long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await api.post("/api/dashboard/set-username", {
|
||||||
|
username: username.trim()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to update-profile
|
||||||
|
response = await api.post("/api/dashboard/update-profile", {
|
||||||
|
display_name: username.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Username "${username}" set successfully! 🎉`)
|
||||||
|
onUsernameSet(response.profile || {
|
||||||
|
...userProfile,
|
||||||
|
display_name: username.trim(),
|
||||||
|
username_set: true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Failed to set username")
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Username setting error:', error)
|
||||||
|
toast.error("Failed to set username. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletAddress = userProfile?.wallet_address || userProfile?.user_id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-2xl">
|
||||||
|
<CardHeader className="text-center space-y-4">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-purple-500 to-blue-600 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||||
|
Welcome to OpenLearnX! 🎓
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-purple-100 to-blue-100 dark:from-purple-900/30 dark:to-blue-900/30 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<Wallet className="w-5 h-5 text-purple-600" />
|
||||||
|
<Badge variant="secondary" className="bg-purple-600 text-white">
|
||||||
|
MetaMask Connected
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 font-mono break-all">
|
||||||
|
{walletAddress?.slice(0, 6)}...{walletAddress?.slice(-4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Choose Your Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
maxLength={25}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
<p>• 3-25 characters</p>
|
||||||
|
<p>• Letters, numbers, and underscores recommended</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
What you'll get:
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||||
|
<li>• Personalized learning dashboard</li>
|
||||||
|
<li>• Global leaderboard ranking</li>
|
||||||
|
<li>• Blockchain-verified achievements</li>
|
||||||
|
<li>• Community interaction</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitUsername}
|
||||||
|
disabled={!username.trim() || username.length < 3 || isSubmitting}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Setting Username...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Set Username & Continue
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,168 +1,644 @@
|
|||||||
|
// frontend/components/dashboard-stats.tsx - ONLY REAL DATA
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useAuth } from "@/context/auth-context"
|
import { useAuth } from "@/context/auth-context"
|
||||||
import { useRouter } from "next/router"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "react-hot-toast"
|
import { toast } from "react-hot-toast"
|
||||||
import type { DashboardStats, ActivityData } from "@/lib/types"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Loader2, Award, BookOpen, Code, CheckCircle2, TrendingUp } from "lucide-react"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import {
|
||||||
|
Trophy, BookOpen, Code, CheckCircle2, Wallet, Shield,
|
||||||
|
Activity, Target, Timer, Award, Zap, Globe, User,
|
||||||
|
BarChart3, Flame, Brain, Loader2, AlertCircle
|
||||||
|
} from "lucide-react"
|
||||||
|
import { UsernameSetup } from "./UsernameSetup"
|
||||||
import api from "@/lib/api"
|
import api from "@/lib/api"
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
total_xp: number
|
||||||
|
courses_completed: number
|
||||||
|
coding_problems_solved: number
|
||||||
|
quiz_accuracy: number
|
||||||
|
coding_streak: number
|
||||||
|
longest_streak: number
|
||||||
|
total_courses: number
|
||||||
|
total_quizzes: number
|
||||||
|
global_rank: number
|
||||||
|
weekly_activity: number[]
|
||||||
|
monthly_goals: { target: number; completed: number }
|
||||||
|
blockchain: {
|
||||||
|
wallet_connected: boolean
|
||||||
|
wallet_address: string
|
||||||
|
total_earned: number
|
||||||
|
transactions: number
|
||||||
|
certificates: number
|
||||||
|
verified_achievements: number
|
||||||
|
}
|
||||||
|
learning_analytics: {
|
||||||
|
time_spent_hours: number
|
||||||
|
average_session_minutes: number
|
||||||
|
completion_rate: number
|
||||||
|
favorite_topics: string[]
|
||||||
|
skill_levels: { [key: string]: number }
|
||||||
|
}
|
||||||
|
recent_achievements: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
earned_at: string
|
||||||
|
points: number
|
||||||
|
rarity: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
user_id: string
|
||||||
|
wallet_address?: string
|
||||||
|
display_name?: string
|
||||||
|
username_set?: boolean
|
||||||
|
avatar_url?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityData {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
completed_at: string
|
||||||
|
points_earned: number
|
||||||
|
blockchain_verified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardEntry {
|
||||||
|
rank: number
|
||||||
|
user_id: string
|
||||||
|
username: string
|
||||||
|
display_name?: string
|
||||||
|
total_xp: number
|
||||||
|
streak: number
|
||||||
|
avatar?: string
|
||||||
|
wallet_address?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardStatsOverview() {
|
export function DashboardStatsOverview() {
|
||||||
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
|
const { walletAddress, walletConnected, isLoadingAuth } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
|
||||||
const [activity, setActivity] = useState<ActivityData[]>([])
|
const [activity, setActivity] = useState<ActivityData[]>([])
|
||||||
|
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true)
|
const [isLoadingData, setIsLoadingData] = useState(true)
|
||||||
|
const [usernameRequired, setUsernameRequired] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoadingAuth && !user && !firebaseUser) {
|
if (!isLoadingAuth && !walletConnected) {
|
||||||
// Allow either MetaMask or Firebase user
|
toast.error("Please connect your MetaMask wallet to view dashboard.")
|
||||||
toast.error("Please login to view your dashboard.")
|
router.push("/auth/login")
|
||||||
router.push("/")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
if (walletConnected && walletAddress) {
|
||||||
setIsLoadingData(true)
|
fetchPureMongoDBData()
|
||||||
setError(null)
|
}
|
||||||
try {
|
}, [walletConnected, walletAddress, isLoadingAuth, router])
|
||||||
// --- ORIGINAL API CALLS (UNCOMMENT WHEN BACKEND IS READY) ---
|
|
||||||
const statsResponse = await api.get<DashboardStats>("/api/dashboard/stats")
|
|
||||||
setStats(statsResponse.data)
|
|
||||||
|
|
||||||
const activityResponse = await api.get<ActivityData[]>("/api/dashboard/activity")
|
const fetchPureMongoDBData = async () => {
|
||||||
setActivity(activityResponse.data)
|
setIsLoadingData(true)
|
||||||
} catch (err: any) {
|
setError(null)
|
||||||
console.error("Failed to fetch dashboard data:", err)
|
|
||||||
setError(err.response?.data?.message || "Failed to load dashboard data.")
|
try {
|
||||||
toast.error(err.response?.data?.message || "Failed to load dashboard data.")
|
console.log('📊 Fetching PURE MongoDB data for wallet:', walletAddress)
|
||||||
} finally {
|
|
||||||
setIsLoadingData(false) // Handled by setTimeout
|
const [statsRes, activityRes, leaderboardRes] = await Promise.all([
|
||||||
|
api.get<{
|
||||||
|
success: boolean
|
||||||
|
data?: DashboardStats
|
||||||
|
user_profile: UserProfile
|
||||||
|
username_required?: boolean
|
||||||
|
data_source: string
|
||||||
|
message?: string
|
||||||
|
}>("/api/dashboard/comprehensive-stats"),
|
||||||
|
api.get<{success: boolean, data: ActivityData[], data_source: string}>("/api/dashboard/recent-activity"),
|
||||||
|
api.get<{success: boolean, data: LeaderboardEntry[], data_source: string}>("/api/dashboard/global-leaderboard")
|
||||||
|
])
|
||||||
|
|
||||||
|
// ✅ VERIFY DATA SOURCE IS PURE MONGODB
|
||||||
|
if (statsRes.data.data_source !== "pure_mongodb_data" && statsRes.data.data_source !== "empty_real_data") {
|
||||||
|
console.error("❌ Data source is not pure MongoDB:", statsRes.data.data_source)
|
||||||
|
toast.error("Invalid data source detected. Refreshing...")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (user || firebaseUser) {
|
if (statsRes.data.success) {
|
||||||
// Only fetch if either user type is logged in
|
if (statsRes.data.username_required) {
|
||||||
fetchDashboardData()
|
setUsernameRequired(true)
|
||||||
}
|
setUserProfile(statsRes.data.user_profile)
|
||||||
}, [user, firebaseUser, isLoadingAuth, router, token])
|
setIsLoadingData(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStats(statsRes.data.data || null)
|
||||||
|
setUserProfile(statsRes.data.user_profile)
|
||||||
|
setUsernameRequired(false)
|
||||||
|
|
||||||
|
console.log('✅ Pure MongoDB data loaded for user:', statsRes.data.user_profile?.display_name)
|
||||||
|
console.log('📊 Data source verified:', statsRes.data.data_source)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activityRes.data.success && activityRes.data.data_source === "pure_mongodb_data") {
|
||||||
|
setActivity(activityRes.data.data)
|
||||||
|
console.log('✅ Real activity loaded:', activityRes.data.data.length, 'items')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaderboardRes.data.success && leaderboardRes.data.data_source === "pure_mongodb_data") {
|
||||||
|
setLeaderboard(leaderboardRes.data.data)
|
||||||
|
console.log('✅ Real leaderboard loaded:', leaderboardRes.data.data.length, 'users')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to fetch pure MongoDB data:", err)
|
||||||
|
setError(err.response?.data?.message || "Failed to load dashboard data.")
|
||||||
|
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
toast.error("MetaMask authentication required.")
|
||||||
|
router.push("/auth/login")
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load real dashboard data.")
|
||||||
|
setStats(null)
|
||||||
|
setActivity([])
|
||||||
|
setLeaderboard([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUsernameSet = (profile: UserProfile) => {
|
||||||
|
setUserProfile(profile)
|
||||||
|
setUsernameRequired(false)
|
||||||
|
fetchPureMongoDBData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateString: string) => {
|
||||||
|
const diff = Date.now() - new Date(dateString).getTime()
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`
|
||||||
|
if (hours > 0) return `${hours}h ago`
|
||||||
|
return 'Just now'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRarityColor = (rarity: string) => {
|
||||||
|
switch (rarity) {
|
||||||
|
case 'legendary': return 'bg-gradient-to-r from-yellow-400 to-orange-500 text-white'
|
||||||
|
case 'epic': return 'bg-gradient-to-r from-purple-500 to-pink-500 text-white'
|
||||||
|
case 'rare': return 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white'
|
||||||
|
default: return 'bg-gradient-to-r from-gray-500 to-gray-600 text-white'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
if (isLoadingAuth || isLoadingData) {
|
if (isLoadingAuth || isLoadingData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[calc(100vh-64px)]">
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary-purple" />
|
<div className="text-center space-y-4">
|
||||||
<span className="ml-2 text-lg">Loading dashboard...</span>
|
<div className="relative">
|
||||||
|
<div className="w-16 h-16 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin mx-auto"></div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<BarChart3 className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Loading Pure MongoDB Data
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Fetching your real learning progress from database...
|
||||||
|
</p>
|
||||||
|
{walletAddress && (
|
||||||
|
<p className="text-xs text-purple-600 dark:text-purple-400 font-mono">
|
||||||
|
🦊 {walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
// Username setup required
|
||||||
|
if (usernameRequired && userProfile) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[calc(100vh-64px)] text-red-500">
|
<UsernameSetup
|
||||||
<p>{error}</p>
|
userProfile={userProfile}
|
||||||
|
onUsernameSet={handleUsernameSet}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state - no real data
|
||||||
|
if (!stats && userProfile) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-7xl">
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="mb-6">
|
||||||
|
<BookOpen className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
No Learning Data Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Start your learning journey to see real analytics here!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Show user profile info */}
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 p-4 rounded-lg mb-6 max-w-md mx-auto">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-purple-600 flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{userProfile.display_name || 'New Learner'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
🦊 {userProfile.wallet_address?.slice(0, 6)}...{userProfile.wallet_address?.slice(-4)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-600">
|
||||||
|
✅ Ready for learning - Pure MongoDB tracking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-x-4">
|
||||||
|
<Button onClick={() => router.push('/courses')} className="mr-4">
|
||||||
|
<BookOpen className="w-4 h-4 mr-2" />
|
||||||
|
Browse Courses
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => router.push('/quizzes')}>
|
||||||
|
<Brain className="w-4 h-4 mr-2" />
|
||||||
|
Take a Quiz
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] text-gray-600 dark:text-gray-300">
|
<div className="container mx-auto py-8 px-4 max-w-7xl">
|
||||||
<p className="text-xl mb-4">No dashboard data available.</p>
|
<div className="text-center py-16">
|
||||||
<p>Start learning to see your progress!</p>
|
<AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Unable to Load Dashboard
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Please ensure your MetaMask wallet is connected and try again.
|
||||||
|
</p>
|
||||||
|
<Button onClick={fetchPureMongoDBData}>
|
||||||
|
<Globe className="w-4 h-4 mr-2" />
|
||||||
|
Retry Loading
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4 max-w-7xl">
|
||||||
{authMethod === "firebase" && !token && (
|
{/* Header with Real User Info */}
|
||||||
<div className="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-6 rounded-md dark:bg-yellow-900 dark:border-yellow-600 dark:text-yellow-200">
|
<div className="mb-8">
|
||||||
<p className="font-bold">Limited Access</p>
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||||
<p>
|
<div className="space-y-2">
|
||||||
You are logged in with email. Full functionality, including personalized stats and activity tracking,
|
<div className="flex items-center gap-3">
|
||||||
requires connecting your MetaMask wallet.
|
<div className="relative">
|
||||||
</p>
|
<img
|
||||||
|
src={userProfile?.avatar_url || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userProfile?.user_id || 'default'}`}
|
||||||
|
alt="User Avatar"
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-purple-600"
|
||||||
|
/>
|
||||||
|
{stats.coding_streak > 0 && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-6 h-6 bg-orange-500 rounded-full flex items-center justify-center">
|
||||||
|
<Flame className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Welcome, {userProfile?.display_name || 'Learner'}! 🦊
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Your real learning progress from MongoDB
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs font-mono">
|
||||||
|
🦊 {walletAddress?.slice(0, 6)}...{walletAddress?.slice(-4)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="default" className="text-xs bg-green-600">
|
||||||
|
✅ Pure MongoDB Data
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="outline" className="px-3 py-1">
|
||||||
|
<Globe className="w-4 h-4 mr-1" />
|
||||||
|
Rank #{stats.global_rank.toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="px-3 py-1">
|
||||||
|
<Zap className="w-4 h-4 mr-1" />
|
||||||
|
{stats.total_xp.toLocaleString()} XP
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="default" className="px-3 py-1 bg-purple-600">
|
||||||
|
<Wallet className="w-4 h-4 mr-1" />
|
||||||
|
MetaMask Verified
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-primary-purple mb-8 text-center">Your Dashboard</h1>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
{/* Real Metrics Grid */}
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
{/* Coding Streak */}
|
||||||
|
<Card className="relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-orange-500/10 to-red-500/10" />
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Total XP</CardTitle>
|
<CardTitle className="text-sm font-medium">Real Coding Streak</CardTitle>
|
||||||
<Award className="h-4 w-4 text-primary-blue" />
|
<Flame className="h-5 w-5 text-orange-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.total_xp}</div>
|
<div className="text-3xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Accumulated experience points</p>
|
{stats.coding_streak}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Best: {stats.longest_streak} days
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Progress
|
||||||
|
value={stats.longest_streak > 0 ? (stats.coding_streak / stats.longest_streak) * 100 : 0}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
|
||||||
|
{/* Course Progress */}
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Courses Completed</CardTitle>
|
<CardTitle className="text-sm font-medium">Real Course Progress</CardTitle>
|
||||||
<BookOpen className="h-4 w-4 text-primary-purple" />
|
<BookOpen className="h-5 w-5 text-blue-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.courses_completed}</div>
|
<div className="text-3xl font-bold">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Courses you've finished</p>
|
{stats.courses_completed}/{stats.total_courses}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{stats.total_courses > 0 ? Math.round((stats.courses_completed / stats.total_courses) * 100) : 0}% completed
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Progress
|
||||||
|
value={stats.total_courses > 0 ? (stats.courses_completed / stats.total_courses) * 100 : 0}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
|
||||||
|
{/* Problem Solving */}
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Problems Solved</CardTitle>
|
<CardTitle className="text-sm font-medium">Real Problems Solved</CardTitle>
|
||||||
<Code className="h-4 w-4 text-primary-blue" />
|
<Code className="h-5 w-5 text-purple-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.coding_problems_solved}</div>
|
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Coding challenges mastered</p>
|
{stats.coding_problems_solved}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{stats.learning_analytics.completion_rate.toFixed(1)}% success rate
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="text-xs mt-1">
|
||||||
|
<Shield className="w-3 h-3 mr-1" />
|
||||||
|
MongoDB Verified
|
||||||
|
</Badge>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
|
||||||
|
{/* Quiz Performance */}
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Quiz Accuracy</CardTitle>
|
<CardTitle className="text-sm font-medium">Real Quiz Accuracy</CardTitle>
|
||||||
<CheckCircle2 className="h-4 w-4 text-primary-purple" />
|
<Brain className="h-5 w-5 text-green-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.quiz_accuracy.toFixed(1)}%</div>
|
<div className="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Overall quiz performance</p>
|
{stats.quiz_accuracy.toFixed(1)}%
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
{stats.total_quizzes} real quizzes completed
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
</p>
|
||||||
<CardTitle className="text-sm font-medium">Coding Streak</CardTitle>
|
|
||||||
<TrendingUp className="h-4 w-4 text-primary-blue" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.coding_streak} days</div>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Consecutive days coding</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold text-primary-purple mb-6 text-center">Activity Heatmap (Coming Soon)</h2>
|
{/* Real Learning Analytics */}
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100 mb-8">
|
<Card className="mb-8">
|
||||||
<CardContent className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
<CardHeader>
|
||||||
<p>Interactive activity heatmap visualization will appear here.</p>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-5 h-5" />
|
||||||
|
Real Learning Analytics from MongoDB
|
||||||
|
<Badge variant="outline" className="text-xs ml-2">
|
||||||
|
100% Authentic Data
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Time Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<Timer className="w-8 h-8 text-blue-600 mx-auto mb-2" />
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{stats.learning_analytics.time_spent_hours}h
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Real Time Spent</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||||
|
<Target className="w-8 h-8 text-green-600 mx-auto mb-2" />
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{stats.learning_analytics.average_session_minutes}m
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Avg Session</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-purple-600 mx-auto mb-2" />
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{stats.learning_analytics.completion_rate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Real Completion Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real Skill Levels */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-gray-100">Real Skill Progression from MongoDB</h4>
|
||||||
|
{Object.entries(stats.learning_analytics.skill_levels).map(([skill, level]) => (
|
||||||
|
<div key={skill} className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="font-medium">{skill}</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">{level}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={level} className="h-2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real Weekly Activity */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-gray-100">Real Weekly Activity Pattern</h4>
|
||||||
|
<div className="flex items-end space-x-2 h-24">
|
||||||
|
{stats.weekly_activity.map((activity, index) => {
|
||||||
|
const maxActivity = Math.max(...stats.weekly_activity) || 1
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-full bg-gradient-to-t from-purple-600 to-cyan-500 rounded-t-sm transition-all duration-300 hover:from-purple-700 hover:to-cyan-600"
|
||||||
|
style={{ height: `${(activity / maxActivity) * 100}%` }}
|
||||||
|
title={`${activity} real activities`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500 mt-1">
|
||||||
|
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][index]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold text-primary-purple mb-6 text-center">
|
{/* Real Recent Activity */}
|
||||||
Strengths/Weaknesses & Leaderboard (Coming Soon)
|
<Card className="mb-8">
|
||||||
</h2>
|
<CardHeader>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
<Activity className="w-5 h-5" />
|
||||||
<CardContent className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
Real Activity History from MongoDB
|
||||||
<p>Radar chart for strengths/weaknesses will appear here.</p>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{activity.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activity.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center gap-4 p-4 rounded-lg border bg-gradient-to-r from-white to-gray-50 dark:from-gray-800 dark:to-gray-700 hover:shadow-md transition-shadow">
|
||||||
|
<div className={`p-2 rounded-lg ${
|
||||||
|
item.type === 'course' ? 'bg-blue-100 dark:bg-blue-900/30' :
|
||||||
|
item.type === 'quiz' ? 'bg-green-100 dark:bg-green-900/30' :
|
||||||
|
item.type === 'coding' ? 'bg-purple-100 dark:bg-purple-900/30' :
|
||||||
|
'bg-yellow-100 dark:bg-yellow-900/30'
|
||||||
|
}`}>
|
||||||
|
{item.type === 'course' && <BookOpen className="w-4 h-4 text-blue-600" />}
|
||||||
|
{item.type === 'quiz' && <Brain className="w-4 h-4 text-green-600" />}
|
||||||
|
{item.type === 'coding' && <Code className="w-4 h-4 text-purple-600" />}
|
||||||
|
{item.type === 'achievement' && <Award className="w-4 h-4 text-yellow-600" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-semibold text-sm">{item.title}</h4>
|
||||||
|
{item.blockchain_verified && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Shield className="w-3 h-3 mr-1" />
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">{item.description}</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatTimeAgo(item.completed_at)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-green-600">
|
||||||
|
+{item.points_earned} XP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Activity className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No real activity found in MongoDB</p>
|
||||||
|
<p className="text-xs">Start learning to see your authentic activity here!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Real Global Leaderboard */}
|
||||||
|
{leaderboard.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Trophy className="w-5 h-5" />
|
||||||
|
Real Global Leaderboard from MongoDB
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{leaderboard.slice(0, 10).map((entry) => (
|
||||||
|
<div key={entry.user_id} className="flex items-center gap-4 p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||||
|
entry.rank === 1 ? 'bg-yellow-500 text-white' :
|
||||||
|
entry.rank === 2 ? 'bg-gray-400 text-white' :
|
||||||
|
entry.rank === 3 ? 'bg-amber-600 text-white' :
|
||||||
|
'bg-gray-200 text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{entry.rank}
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={entry.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${entry.user_id}`}
|
||||||
|
alt={entry.username}
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">{entry.display_name || entry.username}</span>
|
||||||
|
{entry.wallet_address && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
🦊 Real User
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{entry.user_id.slice(0, 8)}...{entry.user_id.slice(-4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-bold text-purple-600">{entry.total_xp.toLocaleString()} Real XP</div>
|
||||||
|
<div className="text-xs text-gray-500">{entry.streak} day streak</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
)}
|
||||||
<CardContent className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
|
||||||
<p>Global leaderboard will appear here.</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-119
@@ -1,10 +1,21 @@
|
|||||||
|
// User types
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
wallet_address: string
|
||||||
|
created_at: string
|
||||||
|
last_login: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication request/response types
|
||||||
export interface AuthNonceRequest {
|
export interface AuthNonceRequest {
|
||||||
wallet_address: string
|
wallet_address: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthNonceResponse {
|
export interface AuthNonceResponse {
|
||||||
|
success: boolean
|
||||||
nonce: string
|
nonce: string
|
||||||
message: string
|
message: string
|
||||||
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthVerifyRequest {
|
export interface AuthVerifyRequest {
|
||||||
@@ -16,135 +27,84 @@ export interface AuthVerifyRequest {
|
|||||||
export interface AuthVerifyResponse {
|
export interface AuthVerifyResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
token: string
|
token: string
|
||||||
user: {
|
user: User
|
||||||
wallet_address: string
|
message: string
|
||||||
// Add other user details if available from your backend
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionOption {
|
// Dashboard types
|
||||||
id: string
|
export interface UserProfile {
|
||||||
text: string
|
user_id: string
|
||||||
|
wallet_address?: string
|
||||||
|
display_name?: string
|
||||||
|
username_set?: boolean
|
||||||
|
avatar_url?: string
|
||||||
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Question {
|
|
||||||
id: string
|
|
||||||
text: string
|
|
||||||
options: QuestionOption[]
|
|
||||||
type: string // e.g., "multiple_choice"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestStartRequest {
|
|
||||||
subject: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestStartResponse {
|
|
||||||
session_id: string
|
|
||||||
question: Question
|
|
||||||
question_number: number
|
|
||||||
total_questions: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Feedback {
|
|
||||||
correct: boolean
|
|
||||||
confidence_score: number
|
|
||||||
explanation: string
|
|
||||||
correct_answer?: string // Optional, if backend provides it
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestAnswerRequest {
|
|
||||||
session_id: string
|
|
||||||
question_id: string
|
|
||||||
answer: number // Index of the selected option
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestAnswerResponse {
|
|
||||||
feedback: Feedback
|
|
||||||
next_question: Question | null
|
|
||||||
test_completed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
wallet_address: string
|
|
||||||
// Add other user details
|
|
||||||
}
|
|
||||||
|
|
||||||
// New types for Course Platform
|
|
||||||
export interface Lesson {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
type: "video" | "text" | "code" | "quiz"
|
|
||||||
content: string // URL for video, markdown for text, code snippet for code
|
|
||||||
completed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Module {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
lessons: Lesson[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Course {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
subject: string
|
|
||||||
description: string
|
|
||||||
progress: number // 0-100
|
|
||||||
modules: Module[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// New types for Coding Platform
|
|
||||||
export interface CodingProblem {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
category: string
|
|
||||||
difficulty: "Easy" | "Medium" | "Hard"
|
|
||||||
description: string
|
|
||||||
initial_code: { [key: string]: string } // e.g., { "python": "def solve():\n pass" }
|
|
||||||
test_cases: { input: string; expected_output: string }[]
|
|
||||||
solved: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodeExecutionResult {
|
|
||||||
output: string
|
|
||||||
error: string | null
|
|
||||||
runtime: number
|
|
||||||
correct: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// New types for Quiz Platform (reusing existing Test types)
|
|
||||||
export interface Quiz {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
topic: string
|
|
||||||
difficulty: "Easy" | "Medium" | "Hard"
|
|
||||||
recent_performance?: number // 0-100
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuizResult {
|
|
||||||
score: number
|
|
||||||
total_questions: number
|
|
||||||
correct_answers: number
|
|
||||||
per_question_breakdown: {
|
|
||||||
question_id: string
|
|
||||||
correct: boolean
|
|
||||||
explanation: string
|
|
||||||
user_answer: string
|
|
||||||
correct_answer: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// New types for Dashboard
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
total_xp: number
|
total_xp: number
|
||||||
courses_in_progress: number
|
|
||||||
courses_completed: number
|
courses_completed: number
|
||||||
coding_problems_solved: number
|
coding_problems_solved: number
|
||||||
quiz_accuracy: number // overall average
|
quiz_accuracy: number
|
||||||
coding_streak: number
|
coding_streak: number
|
||||||
|
longest_streak: number
|
||||||
|
total_courses: number
|
||||||
|
total_quizzes: number
|
||||||
|
global_rank: number
|
||||||
|
weekly_activity: number[]
|
||||||
|
monthly_goals: {
|
||||||
|
target: number
|
||||||
|
completed: number
|
||||||
|
}
|
||||||
|
blockchain: {
|
||||||
|
wallet_connected: boolean
|
||||||
|
wallet_address: string | null
|
||||||
|
total_earned: number
|
||||||
|
transactions: number
|
||||||
|
certificates: number
|
||||||
|
verified_achievements: number
|
||||||
|
}
|
||||||
|
learning_analytics: {
|
||||||
|
time_spent_hours: number
|
||||||
|
average_session_minutes: number
|
||||||
|
completion_rate: number
|
||||||
|
favorite_topics: string[]
|
||||||
|
skill_levels: {
|
||||||
|
[key: string]: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recent_achievements: Achievement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
earned_at: string
|
||||||
|
points: number
|
||||||
|
rarity: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityData {
|
export interface ActivityData {
|
||||||
date: string // YYYY-MM-DD
|
id: string
|
||||||
count: number // Number of activities
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
completed_at: string
|
||||||
|
points_earned: number
|
||||||
|
success_rate: number
|
||||||
|
difficulty: string
|
||||||
|
blockchain_verified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
rank: number
|
||||||
|
user_id: string
|
||||||
|
username: string
|
||||||
|
display_name?: string
|
||||||
|
total_xp: number
|
||||||
|
streak: number
|
||||||
|
avatar: string
|
||||||
|
badges: string[]
|
||||||
|
wallet_address?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@
|
|||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
"axios": "latest",
|
"axios": "latest",
|
||||||
|
"badge": "link:@/components/ui/badge",
|
||||||
|
"button": "link:@/components/ui/button",
|
||||||
|
"card": "link:@/components/ui/card",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
@@ -49,20 +52,23 @@
|
|||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "15.2.4",
|
"next": "15.4.4",
|
||||||
"next-themes": "latest",
|
"next-themes": "latest",
|
||||||
"react": "^19",
|
"progress": "link:@/components/ui/progress",
|
||||||
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "9.8.0",
|
"react-day-picker": "9.8.0",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.54.1",
|
"react-hook-form": "^7.54.1",
|
||||||
"react-hot-toast": "latest",
|
"react-hot-toast": "latest",
|
||||||
"react-markdown": "latest",
|
"react-markdown": "latest",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "2.15.0",
|
"recharts": "2.15.0",
|
||||||
|
"separator": "link:@/components/ui/separator",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.6",
|
"vaul": "^0.9.6",
|
||||||
|
"web3": "^4.16.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+787
-154
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "es6"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"target": "ES6",
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
@@ -18,8 +19,15 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"],
|
||||||
|
"@/components/*": ["./components/*"],
|
||||||
|
"@/hooks/*": ["./hooks/*"],
|
||||||
|
"@/lib/*": ["./lib/*"],
|
||||||
|
"@/utils/*": ["./utils/*"],
|
||||||
|
"@/types/*": ["./types/*"],
|
||||||
|
"@/app/*": ["./app/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
|||||||
@@ -135,3 +135,6 @@ requests==2.31.0
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker
|
docker
|
||||||
|
|
||||||
|
|
||||||
|
flask_jwt_extended
|
||||||
|
|||||||
Reference in New Issue
Block a user