mirror of
https://github.com/th30d4y/OpenLearnX.git
synced 2026-05-26 19:26:33 +00:00
update
This commit is contained in:
+778
-946
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Random import get_random_bytes
|
||||||
|
from Crypto.Util.Padding import pad, unpad
|
||||||
|
import json
|
||||||
|
|
||||||
|
class CertificateManager:
|
||||||
|
def __init__(self):
|
||||||
|
# AES-256 key (store this securely in environment variables)
|
||||||
|
self.key = os.getenv('AES_ENCRYPTION_KEY', self._generate_key())
|
||||||
|
|
||||||
|
def _generate_key(self):
|
||||||
|
"""Generate a new AES-256 key"""
|
||||||
|
return base64.b64encode(get_random_bytes(32)).decode('utf-8')
|
||||||
|
|
||||||
|
def encrypt_wallet_id(self, wallet_id):
|
||||||
|
"""Encrypt wallet ID using AES-256"""
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(self.key)
|
||||||
|
cipher = AES.new(key, AES.MODE_CBC)
|
||||||
|
ct_bytes = cipher.encrypt(pad(wallet_id.encode('utf-8'), AES.block_size))
|
||||||
|
iv = base64.b64encode(cipher.iv).decode('utf-8')
|
||||||
|
ct = base64.b64encode(ct_bytes).decode('utf-8')
|
||||||
|
return {"iv": iv, "encrypted": ct}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Encryption error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def decrypt_wallet_id(self, encrypted_data):
|
||||||
|
"""Decrypt wallet ID"""
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(self.key)
|
||||||
|
iv = base64.b64decode(encrypted_data['iv'])
|
||||||
|
ct = base64.b64decode(encrypted_data['encrypted'])
|
||||||
|
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||||
|
pt = unpad(cipher.decrypt(ct), AES.block_size)
|
||||||
|
return pt.decode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Decryption error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_certificate_id(self):
|
||||||
|
"""Generate random certificate ID"""
|
||||||
|
return ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(12))
|
||||||
+875
-33
@@ -1,49 +1,891 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from datetime import datetime
|
||||||
import jwt
|
import jwt
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
from bson import ObjectId
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
bp = Blueprint('certificate', __name__)
|
bp = Blueprint('certificate', __name__)
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def get_user_from_token(token):
|
def get_user_from_token(token):
|
||||||
"""Extract user from JWT token"""
|
"""Extract user from JWT token with enhanced error handling"""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
secret_key = current_app.config.get('JWT_SECRET_KEY') or current_app.config.get('SECRET_KEY')
|
||||||
token,
|
|
||||||
current_app.config['SECRET_KEY'],
|
if not secret_key:
|
||||||
algorithms=['HS256']
|
logger.error("No JWT secret key found in configuration")
|
||||||
)
|
return None, None
|
||||||
return payload['user_id'], payload['wallet_address']
|
|
||||||
except:
|
payload = jwt.decode(token, secret_key, algorithms=['HS256'])
|
||||||
|
user_id = payload.get('user_id') or payload.get('sub')
|
||||||
|
wallet_address = payload.get('wallet_address')
|
||||||
|
|
||||||
|
logger.info(f"✅ Token decoded successfully for user: {user_id}")
|
||||||
|
return user_id, wallet_address
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error decoding JWT token: {str(e)}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@bp.route('/user/<user_id>', methods=['GET'])
|
def get_db_connection():
|
||||||
async def get_user_certificates(user_id):
|
"""Get MongoDB database connection with enhanced error handling"""
|
||||||
"""Get all certificates for a user"""
|
try:
|
||||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
# Try to get from Flask config first
|
||||||
token_user_id, _ = get_user_from_token(token)
|
mongo_service = current_app.config.get('MONGO_SERVICE')
|
||||||
|
if mongo_service and hasattr(mongo_service, 'db'):
|
||||||
|
print("📊 Using Flask config database connection")
|
||||||
|
return mongo_service.db
|
||||||
|
|
||||||
if not token_user_id or token_user_id != user_id:
|
# Fallback to direct connection with explicit URI
|
||||||
return jsonify({"error": "Unauthorized"}), 403
|
mongodb_uri = current_app.config.get('MONGODB_URI', 'mongodb://localhost:27017/')
|
||||||
|
print(f"📊 Connecting directly to MongoDB: {mongodb_uri}")
|
||||||
|
|
||||||
mongo_service = current_app.config['MONGO_SERVICE']
|
client = MongoClient(mongodb_uri)
|
||||||
certificates = await mongo_service.get_user_certificates(user_id)
|
db = client.openlearnx
|
||||||
|
|
||||||
return jsonify({"certificates": certificates or []})
|
# Test the connection by running a simple command
|
||||||
|
db.command('ping')
|
||||||
|
print("✅ Database connection successful!")
|
||||||
|
|
||||||
@bp.route('/mint', methods=['POST'])
|
return db
|
||||||
async def mint_certificate():
|
|
||||||
"""Mint NFT certificate for completed test"""
|
|
||||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
||||||
user_id, wallet_address = get_user_from_token(token)
|
|
||||||
|
|
||||||
if not user_id:
|
except Exception as e:
|
||||||
return jsonify({"error": "Authentication required"}), 401
|
print(f"❌ Database connection failed: {e}")
|
||||||
|
logger.error(f"Database connection failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
# Mock certificate minting for now
|
def generate_truly_unique_certificate_id():
|
||||||
return jsonify({
|
"""Generate GUARANTEED unique certificate ID"""
|
||||||
"success": True,
|
|
||||||
"certificate": {
|
# Method 1: Nanosecond timestamp for uniqueness
|
||||||
"token_id": 1,
|
nano_timestamp = str(time.time_ns())
|
||||||
"transaction_hash": "0x123...",
|
|
||||||
"message": "Certificate minting functionality ready"
|
# Method 2: High entropy random
|
||||||
|
random_component = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
|
||||||
|
|
||||||
|
# Method 3: UUID component
|
||||||
|
uuid_component = str(uuid.uuid4()).replace('-', '').upper()[:4]
|
||||||
|
|
||||||
|
# Method 4: System-specific component
|
||||||
|
system_component = f"{os.getpid()}{threading.get_ident()}"[-4:]
|
||||||
|
|
||||||
|
# Combine and ensure 12 characters
|
||||||
|
combined = nano_timestamp[-3:] + random_component[:4] + uuid_component[:3] + system_component[-2:]
|
||||||
|
certificate_id = combined[:12].upper()
|
||||||
|
|
||||||
|
# Force different from problematic ID
|
||||||
|
if certificate_id == "DG1ITFZ7DT5B":
|
||||||
|
certificate_id = "UNIQUE" + str(int(time.time()))[-6:]
|
||||||
|
certificate_id = certificate_id[:12].upper()
|
||||||
|
|
||||||
|
print(f"🆔 Generated unique ID: {certificate_id}")
|
||||||
|
return certificate_id
|
||||||
|
|
||||||
|
def generate_unique_share_code():
|
||||||
|
"""Generate unique 8-character share code"""
|
||||||
|
timestamp = str(int(time.time() * 1000000))[-4:]
|
||||||
|
random_part = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4))
|
||||||
|
share_code = timestamp + random_part
|
||||||
|
print(f"🔗 Generated share code: {share_code}")
|
||||||
|
return share_code
|
||||||
|
|
||||||
|
@bp.route('/mint', methods=['POST', 'OPTIONS'])
|
||||||
|
def mint_certificate():
|
||||||
|
"""FIXED: Create certificate with guaranteed database saving"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("🎓 STARTING CERTIFICATE MINTING PROCESS")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# Get request data
|
||||||
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
print("❌ No request data provided")
|
||||||
|
return jsonify({"error": "Request data required"}), 400
|
||||||
|
|
||||||
|
print(f"📥 Received data: {data}")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['user_name', 'course_id']
|
||||||
|
for field in required_fields:
|
||||||
|
if not data.get(field):
|
||||||
|
print(f"❌ Missing required field: {field}")
|
||||||
|
return jsonify({"error": f"Missing required field: {field}"}), 400
|
||||||
|
|
||||||
|
# Get student's entered name
|
||||||
|
student_entered_name = data.get('user_name', '').strip()
|
||||||
|
if not student_entered_name:
|
||||||
|
print("❌ Student name is empty")
|
||||||
|
return jsonify({"error": "Student name is required"}), 400
|
||||||
|
|
||||||
|
print(f"🎓 STUDENT NAME: '{student_entered_name}'")
|
||||||
|
print(f"📚 COURSE ID: '{data['course_id']}'")
|
||||||
|
|
||||||
|
# Get user ID (from token or default)
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
user_id = 'anonymous'
|
||||||
|
wallet_address = None
|
||||||
|
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
token = auth_header.replace('Bearer ', '')
|
||||||
|
token_user_id, wallet_address = get_user_from_token(token)
|
||||||
|
if token_user_id:
|
||||||
|
user_id = token_user_id
|
||||||
|
|
||||||
|
print(f"👤 USER ID: '{user_id}'")
|
||||||
|
|
||||||
|
# ✅ CRITICAL: Get database connection and verify it works
|
||||||
|
print("\n📊 ESTABLISHING DATABASE CONNECTION...")
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
print("❌ CRITICAL: Database connection failed!")
|
||||||
|
return jsonify({"error": "Database connection failed - check MongoDB server"}), 500
|
||||||
|
|
||||||
|
print("✅ Database connection established successfully!")
|
||||||
|
|
||||||
|
# Test database write capability
|
||||||
|
try:
|
||||||
|
test_doc = {"test": "connection", "timestamp": datetime.now()}
|
||||||
|
test_result = db.test_collection.insert_one(test_doc)
|
||||||
|
db.test_collection.delete_one({"_id": test_result.inserted_id})
|
||||||
|
print("✅ Database write test successful!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Database write test failed: {e}")
|
||||||
|
return jsonify({"error": "Database is not writable"}), 500
|
||||||
|
|
||||||
|
# ✅ Check if certificate already exists
|
||||||
|
print(f"\n🔍 Checking for existing certificate...")
|
||||||
|
try:
|
||||||
|
existing_certificate = db.certificates.find_one({
|
||||||
|
"user_id": user_id,
|
||||||
|
"course_id": data['course_id']
|
||||||
|
})
|
||||||
|
|
||||||
|
if existing_certificate:
|
||||||
|
print(f"📜 Certificate already exists: {existing_certificate['certificate_id']}")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificate": {
|
||||||
|
"certificate_id": existing_certificate['certificate_id'],
|
||||||
|
"user_name": student_entered_name, # Always return entered name
|
||||||
|
"course_title": existing_certificate.get('course_title', 'Course'),
|
||||||
|
"mentor_name": existing_certificate.get('instructor_name', existing_certificate.get('mentor_name', 'OpenLearnX Instructor')),
|
||||||
|
"completion_date": existing_certificate['completion_date'],
|
||||||
|
"share_code": existing_certificate.get('share_code'),
|
||||||
|
"public_url": existing_certificate.get('public_url'),
|
||||||
|
"unique_url": f"/certificate/{existing_certificate.get('share_code')}",
|
||||||
|
"message": "Certificate already exists!"
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error checking existing certificates: {e}")
|
||||||
|
|
||||||
|
# Get course information
|
||||||
|
print(f"\n📚 Getting course information...")
|
||||||
|
try:
|
||||||
|
course = db.courses.find_one({"id": data['course_id']})
|
||||||
|
if not course:
|
||||||
|
print(f"⚠️ Course not found, creating default")
|
||||||
|
course = {
|
||||||
|
"id": data['course_id'],
|
||||||
|
"title": data.get('course_title', f"Course {data['course_id']}"),
|
||||||
|
"mentor": "OpenLearnX Instructor"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(f"✅ Course found: {course['title']}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error finding course: {e}")
|
||||||
|
course = {
|
||||||
|
"id": data['course_id'],
|
||||||
|
"title": data.get('course_title', f"Course {data['course_id']}"),
|
||||||
|
"mentor": "OpenLearnX Instructor"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ✅ GENERATE UNIQUE IDs
|
||||||
|
print(f"\n🆔 Generating unique IDs...")
|
||||||
|
certificate_id = generate_truly_unique_certificate_id()
|
||||||
|
share_code = generate_unique_share_code()
|
||||||
|
token_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
print(f"🆔 Certificate ID: {certificate_id}")
|
||||||
|
print(f"🔗 Share Code: {share_code}")
|
||||||
|
print(f"🎫 Token ID: {token_id}")
|
||||||
|
|
||||||
|
# Check for ID collisions in database
|
||||||
|
print(f"\n🔍 Checking for ID collisions...")
|
||||||
|
max_attempts = 10
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
existing_cert = db.certificates.find_one({"certificate_id": certificate_id})
|
||||||
|
existing_share = db.certificates.find_one({"share_code": share_code})
|
||||||
|
|
||||||
|
if not existing_cert and not existing_share:
|
||||||
|
print(f"✅ IDs are unique (checked attempt {attempt + 1})")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f"⚠️ ID collision detected on attempt {attempt + 1}, regenerating...")
|
||||||
|
certificate_id = generate_truly_unique_certificate_id()
|
||||||
|
share_code = generate_unique_share_code()
|
||||||
|
|
||||||
|
# Get instructor name (separate from student)
|
||||||
|
instructor_name = course.get('mentor', 'OpenLearnX Instructor')
|
||||||
|
if isinstance(instructor_name, dict):
|
||||||
|
instructor_name = instructor_name.get('name', 'OpenLearnX Instructor')
|
||||||
|
|
||||||
|
# Prevent student name from being used as instructor
|
||||||
|
if instructor_name == student_entered_name:
|
||||||
|
instructor_name = 'OpenLearnX Instructor'
|
||||||
|
|
||||||
|
print(f"\n👥 Names configured:")
|
||||||
|
print(f" 🎓 Student: '{student_entered_name}'")
|
||||||
|
print(f" 👨🏫 Instructor: '{instructor_name}'")
|
||||||
|
|
||||||
|
# Get wallet information
|
||||||
|
wallet_id = wallet_address or data.get('wallet_id', f'test-wallet-{int(time.time())}')
|
||||||
|
|
||||||
|
# ✅ CREATE COMPLETE CERTIFICATE DOCUMENT
|
||||||
|
print(f"\n📄 Creating certificate document...")
|
||||||
|
certificate_document = {
|
||||||
|
# ✅ UNIQUE IDENTIFIERS
|
||||||
|
"certificate_id": certificate_id,
|
||||||
|
"token_id": token_id,
|
||||||
|
"share_code": share_code,
|
||||||
|
|
||||||
|
# ✅ STUDENT INFORMATION (EXPLICIT)
|
||||||
|
"student_name": student_entered_name, # Explicit student field
|
||||||
|
"user_name": student_entered_name, # Main name field
|
||||||
|
|
||||||
|
# ✅ USER & COURSE INFO
|
||||||
|
"user_id": user_id,
|
||||||
|
"course_id": data['course_id'],
|
||||||
|
"course_title": course['title'],
|
||||||
|
|
||||||
|
# ✅ INSTRUCTOR INFORMATION (SEPARATE)
|
||||||
|
"mentor_name": instructor_name, # Instructor name
|
||||||
|
"instructor_name": instructor_name, # Explicit instructor field
|
||||||
|
"course_mentor": instructor_name, # Backward compatibility
|
||||||
|
|
||||||
|
# ✅ WALLET & BLOCKCHAIN
|
||||||
|
"wallet_address": wallet_id,
|
||||||
|
"encrypted_wallet_id": {
|
||||||
|
"iv": "test_iv_" + secrets.token_hex(8),
|
||||||
|
"encrypted": "test_encrypted_" + secrets.token_hex(8),
|
||||||
|
"algorithm": "AES-256-CBC"
|
||||||
|
},
|
||||||
|
|
||||||
|
# ✅ TIMESTAMPS
|
||||||
|
"completion_date": datetime.now().isoformat(),
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"minted_at": datetime.now().isoformat(),
|
||||||
|
|
||||||
|
# ✅ CERTIFICATE METADATA
|
||||||
|
"status": "active",
|
||||||
|
"issued_by": "OpenLearnX",
|
||||||
|
"verification_url": f"/certificates/{certificate_id}",
|
||||||
|
"share_url": f"/certificate/{share_code}",
|
||||||
|
"public_url": f"http://localhost:3000/certificate/{share_code}",
|
||||||
|
"blockchain_hash": f"0x{secrets.token_hex(32)}",
|
||||||
|
|
||||||
|
# ✅ ANALYTICS
|
||||||
|
"is_revoked": False,
|
||||||
|
"view_count": 0,
|
||||||
|
"shared_count": 0
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
# ✅ LOG COMPLETE DOCUMENT BEFORE SAVING
|
||||||
|
print(f"\n📋 CERTIFICATE DOCUMENT TO SAVE:")
|
||||||
|
print(f" 🆔 Certificate ID: {certificate_document['certificate_id']}")
|
||||||
|
print(f" 🎓 Student Name: '{certificate_document['student_name']}'")
|
||||||
|
print(f" 🎓 User Name: '{certificate_document['user_name']}'")
|
||||||
|
print(f" 👨🏫 Instructor: '{certificate_document['instructor_name']}'")
|
||||||
|
print(f" 📚 Course: '{certificate_document['course_title']}'")
|
||||||
|
print(f" 🔗 Share Code: {certificate_document['share_code']}")
|
||||||
|
|
||||||
|
# ✅ CRITICAL: SAVE TO DATABASE WITH VERIFICATION
|
||||||
|
print(f"\n💾 SAVING TO DATABASE...")
|
||||||
|
try:
|
||||||
|
# Create indexes to ensure uniqueness
|
||||||
|
try:
|
||||||
|
db.certificates.create_index([("certificate_id", 1)], unique=True, background=True)
|
||||||
|
db.certificates.create_index([("share_code", 1)], unique=True, background=True)
|
||||||
|
print("✅ Database indexes created")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Index creation warning: {e}")
|
||||||
|
|
||||||
|
# Insert the document
|
||||||
|
insert_result = db.certificates.insert_one(certificate_document)
|
||||||
|
print(f"✅ DOCUMENT INSERTED SUCCESSFULLY!")
|
||||||
|
print(f" 📊 MongoDB ID: {insert_result.inserted_id}")
|
||||||
|
print(f" 🆔 Certificate ID: {certificate_id}")
|
||||||
|
|
||||||
|
# ✅ VERIFY THE DOCUMENT WAS ACTUALLY SAVED
|
||||||
|
print(f"\n🔍 VERIFYING DOCUMENT WAS SAVED...")
|
||||||
|
saved_document = db.certificates.find_one({"certificate_id": certificate_id})
|
||||||
|
|
||||||
|
if saved_document:
|
||||||
|
print(f"✅ VERIFICATION SUCCESSFUL!")
|
||||||
|
print(f" 🆔 Saved Certificate ID: {saved_document['certificate_id']}")
|
||||||
|
print(f" 🎓 Saved Student Name: '{saved_document['student_name']}'")
|
||||||
|
print(f" 📊 MongoDB ID: {saved_document['_id']}")
|
||||||
|
else:
|
||||||
|
print(f"❌ VERIFICATION FAILED - Document not found!")
|
||||||
|
return jsonify({"error": "Failed to verify certificate was saved"}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ DATABASE SAVE ERROR: {e}")
|
||||||
|
logger.error(f"Database save error: {e}")
|
||||||
|
|
||||||
|
# Try alternative save method
|
||||||
|
if "E11000" in str(e):
|
||||||
|
print("⚠️ Duplicate key error, generating new ID...")
|
||||||
|
certificate_id = generate_truly_unique_certificate_id()
|
||||||
|
certificate_document["certificate_id"] = certificate_id
|
||||||
|
certificate_document["verification_url"] = f"/certificates/{certificate_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
insert_result = db.certificates.insert_one(certificate_document)
|
||||||
|
print(f"✅ Saved with new ID: {certificate_id}")
|
||||||
|
except Exception as retry_error:
|
||||||
|
print(f"❌ Retry failed: {retry_error}")
|
||||||
|
return jsonify({"error": "Failed to save certificate after retry"}), 500
|
||||||
|
else:
|
||||||
|
return jsonify({"error": f"Database save failed: {str(e)}"}), 500
|
||||||
|
|
||||||
|
# ✅ PREPARE RESPONSE
|
||||||
|
print(f"\n📤 PREPARING RESPONSE...")
|
||||||
|
certificate_response = {
|
||||||
|
"certificate_id": certificate_document['certificate_id'],
|
||||||
|
"token_id": certificate_document['token_id'],
|
||||||
|
"share_code": certificate_document['share_code'],
|
||||||
|
|
||||||
|
# ✅ STUDENT INFO (GUARANTEED CORRECT)
|
||||||
|
"user_name": student_entered_name,
|
||||||
|
"student_name": student_entered_name,
|
||||||
|
|
||||||
|
# ✅ COURSE INFO
|
||||||
|
"course_title": certificate_document['course_title'],
|
||||||
|
|
||||||
|
# ✅ INSTRUCTOR INFO
|
||||||
|
"mentor_name": instructor_name,
|
||||||
|
"instructor_name": instructor_name,
|
||||||
|
|
||||||
|
# ✅ OTHER INFO
|
||||||
|
"completion_date": certificate_document['completion_date'],
|
||||||
|
"verification_url": certificate_document['verification_url'],
|
||||||
|
"share_url": certificate_document['share_url'],
|
||||||
|
"public_url": certificate_document['public_url'],
|
||||||
|
"unique_url": f"/certificate/{certificate_document['share_code']}",
|
||||||
|
"blockchain_hash": certificate_document['blockchain_hash'],
|
||||||
|
"wallet_address": certificate_document['wallet_address'],
|
||||||
|
|
||||||
|
"message": f"Certificate {certificate_document['certificate_id']} created successfully for {student_entered_name}!"
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"✅ RESPONSE PREPARED:")
|
||||||
|
print(f" 🆔 Certificate ID: {certificate_response['certificate_id']}")
|
||||||
|
print(f" 🎓 Student: '{certificate_response['user_name']}'")
|
||||||
|
print(f" 👨🏫 Instructor: '{certificate_response['mentor_name']}'")
|
||||||
|
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("🎉 CERTIFICATE MINTING COMPLETED SUCCESSFULLY!")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificate": certificate_response
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ CRITICAL ERROR IN MINT_CERTIFICATE:")
|
||||||
|
print(f"Error: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
print(f"Traceback: {traceback.format_exc()}")
|
||||||
|
logger.error(f"Critical error in mint_certificate: {str(e)}")
|
||||||
|
return jsonify({"error": f"Critical error: {str(e)}"}), 500
|
||||||
|
|
||||||
|
@bp.route('/<certificate_id>', methods=['GET', 'OPTIONS'])
|
||||||
|
def get_certificate_by_id(certificate_id):
|
||||||
|
"""Get certificate by ID with proper database access"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"🔍 Getting certificate with ID: {certificate_id}")
|
||||||
|
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
# Search by certificate_id or share_code
|
||||||
|
certificate = db.certificates.find_one({
|
||||||
|
"$or": [
|
||||||
|
{"certificate_id": certificate_id},
|
||||||
|
{"share_code": certificate_id},
|
||||||
|
{"certificate_id": {"$regex": f"^{certificate_id}$", "$options": "i"}},
|
||||||
|
{"share_code": {"$regex": f"^{certificate_id}$", "$options": "i"}}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if not certificate:
|
||||||
|
return jsonify({"error": "Certificate not found"}), 404
|
||||||
|
|
||||||
|
if certificate.get('is_revoked', False):
|
||||||
|
return jsonify({"error": "Certificate has been revoked"}), 410
|
||||||
|
|
||||||
|
# Increment view count
|
||||||
|
try:
|
||||||
|
db.certificates.update_one(
|
||||||
|
{"_id": certificate["_id"]},
|
||||||
|
{"$inc": {"view_count": 1}}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to increment view count: {e}")
|
||||||
|
|
||||||
|
# Return with proper field mapping
|
||||||
|
certificate_response = {
|
||||||
|
"certificate_id": certificate['certificate_id'],
|
||||||
|
"share_code": certificate.get('share_code'),
|
||||||
|
"user_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
||||||
|
"student_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
||||||
|
"course_title": certificate['course_title'],
|
||||||
|
"mentor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
"instructor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
"completion_date": certificate['completion_date'],
|
||||||
|
"status": certificate.get('status', 'active'),
|
||||||
|
"issued_by": certificate.get('issued_by', 'OpenLearnX'),
|
||||||
|
"blockchain_hash": certificate.get('blockchain_hash'),
|
||||||
|
"wallet_address": certificate.get('wallet_address'),
|
||||||
|
"view_count": certificate.get('view_count', 0),
|
||||||
|
"public_url": certificate.get('public_url'),
|
||||||
|
"is_verified": True,
|
||||||
|
"is_revoked": certificate.get('is_revoked', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificate": certificate_response
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting certificate: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to fetch certificate"}), 500
|
||||||
|
|
||||||
|
@bp.route('/verify/<share_code>', methods=['GET', 'OPTIONS'])
|
||||||
|
def verify_certificate_by_code(share_code):
|
||||||
|
"""Verify certificate by share code"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"🔍 Verifying certificate with code: {share_code}")
|
||||||
|
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"verified": False,
|
||||||
|
"message": "Database connection failed"
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
certificate = db.certificates.find_one({
|
||||||
|
"$or": [
|
||||||
|
{"share_code": share_code},
|
||||||
|
{"certificate_id": share_code},
|
||||||
|
{"share_code": {"$regex": f"^{share_code}$", "$options": "i"}},
|
||||||
|
{"certificate_id": {"$regex": f"^{share_code}$", "$options": "i"}}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if not certificate:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"verified": False,
|
||||||
|
"message": "Certificate not found"
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if certificate.get('is_revoked', False):
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"verified": False,
|
||||||
|
"message": "Certificate has been revoked"
|
||||||
|
}), 410
|
||||||
|
|
||||||
|
# Increment view count
|
||||||
|
try:
|
||||||
|
db.certificates.update_one(
|
||||||
|
{"_id": certificate["_id"]},
|
||||||
|
{"$inc": {"view_count": 1}}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to increment view count: {e}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"verified": True,
|
||||||
|
"certificate": {
|
||||||
|
"certificate_id": certificate['certificate_id'],
|
||||||
|
"share_code": certificate.get('share_code'),
|
||||||
|
"student_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
||||||
|
"course_title": certificate['course_title'],
|
||||||
|
"instructor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
"completion_date": certificate['completion_date'],
|
||||||
|
"issued_by": certificate.get('issued_by', 'OpenLearnX'),
|
||||||
|
"blockchain_hash": certificate.get('blockchain_hash'),
|
||||||
|
"view_count": certificate.get('view_count', 0)
|
||||||
|
},
|
||||||
|
"message": "Certificate is valid and verified"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error verifying certificate: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"verified": False,
|
||||||
|
"message": "Verification failed"
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@bp.route('/user/<user_id>', methods=['GET', 'OPTIONS'])
|
||||||
|
def get_user_certificates(user_id):
|
||||||
|
"""Get all certificates for a user"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
token = auth_header.replace('Bearer ', '')
|
||||||
|
token_user_id, wallet_address = get_user_from_token(token)
|
||||||
|
|
||||||
|
if token_user_id and token_user_id != user_id:
|
||||||
|
return jsonify({"error": "Unauthorized"}), 403
|
||||||
|
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
certificates = list(db.certificates.find(
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"_id": 0, "encrypted_wallet_id": 0}
|
||||||
|
).sort("created_at", -1))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificates": certificates,
|
||||||
|
"count": len(certificates),
|
||||||
|
"user_id": user_id
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting user certificates: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to retrieve certificates"}), 500
|
||||||
|
|
||||||
|
@bp.route('/download/<certificate_id>', methods=['GET', 'OPTIONS'])
|
||||||
|
def download_certificate(certificate_id):
|
||||||
|
"""Download certificate as HTML for PDF conversion"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
certificate = db.certificates.find_one({
|
||||||
|
"$or": [
|
||||||
|
{"certificate_id": certificate_id},
|
||||||
|
{"share_code": certificate_id}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if not certificate:
|
||||||
|
return jsonify({"error": "Certificate not found"}), 404
|
||||||
|
|
||||||
|
if certificate.get('is_revoked', False):
|
||||||
|
return jsonify({"error": "Certificate has been revoked"}), 410
|
||||||
|
|
||||||
|
# Generate HTML for PDF
|
||||||
|
certificate_html = generate_certificate_html(certificate)
|
||||||
|
|
||||||
|
return certificate_html, 200, {
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
'Content-Disposition': f'attachment; filename="Certificate_{certificate["certificate_id"]}.html"'
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading certificate: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to download certificate"}), 500
|
||||||
|
|
||||||
|
@bp.route('/share/<certificate_id>', methods=['POST', 'OPTIONS'])
|
||||||
|
def track_certificate_share(certificate_id):
|
||||||
|
"""Track certificate sharing"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
result = db.certificates.update_one(
|
||||||
|
{
|
||||||
|
"$or": [
|
||||||
|
{"certificate_id": certificate_id},
|
||||||
|
{"share_code": certificate_id}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"$inc": {"shared_count": 1}}
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.matched_count == 0:
|
||||||
|
return jsonify({"error": "Certificate not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": "Share tracked successfully"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error tracking share: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to track share"}), 500
|
||||||
|
|
||||||
|
@bp.route('/test-db', methods=['GET'])
|
||||||
|
def test_database():
|
||||||
|
"""Test database connectivity and write capability"""
|
||||||
|
try:
|
||||||
|
print("🧪 Testing database connection...")
|
||||||
|
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
# Test write
|
||||||
|
test_doc = {
|
||||||
|
"test_id": str(uuid.uuid4()),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"message": "Database test document"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = db.test_certificates.insert_one(test_doc)
|
||||||
|
|
||||||
|
# Test read
|
||||||
|
saved_doc = db.test_certificates.find_one({"_id": result.inserted_id})
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
db.test_certificates.delete_one({"_id": result.inserted_id})
|
||||||
|
|
||||||
|
# Check existing certificates
|
||||||
|
cert_count = db.certificates.count_documents({})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"database_connection": "working",
|
||||||
|
"write_test": "successful",
|
||||||
|
"read_test": "successful",
|
||||||
|
"existing_certificates": cert_count,
|
||||||
|
"test_document_id": str(result.inserted_id),
|
||||||
|
"message": "Database is working properly!"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Database test failed: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Database test failed"
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@bp.route('/list-all', methods=['GET'])
|
||||||
|
def list_all_certificates():
|
||||||
|
"""List all certificates in the database"""
|
||||||
|
try:
|
||||||
|
db = get_db_connection()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
certificates = list(db.certificates.find({}, {"_id": 0}).sort("created_at", -1))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificates": certificates,
|
||||||
|
"count": len(certificates),
|
||||||
|
"message": f"Found {len(certificates)} certificates in database"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error listing certificates: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route('/test-generation', methods=['GET'])
|
||||||
|
def test_generation():
|
||||||
|
"""Test certificate ID generation"""
|
||||||
|
try:
|
||||||
|
ids = []
|
||||||
|
for i in range(10):
|
||||||
|
cert_id = generate_truly_unique_certificate_id()
|
||||||
|
share_code = generate_unique_share_code()
|
||||||
|
ids.append({
|
||||||
|
"attempt": i + 1,
|
||||||
|
"certificate_id": cert_id,
|
||||||
|
"share_code": share_code,
|
||||||
|
"timestamp": time.time()
|
||||||
|
})
|
||||||
|
time.sleep(0.01) # Small delay
|
||||||
|
|
||||||
|
# Check for duplicates
|
||||||
|
cert_ids = [item["certificate_id"] for item in ids]
|
||||||
|
share_codes = [item["share_code"] for item in ids]
|
||||||
|
|
||||||
|
cert_duplicates = len(cert_ids) != len(set(cert_ids))
|
||||||
|
share_duplicates = len(share_codes) != len(set(share_codes))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"generated_ids": ids,
|
||||||
|
"certificate_id_duplicates": cert_duplicates,
|
||||||
|
"share_code_duplicates": share_duplicates,
|
||||||
|
"unique_cert_ids": len(set(cert_ids)),
|
||||||
|
"unique_share_codes": len(set(share_codes)),
|
||||||
|
"message": "All IDs should be unique!" if not cert_duplicates and not share_duplicates else "Duplicates detected!"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
def generate_certificate_html(certificate):
|
||||||
|
"""Generate HTML for certificate PDF download"""
|
||||||
|
student_name = certificate.get('student_name', certificate.get('user_name', 'Student'))
|
||||||
|
instructor_name = certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor')))
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - {student_name}</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
body {{
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.certificate {{
|
||||||
|
background: white;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 60px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 25px 50px rgba(0,0,0,0.15);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
border: 8px solid #4f46e5;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.title {{
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4f46e5;
|
||||||
|
margin: 20px 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.student-name {{
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 48px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 40px 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 3px solid #4f46e5;
|
||||||
|
border-bottom: 3px solid #4f46e5;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.course-title {{
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.cert-id {{
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.mentor-section {{
|
||||||
|
margin-top: 50px;
|
||||||
|
padding-top: 30px;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.mentor-name {{
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate">
|
||||||
|
<div style="font-size: 60px; margin-bottom: 20px;">🏆</div>
|
||||||
|
<h1 class="title">CERTIFICATE OF COMPLETION</h1>
|
||||||
|
|
||||||
|
<div style="font-size: 18px; color: #6b7280; margin-bottom: 30px;">This is to certify that</div>
|
||||||
|
|
||||||
|
<div class="student-name">{student_name}</div>
|
||||||
|
|
||||||
|
<div style="font-size: 18px; color: #6b7280; margin-bottom: 20px;">has successfully completed the course</div>
|
||||||
|
<div class="course-title">"{certificate['course_title']}"</div>
|
||||||
|
|
||||||
|
<div style="font-size: 16px; color: #374151; margin: 20px 0;">
|
||||||
|
✅ Completed on: {datetime.fromisoformat(certificate['completion_date']).strftime('%B %d, %Y')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mentor-section">
|
||||||
|
<div style="width: 200px; height: 2px; background: #6b7280; margin: 0 auto 10px auto;"></div>
|
||||||
|
<div class="mentor-name">{instructor_name}</div>
|
||||||
|
<div style="font-size: 14px; color: #6b7280; margin-top: 5px;">Course Instructor</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-id">
|
||||||
|
<strong>Certificate ID: {certificate['certificate_id']}</strong><br>
|
||||||
|
OpenLearnX Learning Platform<br>
|
||||||
|
<span style="color: #7c3aed;">🔒 Blockchain Verified Completion</span>
|
||||||
|
{f'<br><small>Blockchain Hash: {certificate.get("blockchain_hash", "")}</small>' if certificate.get('blockchain_hash') else ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import logging
|
||||||
|
from bson import ObjectId
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
# Import your certificate manager
|
||||||
|
from models.certificate import CertificateManager
|
||||||
|
|
||||||
|
bp = Blueprint('certificates', __name__)
|
||||||
|
cert_manager = CertificateManager()
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ✅ FIXED: Database connection function
|
||||||
|
def get_db():
|
||||||
|
"""Get MongoDB database connection"""
|
||||||
|
try:
|
||||||
|
client = MongoClient(os.getenv('MONGODB_URI', 'mongodb://localhost:27017/'))
|
||||||
|
db = client.openlearnx
|
||||||
|
# Test the connection
|
||||||
|
db.command('ismaster')
|
||||||
|
return db
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Database connection failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@bp.route("/certificates", methods=["POST", "OPTIONS"])
|
||||||
|
def create_certificate():
|
||||||
|
"""Create a new certificate with GUARANTEED unique ID and fixed student name handling"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
logger.info(f"📝 Certificate creation request: {data}")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['user_name', 'course_id', 'wallet_id', 'user_id']
|
||||||
|
for field in required_fields:
|
||||||
|
if not data.get(field):
|
||||||
|
logger.error(f"❌ Missing required field: {field}")
|
||||||
|
return jsonify({"error": f"Missing required field: {field}"}), 400
|
||||||
|
|
||||||
|
# ✅ CRITICAL FIX: Get the STUDENT's entered name (exactly as they typed it)
|
||||||
|
student_entered_name = data.get('user_name', '').strip()
|
||||||
|
if not student_entered_name:
|
||||||
|
logger.error("❌ Student name cannot be empty")
|
||||||
|
return jsonify({"error": "Student name is required"}), 400
|
||||||
|
|
||||||
|
logger.info(f"🎓 Processing certificate for STUDENT: '{student_entered_name}'")
|
||||||
|
|
||||||
|
# Get database connection
|
||||||
|
db = get_db()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
# ✅ Check if certificate already exists for this user and course
|
||||||
|
existing_certificate = db.certificates.find_one({
|
||||||
|
"user_id": data['user_id'],
|
||||||
|
"course_id": data['course_id']
|
||||||
|
})
|
||||||
|
|
||||||
|
if existing_certificate is not None:
|
||||||
|
logger.info(f"📜 Certificate already exists for STUDENT: '{student_entered_name}'")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificate": {
|
||||||
|
"certificate_id": existing_certificate['certificate_id'],
|
||||||
|
"user_name": student_entered_name, # ✅ FORCE RETURN STUDENT'S ENTERED NAME
|
||||||
|
"course_title": existing_certificate['course_title'],
|
||||||
|
"mentor_name": existing_certificate.get('mentor_name', existing_certificate.get('course_mentor', 'OpenLearnX Instructor')),
|
||||||
|
"completion_date": existing_certificate['completion_date'],
|
||||||
|
"share_code": existing_certificate.get('share_code'),
|
||||||
|
"public_url": existing_certificate.get('public_url'),
|
||||||
|
"unique_url": f"/certificate/{existing_certificate.get('share_code', existing_certificate['certificate_id'])}",
|
||||||
|
"message": "Certificate already exists!"
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
# Check if course exists
|
||||||
|
try:
|
||||||
|
course = db.courses.find_one({"id": data['course_id']})
|
||||||
|
if course is None:
|
||||||
|
return jsonify({"error": "Course not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error finding course: {e}")
|
||||||
|
return jsonify({"error": "Failed to verify course"}), 500
|
||||||
|
|
||||||
|
# ✅ CRITICAL FIX: GUARANTEED UNIQUE ID GENERATION
|
||||||
|
certificate_id = None
|
||||||
|
share_code = None
|
||||||
|
max_attempts = 50
|
||||||
|
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
# Generate new IDs using enhanced method
|
||||||
|
temp_cert_id = generate_unique_certificate_id()
|
||||||
|
temp_share_code = generate_unique_share_code()
|
||||||
|
|
||||||
|
logger.info(f"🆔 Attempt {attempt + 1}: Generated cert_id={temp_cert_id}, share_code={temp_share_code}")
|
||||||
|
|
||||||
|
# Check if IDs already exist in database
|
||||||
|
existing_cert_id = db.certificates.find_one({"certificate_id": temp_cert_id})
|
||||||
|
existing_share_code = db.certificates.find_one({"share_code": temp_share_code})
|
||||||
|
|
||||||
|
if not existing_cert_id and not existing_share_code:
|
||||||
|
certificate_id = temp_cert_id
|
||||||
|
share_code = temp_share_code
|
||||||
|
logger.info(f"✅ UNIQUE IDs confirmed: cert_id={certificate_id}, share_code={share_code}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ ID collision detected on attempt {attempt + 1}")
|
||||||
|
time.sleep(0.001) # Small delay to ensure timestamp changes
|
||||||
|
|
||||||
|
if not certificate_id or not share_code:
|
||||||
|
logger.error(f"❌ Failed to generate unique IDs after {max_attempts} attempts")
|
||||||
|
return jsonify({"error": "Failed to generate unique certificate ID"}), 500
|
||||||
|
|
||||||
|
# Generate token ID
|
||||||
|
token_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Encrypt wallet ID
|
||||||
|
encrypted_wallet = cert_manager.encrypt_wallet_id(data['wallet_id'])
|
||||||
|
if not encrypted_wallet:
|
||||||
|
return jsonify({"error": "Failed to encrypt wallet ID"}), 500
|
||||||
|
|
||||||
|
# ✅ CRITICAL FIX: Extract INSTRUCTOR name from course (separate from student)
|
||||||
|
instructor_name = course.get('mentor', 'OpenLearnX Instructor')
|
||||||
|
if isinstance(instructor_name, dict):
|
||||||
|
instructor_name = instructor_name.get('name', 'OpenLearnX Instructor')
|
||||||
|
|
||||||
|
# ✅ PREVENT STUDENT NAME FROM BEING USED AS INSTRUCTOR NAME
|
||||||
|
if instructor_name == student_entered_name:
|
||||||
|
instructor_name = 'OpenLearnX Instructor'
|
||||||
|
|
||||||
|
logger.info(f"🎓 FINAL VERIFICATION - STUDENT: '{student_entered_name}' | INSTRUCTOR: '{instructor_name}'")
|
||||||
|
|
||||||
|
# ✅ Create certificate document with GUARANTEED UNIQUE IDs and proper field separation
|
||||||
|
certificate = {
|
||||||
|
"certificate_id": certificate_id, # ✅ GUARANTEED UNIQUE
|
||||||
|
"token_id": token_id,
|
||||||
|
"share_code": share_code, # ✅ GUARANTEED UNIQUE
|
||||||
|
"student_name": student_entered_name, # ✅ EXPLICIT STUDENT FIELD
|
||||||
|
"user_name": student_entered_name, # ✅ STUDENT'S ENTERED NAME
|
||||||
|
"user_id": data['user_id'],
|
||||||
|
"course_id": data['course_id'],
|
||||||
|
"course_title": course['title'],
|
||||||
|
"mentor_name": instructor_name, # ✅ INSTRUCTOR NAME
|
||||||
|
"instructor_name": instructor_name, # ✅ EXPLICIT INSTRUCTOR FIELD
|
||||||
|
"course_mentor": instructor_name, # ✅ BACKWARD COMPATIBILITY
|
||||||
|
"encrypted_wallet_id": encrypted_wallet,
|
||||||
|
"completion_date": datetime.now().isoformat(),
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"status": "active",
|
||||||
|
"issued_by": "OpenLearnX",
|
||||||
|
"verification_url": f"/certificates/{certificate_id}",
|
||||||
|
"share_url": f"/certificate/{share_code}",
|
||||||
|
"public_url": f"{request.host_url}certificate/{share_code}",
|
||||||
|
"blockchain_hash": None,
|
||||||
|
"is_revoked": False,
|
||||||
|
"view_count": 0,
|
||||||
|
"shared_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ✅ Save to MongoDB with enhanced error handling
|
||||||
|
try:
|
||||||
|
# Create indexes for uniqueness
|
||||||
|
db.certificates.create_index([("certificate_id", 1)], unique=True, background=True)
|
||||||
|
db.certificates.create_index([("share_code", 1)], unique=True, background=True)
|
||||||
|
|
||||||
|
result = db.certificates.insert_one(certificate)
|
||||||
|
logger.info(f"✅ Certificate saved successfully for STUDENT: '{student_entered_name}' with unique ID: {certificate_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Database save error: {e}")
|
||||||
|
return jsonify({"error": "Failed to save certificate to database"}), 500
|
||||||
|
|
||||||
|
# ✅ Return response with GUARANTEED STUDENT NAME and UNIQUE IDs
|
||||||
|
certificate_response = {
|
||||||
|
"certificate_id": certificate_id, # ✅ GUARANTEED UNIQUE ID
|
||||||
|
"token_id": token_id,
|
||||||
|
"share_code": share_code, # ✅ GUARANTEED UNIQUE SHARE CODE
|
||||||
|
"user_name": student_entered_name, # ✅ STUDENT'S ENTERED NAME (GUARANTEED)
|
||||||
|
"student_name": student_entered_name, # ✅ EXPLICIT STUDENT NAME
|
||||||
|
"course_title": course['title'],
|
||||||
|
"mentor_name": instructor_name, # ✅ INSTRUCTOR NAME
|
||||||
|
"instructor_name": instructor_name, # ✅ EXPLICIT INSTRUCTOR NAME
|
||||||
|
"course_mentor": instructor_name, # ✅ BACKWARD COMPATIBILITY
|
||||||
|
"completion_date": certificate['completion_date'],
|
||||||
|
"verification_url": certificate['verification_url'],
|
||||||
|
"share_url": certificate['share_url'],
|
||||||
|
"public_url": certificate['public_url'],
|
||||||
|
"unique_url": f"/certificate/{share_code}",
|
||||||
|
"message": f"Certificate with UNIQUE ID {certificate_id} generated successfully for {student_entered_name}!"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"📤 RETURNING CERTIFICATE with unique ID: {certificate_id} for STUDENT: {student_entered_name}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificate": certificate_response
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Unexpected error creating certificate: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to create certificate"}), 500
|
||||||
|
|
||||||
|
@bp.route("/certificates/<certificate_id>", methods=["GET", "OPTIONS"])
|
||||||
|
def get_certificate(certificate_id):
|
||||||
|
"""Get certificate by ID with proper field mapping"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
certificate = db.certificates.find_one({"certificate_id": certificate_id})
|
||||||
|
|
||||||
|
if not certificate:
|
||||||
|
return jsonify({"error": "Certificate not found"}), 404
|
||||||
|
|
||||||
|
# Check if certificate is revoked
|
||||||
|
if certificate.get('is_revoked', False):
|
||||||
|
return jsonify({"error": "Certificate has been revoked"}), 410
|
||||||
|
|
||||||
|
# ✅ Decrypt wallet ID for display
|
||||||
|
decrypted_wallet = None
|
||||||
|
if certificate.get('encrypted_wallet_id'):
|
||||||
|
decrypted_wallet = cert_manager.decrypt_wallet_id(certificate['encrypted_wallet_id'])
|
||||||
|
|
||||||
|
# ✅ Prepare response with proper field mapping (prioritize explicit fields)
|
||||||
|
certificate_response = {
|
||||||
|
"certificate_id": certificate['certificate_id'],
|
||||||
|
"token_id": certificate.get('token_id'),
|
||||||
|
"share_code": certificate.get('share_code'),
|
||||||
|
# ✅ STUDENT NAME (prioritize explicit student_name field)
|
||||||
|
"user_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
||||||
|
"student_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
||||||
|
# ✅ COURSE INFO
|
||||||
|
"course_title": certificate['course_title'],
|
||||||
|
# ✅ INSTRUCTOR NAME (prioritize explicit instructor_name field)
|
||||||
|
"mentor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
"instructor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
"course_mentor": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
# ✅ OTHER INFO
|
||||||
|
"completion_date": certificate['completion_date'],
|
||||||
|
"status": certificate['status'],
|
||||||
|
"wallet_id": decrypted_wallet,
|
||||||
|
"issued_by": certificate.get('issued_by', 'OpenLearnX'),
|
||||||
|
"verification_url": certificate.get('verification_url'),
|
||||||
|
"share_url": certificate.get('share_url'),
|
||||||
|
"public_url": certificate.get('public_url'),
|
||||||
|
"unique_url": f"/certificate/{certificate.get('share_code', certificate_id)}",
|
||||||
|
"view_count": certificate.get('view_count', 0),
|
||||||
|
"blockchain_hash": certificate.get('blockchain_hash'),
|
||||||
|
"is_verified": True,
|
||||||
|
"is_revoked": certificate.get('is_revoked', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificate": certificate_response
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching certificate: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to fetch certificate"}), 500
|
||||||
|
|
||||||
|
# ✅ UNIQUE CERTIFICATE VIEW ENDPOINT
|
||||||
|
@bp.route("/certificate/<share_code>", methods=["GET", "OPTIONS"])
|
||||||
|
def view_certificate_by_code(share_code):
|
||||||
|
"""View certificate by unique share code"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
# Find certificate by share code
|
||||||
|
certificate = db.certificates.find_one({"share_code": share_code})
|
||||||
|
|
||||||
|
if certificate is None:
|
||||||
|
return jsonify({"error": "Certificate not found"}), 404
|
||||||
|
|
||||||
|
# Check if certificate is revoked
|
||||||
|
if certificate.get('is_revoked', False):
|
||||||
|
return jsonify({"error": "Certificate has been revoked"}), 410
|
||||||
|
|
||||||
|
# ✅ INCREMENT VIEW COUNT
|
||||||
|
db.certificates.update_one(
|
||||||
|
{"share_code": share_code},
|
||||||
|
{"$inc": {"view_count": 1}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decrypt wallet ID for display
|
||||||
|
decrypted_wallet = None
|
||||||
|
if certificate.get('encrypted_wallet_id') is not None:
|
||||||
|
decrypted_wallet = cert_manager.decrypt_wallet_id(certificate['encrypted_wallet_id'])
|
||||||
|
|
||||||
|
# ✅ PREPARE RESPONSE WITH GUARANTEED STUDENT NAME
|
||||||
|
certificate_response = {
|
||||||
|
"certificate_id": certificate['certificate_id'],
|
||||||
|
"share_code": certificate['share_code'],
|
||||||
|
"user_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
||||||
|
"student_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
||||||
|
"course_title": certificate['course_title'],
|
||||||
|
"mentor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
"instructor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
||||||
|
"completion_date": certificate['completion_date'],
|
||||||
|
"status": certificate['status'],
|
||||||
|
"wallet_id": decrypted_wallet,
|
||||||
|
"issued_by": certificate.get('issued_by', 'OpenLearnX'),
|
||||||
|
"verification_url": certificate.get('verification_url'),
|
||||||
|
"share_url": certificate.get('share_url'),
|
||||||
|
"public_url": certificate.get('public_url'),
|
||||||
|
"view_count": certificate.get('view_count', 0),
|
||||||
|
"is_verified": True,
|
||||||
|
"is_revoked": certificate.get('is_revoked', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificate": certificate_response
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching certificate by code: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to fetch certificate"}), 500
|
||||||
|
|
||||||
|
@bp.route("/certificates/user/<user_id>", methods=["GET", "OPTIONS"])
|
||||||
|
def get_user_certificates(user_id):
|
||||||
|
"""Get all certificates for a user"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
certificates = list(db.certificates.find(
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"_id": 0, "encrypted_wallet_id": 0}
|
||||||
|
))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificates": certificates,
|
||||||
|
"count": len(certificates)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching user certificates: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to fetch certificates"}), 500
|
||||||
|
|
||||||
|
# ✅ SHARE TRACKING ENDPOINT
|
||||||
|
@bp.route("/certificates/<certificate_id>/share", methods=["POST", "OPTIONS"])
|
||||||
|
def track_certificate_share(certificate_id):
|
||||||
|
"""Track certificate sharing"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
# Increment share count
|
||||||
|
result = db.certificates.update_one(
|
||||||
|
{"certificate_id": certificate_id},
|
||||||
|
{"$inc": {"shared_count": 1}}
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.matched_count == 0:
|
||||||
|
return jsonify({"error": "Certificate not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": "Share tracked successfully"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error tracking share: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to track share"}), 500
|
||||||
|
|
||||||
|
@bp.route("/admin/certificates", methods=["GET", "OPTIONS"])
|
||||||
|
def get_all_certificates():
|
||||||
|
"""Admin endpoint to get all certificates"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check admin authentication
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
if auth_header is None or not auth_header.startswith('Bearer '):
|
||||||
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
|
token = auth_header.split(' ')[1]
|
||||||
|
expected_token = os.getenv('ADMIN_TOKEN', 'admin-secret-key')
|
||||||
|
|
||||||
|
if token != expected_token:
|
||||||
|
return jsonify({"error": "Invalid admin token"}), 401
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
if db is None:
|
||||||
|
return jsonify({"error": "Database connection failed"}), 500
|
||||||
|
|
||||||
|
# Add pagination
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
limit = int(request.args.get('limit', 10))
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
certificates = list(db.certificates.find(
|
||||||
|
{},
|
||||||
|
{"_id": 0, "encrypted_wallet_id": 0}
|
||||||
|
).skip(skip).limit(limit).sort("created_at", -1))
|
||||||
|
|
||||||
|
total = db.certificates.count_documents({})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"certificates": certificates,
|
||||||
|
"pagination": {
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total": total,
|
||||||
|
"pages": (total + limit - 1) // limit
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching certificates: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to fetch certificates"}), 500
|
||||||
|
|
||||||
|
# ✅ HELPER FUNCTIONS FOR UNIQUE ID GENERATION
|
||||||
|
def generate_unique_certificate_id():
|
||||||
|
"""Generate truly unique certificate ID with multiple randomness sources"""
|
||||||
|
# High-precision timestamp (microseconds)
|
||||||
|
timestamp_micro = str(int(time.time() * 1000000))[-8:]
|
||||||
|
|
||||||
|
# Cryptographically secure random
|
||||||
|
crypto_random = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(4))
|
||||||
|
|
||||||
|
# Combined for uniqueness
|
||||||
|
certificate_id = (timestamp_micro + crypto_random)[:12]
|
||||||
|
|
||||||
|
# Ensure exactly 12 characters
|
||||||
|
if len(certificate_id) < 12:
|
||||||
|
certificate_id += ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(12 - len(certificate_id)))
|
||||||
|
|
||||||
|
return certificate_id
|
||||||
|
|
||||||
|
def generate_unique_share_code():
|
||||||
|
"""Generate unique share code with timestamp"""
|
||||||
|
# High precision timestamp
|
||||||
|
timestamp = str(int(time.time_ns()))[-4:]
|
||||||
|
|
||||||
|
# Cryptographically secure random
|
||||||
|
crypto_random = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4))
|
||||||
|
|
||||||
|
return timestamp + crypto_random
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { Calendar, User, BookOpen, Wallet, Award, Share2, Download, CheckCircle, AlertCircle } from "lucide-react"
|
||||||
|
import { toast } from "react-hot-toast"
|
||||||
|
|
||||||
|
interface Certificate {
|
||||||
|
certificate_id: string
|
||||||
|
share_code: string
|
||||||
|
user_name: string
|
||||||
|
student_name: string
|
||||||
|
course_title: string
|
||||||
|
mentor_name: string
|
||||||
|
instructor_name: string
|
||||||
|
completion_date: string
|
||||||
|
wallet_address?: string
|
||||||
|
issued_by: string
|
||||||
|
view_count: number
|
||||||
|
blockchain_hash?: string
|
||||||
|
public_url?: string
|
||||||
|
verification_url?: string
|
||||||
|
is_verified: boolean
|
||||||
|
is_revoked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CertificatePage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const [certificate, setCertificate] = useState<Certificate | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const certificateId = params?.id as string
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (certificateId) {
|
||||||
|
fetchCertificate(certificateId)
|
||||||
|
}
|
||||||
|
}, [certificateId])
|
||||||
|
|
||||||
|
const fetchCertificate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
console.log(`🔍 Fetching certificate with ID: ${id}`)
|
||||||
|
|
||||||
|
let response = null
|
||||||
|
|
||||||
|
// Try verify endpoint first
|
||||||
|
try {
|
||||||
|
console.log(`🔍 Trying verify endpoint: /api/certificate/verify/${id}`)
|
||||||
|
response = await fetch(`http://127.0.0.1:5000/api/certificate/verify/${id}`)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.verified) {
|
||||||
|
console.log('✅ Found certificate by verify endpoint')
|
||||||
|
setCertificate(data.certificate)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Verify endpoint failed, trying next...')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try direct ID endpoint
|
||||||
|
try {
|
||||||
|
console.log(`🔍 Trying direct endpoint: /api/certificate/${id}`)
|
||||||
|
response = await fetch(`http://127.0.0.1:5000/api/certificate/${id}`)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
console.log('✅ Found certificate by direct endpoint')
|
||||||
|
setCertificate(data.certificate)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Direct endpoint failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('❌ Certificate not found in any endpoint')
|
||||||
|
setError("Certificate not found")
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fetching certificate:', error)
|
||||||
|
setError("Failed to load certificate")
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadPDF = () => {
|
||||||
|
if (!certificate) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certificateHTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - ${certificate.user_name}</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate {
|
||||||
|
background: white;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 60px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 25px 50px rgba(0,0,0,0.15);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
border: 8px solid #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4f46e5;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-name {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 48px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 40px 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 3px solid #4f46e5;
|
||||||
|
border-bottom: 3px solid #4f46e5;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-id {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentor-section {
|
||||||
|
margin-top: 50px;
|
||||||
|
padding-top: 30px;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentor-name {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate">
|
||||||
|
<div style="font-size: 60px; margin-bottom: 20px;">🏆</div>
|
||||||
|
<h1 class="title">CERTIFICATE OF COMPLETION</h1>
|
||||||
|
|
||||||
|
<div style="font-size: 18px; color: #6b7280; margin-bottom: 30px;">This is to certify that</div>
|
||||||
|
|
||||||
|
<div class="student-name">${certificate.user_name}</div>
|
||||||
|
|
||||||
|
<div style="font-size: 18px; color: #6b7280; margin-bottom: 20px;">has successfully completed the course</div>
|
||||||
|
<div class="course-title">"${certificate.course_title}"</div>
|
||||||
|
|
||||||
|
<div style="font-size: 16px; color: #374151; margin: 20px 0;">
|
||||||
|
✅ Completed on: ${new Date(certificate.completion_date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mentor-section">
|
||||||
|
<div style="width: 200px; height: 2px; background: #6b7280; margin: 0 auto 10px auto;"></div>
|
||||||
|
<div class="mentor-name">${certificate.mentor_name}</div>
|
||||||
|
<div style="font-size: 14px; color: #6b7280; margin-top: 5px;">Course Instructor</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-id">
|
||||||
|
<strong>Certificate ID: ${certificate.certificate_id}</strong><br>
|
||||||
|
OpenLearnX Learning Platform<br>
|
||||||
|
<span style="color: #7c3aed;">🔒 Blockchain Verified Completion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.document.write(certificateHTML)
|
||||||
|
printWindow.document.close()
|
||||||
|
|
||||||
|
printWindow.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
printWindow.print()
|
||||||
|
printWindow.close()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Certificate PDF download initiated!")
|
||||||
|
} else {
|
||||||
|
toast.error("Popup blocked. Please allow popups and try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PDF generation error:', error)
|
||||||
|
toast.error("Failed to generate PDF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
if (!certificate) return
|
||||||
|
|
||||||
|
const shareText = `🎓 Check out my certificate of completion for "${certificate.course_title}" from OpenLearnX!\n\nStudent: ${certificate.user_name}\nCertificate ID: ${certificate.certificate_id}\n\n#OpenLearnX #Certificate #Learning`
|
||||||
|
const shareUrl = window.location.href
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: `Certificate - ${certificate.course_title}`,
|
||||||
|
text: shareText,
|
||||||
|
url: shareUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track share
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:5000/api/certificate/share/${certificate.certificate_id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Share tracking failed:', e)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Share cancelled')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`${shareText}\n\n${shareUrl}`)
|
||||||
|
toast.success("Certificate link copied to clipboard!")
|
||||||
|
|
||||||
|
// Track share
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:5000/api/certificate/share/${certificate.certificate_id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Share tracking failed:', e)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to copy link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-100 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-indigo-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading certificate...</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Certificate ID: {certificateId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-red-50 to-pink-100 flex items-center justify-center">
|
||||||
|
<div className="text-center max-w-md mx-auto p-8">
|
||||||
|
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Certificate Not Found</h1>
|
||||||
|
<p className="text-gray-600 mb-4">{error}</p>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">Certificate ID: {certificateId}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go to Homepage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!certificate) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-600">No certificate data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-100 py-12 px-4">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex items-center justify-center space-x-2 mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Verified Certificate</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">This certificate has been verified on the blockchain</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl p-12 border-8 border-indigo-200 relative overflow-hidden">
|
||||||
|
|
||||||
|
<div className="absolute top-6 right-6 bg-green-100 text-green-800 px-4 py-2 rounded-full text-sm font-semibold flex items-center space-x-2">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span>Verified</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
|
||||||
|
<div className="text-8xl mb-6">🏆</div>
|
||||||
|
|
||||||
|
<h2 className="text-5xl font-bold text-indigo-600 mb-6 font-serif">
|
||||||
|
CERTIFICATE OF COMPLETION
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 mb-8">This is to certify that</p>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="text-6xl font-bold text-gray-900 mb-4 border-t-4 border-b-4 border-indigo-300 py-6 capitalize font-serif">
|
||||||
|
{certificate.user_name}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">Student</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 mb-4">has successfully completed the course</p>
|
||||||
|
<h3 className="text-3xl font-semibold text-gray-900 mb-8 italic font-serif">
|
||||||
|
"{certificate.course_title}"
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 my-8 p-6 bg-indigo-50 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<Calendar className="w-8 h-8 text-indigo-600 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">Completion Date</p>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{new Date(certificate.completion_date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Award className="w-8 h-8 text-indigo-600 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">Certificate ID</p>
|
||||||
|
<p className="font-mono font-semibold text-indigo-600 text-sm">
|
||||||
|
{certificate.certificate_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<User className="w-8 h-8 text-indigo-600 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">Views</p>
|
||||||
|
<p className="font-semibold text-gray-900">{certificate.view_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-8 border-t-2 border-gray-200">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-48 h-0.5 bg-gray-400 mb-3 mx-auto"></div>
|
||||||
|
<p className="text-xl font-semibold text-gray-700">
|
||||||
|
{certificate.mentor_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Course Instructor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
<strong>{certificate.issued_by}</strong><br/>
|
||||||
|
Digital Certificate of Achievement<br/>
|
||||||
|
<span className="text-purple-600">🔒 Blockchain Verified</span>
|
||||||
|
</p>
|
||||||
|
{certificate.blockchain_hash && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2 font-mono break-all">
|
||||||
|
Blockchain Hash: {certificate.blockchain_hash}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-6 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadPDF}
|
||||||
|
className="flex items-center justify-center space-x-2 px-8 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
<span>Download PDF</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className="flex items-center justify-center space-x-2 px-8 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Share2 className="w-5 h-5" />
|
||||||
|
<span>Share Certificate</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-8 text-sm text-gray-500">
|
||||||
|
<p>This certificate can be verified at any time using the certificate ID above.</p>
|
||||||
|
<p className="mt-2">Powered by OpenLearnX • Secured by Blockchain Technology</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Loader2, Play, Clock, BookOpen, ChevronDown, ChevronRight, User, Users,
|
|||||||
import { toast } from "react-hot-toast"
|
import { toast } from "react-hot-toast"
|
||||||
import api from "@/lib/api"
|
import api from "@/lib/api"
|
||||||
import { useAuth } from "@/context/auth-context"
|
import { useAuth } from "@/context/auth-context"
|
||||||
|
import { CertificateModal } from "@/components/certificate-modal"
|
||||||
|
|
||||||
type Course = {
|
type Course = {
|
||||||
id: string
|
id: string
|
||||||
@@ -61,6 +62,9 @@ export default function CoursePage() {
|
|||||||
const [expandedModules, setExpandedModules] = useState<{ [moduleId: string]: boolean }>({})
|
const [expandedModules, setExpandedModules] = useState<{ [moduleId: string]: boolean }>({})
|
||||||
const [completed, setCompleted] = useState(false)
|
const [completed, setCompleted] = useState(false)
|
||||||
|
|
||||||
|
// ✅ Certificate Modal State
|
||||||
|
const [showCertificateModal, setShowCertificateModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user && !firebaseUser) {
|
if (!authLoading && !user && !firebaseUser) {
|
||||||
toast.error("Please login to view courses.")
|
toast.error("Please login to view courses.")
|
||||||
@@ -320,9 +324,10 @@ export default function CoursePage() {
|
|||||||
return allLessons.length > 0 && allLessons[allLessons.length - 1].id === selectedLessonId
|
return allLessons.length > 0 && allLessons[allLessons.length - 1].id === selectedLessonId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Updated markComplete function to show certificate modal
|
||||||
const markComplete = () => {
|
const markComplete = () => {
|
||||||
setCompleted(true)
|
setCompleted(true)
|
||||||
toast.success("Course Completed! 🎉")
|
setShowCertificateModal(true) // Show certificate modal instead of just toast
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTotalLessons = () => {
|
const getTotalLessons = () => {
|
||||||
@@ -638,13 +643,19 @@ export default function CoursePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Completion Message */}
|
{/* ✅ Updated Completion Message */}
|
||||||
{completed && (
|
{completed && !showCertificateModal && (
|
||||||
<div className="mt-8 bg-green-50 border border-green-200 rounded-lg p-6 text-center">
|
<div className="mt-8 bg-green-50 border border-green-200 rounded-lg p-6 text-center">
|
||||||
<div className="text-green-700">
|
<div className="text-green-700">
|
||||||
<div className="text-4xl mb-2">🎉</div>
|
<div className="text-4xl mb-2">🎉</div>
|
||||||
<h3 className="text-xl font-bold mb-2">Congratulations!</h3>
|
<h3 className="text-xl font-bold mb-2">Congratulations!</h3>
|
||||||
<p>You have successfully completed this course. Certificate coming soon!</p>
|
<p className="mb-4">You have successfully completed this course!</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCertificateModal(true)}
|
||||||
|
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Get Your Certificate 🏆
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -731,6 +742,19 @@ export default function CoursePage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ Certificate Modal */}
|
||||||
|
{showCertificateModal && course && (
|
||||||
|
<CertificateModal
|
||||||
|
isOpen={showCertificateModal}
|
||||||
|
onClose={() => setShowCertificateModal(false)}
|
||||||
|
courseTitle={course.title}
|
||||||
|
courseMentor={course.mentor}
|
||||||
|
courseId={course.id}
|
||||||
|
userId={user?.uid || firebaseUser?.uid || 'anonymous'}
|
||||||
|
walletId={user?.wallet || firebaseUser?.uid || 'no-wallet'} // Adjust based on your user structure
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { X, Download, Share2, Award, Calendar, User, BookOpen, Wallet, CheckCircle } from "lucide-react"
|
||||||
|
import { toast } from "react-hot-toast"
|
||||||
|
|
||||||
|
interface Certificate {
|
||||||
|
certificate_id: string
|
||||||
|
token_id?: string
|
||||||
|
user_name: string
|
||||||
|
course_title: string
|
||||||
|
mentor_name: string
|
||||||
|
completion_date: string
|
||||||
|
wallet_id?: string
|
||||||
|
verification_url?: string
|
||||||
|
share_code?: string
|
||||||
|
public_url?: string
|
||||||
|
unique_url?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
courseTitle: string
|
||||||
|
courseMentor: string
|
||||||
|
courseId: string
|
||||||
|
userId: string
|
||||||
|
walletId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CertificateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
courseTitle,
|
||||||
|
courseMentor,
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
walletId
|
||||||
|
}: CertificateModalProps) {
|
||||||
|
const [step, setStep] = useState<'input' | 'generating' | 'completed'>('input')
|
||||||
|
const [userName, setUserName] = useState('')
|
||||||
|
const [certificate, setCertificate] = useState<Certificate | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleGenerateCertificate = async () => {
|
||||||
|
if (!userName.trim()) {
|
||||||
|
toast.error("Please enter your name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setStep('generating')
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🎓 Generating certificate for STUDENT:', userName.trim())
|
||||||
|
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/certificate/mint', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_name: userName.trim(),
|
||||||
|
course_id: courseId,
|
||||||
|
wallet_id: walletId,
|
||||||
|
user_id: userId,
|
||||||
|
course_title: courseTitle
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('✅ Certificate API response:', data)
|
||||||
|
|
||||||
|
const certificateData = data.certificate
|
||||||
|
|
||||||
|
const certificateWithWallet = {
|
||||||
|
certificate_id: certificateData.certificate_id,
|
||||||
|
token_id: certificateData.token_id,
|
||||||
|
user_name: certificateData.user_name,
|
||||||
|
course_title: certificateData.course_title,
|
||||||
|
mentor_name: certificateData.mentor_name,
|
||||||
|
completion_date: certificateData.completion_date,
|
||||||
|
wallet_id: walletId,
|
||||||
|
verification_url: certificateData.verification_url,
|
||||||
|
share_code: certificateData.share_code,
|
||||||
|
public_url: certificateData.public_url,
|
||||||
|
unique_url: certificateData.unique_url,
|
||||||
|
message: certificateData.message
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎯 Certificate data:', certificateWithWallet)
|
||||||
|
console.log('🆔 Unique Certificate ID:', certificateWithWallet.certificate_id)
|
||||||
|
|
||||||
|
setCertificate(certificateWithWallet)
|
||||||
|
setStep('completed')
|
||||||
|
toast.success(`Certificate generated for ${certificateWithWallet.user_name}! 🎉`)
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
console.error('❌ Certificate error:', error)
|
||||||
|
toast.error(error.error || "Failed to generate certificate")
|
||||||
|
setStep('input')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Certificate generation error:', error)
|
||||||
|
toast.error("Failed to generate certificate. Please check your connection.")
|
||||||
|
setStep('input')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadCertificate = async () => {
|
||||||
|
if (!certificate) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certificateHTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - ${certificate.user_name}</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate {
|
||||||
|
background: white;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 60px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 25px 50px rgba(0,0,0,0.15);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
border: 8px solid #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4f46e5;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-name {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 48px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 40px 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 3px solid #4f46e5;
|
||||||
|
border-bottom: 3px solid #4f46e5;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-container {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 2px dashed #9333ea;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 25px auto;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-address {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #7c3aed;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #374151;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentor-section {
|
||||||
|
margin-top: 50px;
|
||||||
|
padding-top: 30px;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentor-name {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-id {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trophy {
|
||||||
|
font-size: 60px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate">
|
||||||
|
<div class="trophy">🏆</div>
|
||||||
|
<h1 class="title">CERTIFICATE OF COMPLETION</h1>
|
||||||
|
|
||||||
|
<div class="subtitle">This is to certify that</div>
|
||||||
|
|
||||||
|
<div class="student-name">${certificate.user_name}</div>
|
||||||
|
|
||||||
|
<div class="wallet-container">
|
||||||
|
<div style="font-size: 14px; color: #374151; margin-bottom: 8px; font-weight: 600;">Blockchain Wallet Address</div>
|
||||||
|
<div class="wallet-address">${certificate.wallet_id}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subtitle">has successfully completed the course</div>
|
||||||
|
<div class="course-title">"${certificate.course_title}"</div>
|
||||||
|
|
||||||
|
<div class="date">✅ Completed on: ${new Date(certificate.completion_date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}</div>
|
||||||
|
|
||||||
|
<div class="mentor-section">
|
||||||
|
<div style="width: 200px; height: 2px; background: #6b7280; margin: 0 auto 10px auto;"></div>
|
||||||
|
<div class="mentor-name">${certificate.mentor_name}</div>
|
||||||
|
<div style="font-size: 14px; color: #6b7280; margin-top: 5px;">Course Instructor</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-id">
|
||||||
|
<strong>Certificate ID: ${certificate.certificate_id}</strong><br>
|
||||||
|
OpenLearnX Learning Platform<br>
|
||||||
|
<span style="color: #7c3aed;">🔒 Blockchain Verified Completion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.document.write(certificateHTML)
|
||||||
|
printWindow.document.close()
|
||||||
|
|
||||||
|
printWindow.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
printWindow.print()
|
||||||
|
printWindow.close()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Certificate PDF download initiated! Use your browser's print dialog to save as PDF.")
|
||||||
|
} else {
|
||||||
|
toast.error("Popup blocked. Please allow popups and try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PDF generation error:', error)
|
||||||
|
toast.error("Failed to generate PDF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareCertificate = async () => {
|
||||||
|
if (!certificate) return
|
||||||
|
|
||||||
|
const shareText = `🎓 I just completed "${certificate.course_title}" on OpenLearnX!\n\n👤 Student: ${certificate.user_name}\n🏆 Certificate ID: ${certificate.certificate_id}\n🔗 View: ${certificate.public_url || window.location.origin + certificate.unique_url}\n\n#OpenLearnX #Blockchain #Learning`
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: `Certificate of Completion - ${certificate.course_title}`,
|
||||||
|
text: shareText,
|
||||||
|
url: certificate.public_url || `${window.location.origin}${certificate.unique_url}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track share
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:5000/api/certificate/share/${certificate.certificate_id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Share tracking failed:', e)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Share cancelled')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareText)
|
||||||
|
toast.success("Certificate details copied to clipboard!")
|
||||||
|
|
||||||
|
// Track share
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:5000/api/certificate/share/${certificate.certificate_id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Share tracking failed:', e)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to copy certificate details")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setStep('input')
|
||||||
|
setUserName('')
|
||||||
|
setCertificate(null)
|
||||||
|
setLoading(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Step 1: Name Input */}
|
||||||
|
{step === 'input' && (
|
||||||
|
<>
|
||||||
|
<div className="px-8 py-6 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 to-indigo-600 rounded-full flex items-center justify-center">
|
||||||
|
<Award className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Generate Certificate</h2>
|
||||||
|
<p className="text-gray-600">You've completed the course!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="text-6xl mb-4">🎉</div>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900 mb-2">Congratulations!</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
You have successfully completed <strong>"{courseTitle}"</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-lg p-6 mb-8">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-4">Course Details:</h4>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex items-center text-gray-700">
|
||||||
|
<BookOpen className="w-4 h-4 mr-3 text-indigo-600" />
|
||||||
|
<span><strong>Course:</strong> {courseTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-gray-700">
|
||||||
|
<User className="w-4 h-4 mr-3 text-indigo-600" />
|
||||||
|
<span><strong>Instructor:</strong> {courseMentor}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-gray-700">
|
||||||
|
<Calendar className="w-4 h-4 mr-3 text-indigo-600" />
|
||||||
|
<span><strong>Completed:</strong> {new Date().toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start text-gray-700">
|
||||||
|
<Wallet className="w-4 h-4 mr-3 mt-0.5 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<span><strong>Wallet:</strong></span>
|
||||||
|
<div className="font-mono text-xs text-purple-600 mt-1 break-all">
|
||||||
|
{walletId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Enter your full name for the certificate: *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userName}
|
||||||
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
|
placeholder="e.g., John Smith"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-lg"
|
||||||
|
autoFocus
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Your name will appear prominently on the certificate.
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{userName.length}/50
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateCertificate}
|
||||||
|
disabled={!userName.trim() || loading}
|
||||||
|
className="flex-1 px-6 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-all"
|
||||||
|
>
|
||||||
|
{loading ? 'Generating...' : 'Generate Certificate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Generating */}
|
||||||
|
{step === 'generating' && (
|
||||||
|
<div className="px-8 py-12 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-indigo-600 mx-auto mb-6"></div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">Generating Your Certificate</h3>
|
||||||
|
<p className="text-gray-600">Creating unique certificate ID and blockchain verification...</p>
|
||||||
|
<div className="mt-4 flex items-center justify-center space-x-2 text-sm text-gray-500">
|
||||||
|
<div className="w-2 h-2 bg-indigo-600 rounded-full animate-bounce"></div>
|
||||||
|
<div className="w-2 h-2 bg-indigo-600 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
||||||
|
<div className="w-2 h-2 bg-indigo-600 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Certificate Generated */}
|
||||||
|
{step === 'completed' && certificate && (
|
||||||
|
<>
|
||||||
|
<div className="px-8 py-6 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Certificate Ready!</h2>
|
||||||
|
<p className="text-gray-600">For: {certificate.user_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 border-4 border-indigo-200 rounded-xl p-8 mb-8 text-center relative overflow-hidden">
|
||||||
|
<div className="absolute top-4 right-4 bg-green-100 text-green-800 px-3 py-1 rounded-full text-xs font-semibold flex items-center space-x-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
<span>Verified</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-4xl mb-4">🏆</div>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900 mb-2">CERTIFICATE OF COMPLETION</h3>
|
||||||
|
<p className="text-gray-600 mb-6">This is to certify that</p>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-4xl font-bold text-indigo-600 mb-3 border-b-2 border-indigo-300 pb-2 inline-block capitalize">
|
||||||
|
{certificate.user_name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Student</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Blockchain Wallet Address:</p>
|
||||||
|
<div className="bg-purple-100 border-2 border-dashed border-purple-300 rounded-lg p-3 mx-auto max-w-md">
|
||||||
|
<p className="text-purple-700 font-mono text-sm break-all">
|
||||||
|
{certificate.wallet_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-2">has successfully completed the course</p>
|
||||||
|
<h5 className="text-xl font-semibold text-gray-900 mb-4 italic">"{certificate.course_title}"</h5>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500 mb-6">
|
||||||
|
<p>Completed on: {new Date(certificate.completion_date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-indigo-200">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-32 h-0.5 bg-gray-400 mb-2 mx-auto"></div>
|
||||||
|
<p className="text-base font-semibold text-gray-700">
|
||||||
|
{certificate.mentor_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Course Instructor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-indigo-200">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border">
|
||||||
|
<p className="text-sm font-semibold text-gray-700 mb-2">🆔 Unique Certificate ID:</p>
|
||||||
|
<p className="text-lg font-mono font-bold text-indigo-600 bg-white px-3 py-2 rounded border">
|
||||||
|
{certificate.certificate_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-4">
|
||||||
|
<strong>OpenLearnX Learning Platform</strong><br/>
|
||||||
|
<span className="text-purple-600">🔒 Blockchain Verified Completion</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadCertificate}
|
||||||
|
className="flex items-center justify-center space-x-2 px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
<span>Download PDF</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleShareCertificate}
|
||||||
|
className="flex items-center justify-center space-x-2 px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Share2 className="w-5 h-5" />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
🎉 Your certificate with unique ID <strong>{certificate.certificate_id}</strong> has been generated!
|
||||||
|
</p>
|
||||||
|
{certificate.unique_url && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
View at: <a href={certificate.unique_url} className="text-indigo-600 hover:underline">{certificate.unique_url}</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"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",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
"ethers": "latest",
|
"ethers": "latest",
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
Generated
+16
@@ -116,6 +116,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: 1.0.4
|
specifier: 1.0.4
|
||||||
version: 1.0.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.0.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
crypto-js:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: 4.1.0
|
specifier: 4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
@@ -192,6 +195,9 @@ importers:
|
|||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/crypto-js':
|
||||||
|
specifier: ^4.2.2
|
||||||
|
version: 4.2.2
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22
|
specifier: ^22
|
||||||
version: 22.16.5
|
version: 22.16.5
|
||||||
@@ -1404,6 +1410,9 @@ packages:
|
|||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
|
'@types/crypto-js@4.2.2':
|
||||||
|
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
||||||
|
|
||||||
'@types/d3-array@3.2.1':
|
'@types/d3-array@3.2.1':
|
||||||
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
|
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
|
||||||
|
|
||||||
@@ -1651,6 +1660,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
crypto-js@4.2.0:
|
||||||
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -4120,6 +4132,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@types/crypto-js@4.2.2': {}
|
||||||
|
|
||||||
'@types/d3-array@3.2.1': {}
|
'@types/d3-array@3.2.1': {}
|
||||||
|
|
||||||
'@types/d3-color@3.1.3': {}
|
'@types/d3-color@3.1.3': {}
|
||||||
@@ -4375,6 +4389,8 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
crypto-js@4.2.0: {}
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user