Merge pull request #494 from learnhouse/feat/course-certificates

Course certificates
This commit is contained in:
Badr B. 2025-07-20 12:17:59 +02:00 committed by GitHub
commit 5bbf15ee41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 4342 additions and 147 deletions

View file

@ -0,0 +1,63 @@
from typing import Optional
from sqlalchemy import JSON, Column, ForeignKey
from sqlmodel import Field, SQLModel
class CertificationBase(SQLModel):
course_id: int = Field(sa_column= Column("course_id", ForeignKey("course.id", ondelete="CASCADE")))
config: dict = Field(default={}, sa_column= Column("config", JSON))
class Certifications(CertificationBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
certification_uuid: str = Field(unique=True)
course_id: int = Field(sa_column= Column("course_id", ForeignKey("course.id", ondelete="CASCADE")))
config: dict = Field(default={}, sa_column= Column("config", JSON))
creation_date: str = ""
update_date: str = ""
class CertificationCreate(SQLModel):
course_id: int
config: dict = Field(default={})
class CertificationUpdate(SQLModel):
config: Optional[dict] = None
class CertificationRead(SQLModel):
id: int
certification_uuid: str
course_id: int
config: dict
creation_date: str
update_date: str
class CertificateUserBase(SQLModel):
user_id: int = Field(sa_column= Column("user_id", ForeignKey("user.id", ondelete="CASCADE")))
certification_id: int = Field(sa_column= Column("certification_id", ForeignKey("certifications.id", ondelete="CASCADE")))
user_certification_uuid: str
class CertificateUser(CertificateUserBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(sa_column= Column("user_id", ForeignKey("user.id", ondelete="CASCADE")))
certification_id: int = Field(sa_column= Column("certification_id", ForeignKey("certifications.id", ondelete="CASCADE")))
user_certification_uuid: str
created_at: str = ""
updated_at: str = ""
class CertificateUserCreate(SQLModel):
user_id: int
certification_id: int
user_certification_uuid: str
class CertificateUserRead(SQLModel):
id: int
user_id: int
certification_id: int
user_certification_uuid: str
created_at: str
updated_at: str
class CertificateUserUpdate(SQLModel):
user_id: Optional[int] = None
certification_id: Optional[int] = None
user_certification_uuid: Optional[str] = None

View file

@ -4,7 +4,7 @@ from src.routers import health
from src.routers import usergroups from src.routers import usergroups
from src.routers import dev, trail, users, auth, orgs, roles, search from src.routers import dev, trail, users, auth, orgs, roles, search
from src.routers.ai import ai from src.routers.ai import ai
from src.routers.courses import chapters, collections, courses, assignments from src.routers.courses import chapters, collections, courses, assignments, certifications
from src.routers.courses.activities import activities, blocks from src.routers.courses.activities import activities, blocks
from src.routers.ee import cloud_internal, payments from src.routers.ee import cloud_internal, payments
from src.routers.install import install from src.routers.install import install
@ -33,6 +33,9 @@ v1_router.include_router(activities.router, prefix="/activities", tags=["activit
v1_router.include_router( v1_router.include_router(
collections.router, prefix="/collections", tags=["collections"] collections.router, prefix="/collections", tags=["collections"]
) )
v1_router.include_router(
certifications.router, prefix="/certifications", tags=["certifications"]
)
v1_router.include_router(trail.router, prefix="/trail", tags=["trail"]) v1_router.include_router(trail.router, prefix="/trail", tags=["trail"])
v1_router.include_router(ai.router, prefix="/ai", tags=["ai"]) v1_router.include_router(ai.router, prefix="/ai", tags=["ai"])
v1_router.include_router(payments.router, prefix="/payments", tags=["payments"]) v1_router.include_router(payments.router, prefix="/payments", tags=["payments"])

View file

@ -0,0 +1,143 @@
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from src.core.events.database import get_db_session
from src.db.courses.certifications import (
CertificationCreate,
CertificationRead,
CertificationUpdate,
)
from src.db.users import PublicUser
from src.security.auth import get_current_user
from src.services.courses.certifications import (
create_certification,
get_certification,
get_certifications_by_course,
update_certification,
delete_certification,
get_user_certificates_for_course,
get_certificate_by_user_certification_uuid,
get_all_user_certificates,
)
router = APIRouter()
@router.post("/")
async def api_create_certification(
request: Request,
certification_object: CertificationCreate,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> CertificationRead:
"""
Create new certification for a course
"""
return await create_certification(
request, certification_object, current_user, db_session
)
@router.get("/{certification_uuid}")
async def api_get_certification(
request: Request,
certification_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> CertificationRead:
"""
Get single certification by certification_id
"""
return await get_certification(
request, certification_uuid, current_user, db_session
)
@router.get("/course/{course_uuid}")
async def api_get_certifications_by_course(
request: Request,
course_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> List[CertificationRead]:
"""
Get all certifications for a specific course
"""
return await get_certifications_by_course(
request, course_uuid, current_user, db_session
)
@router.put("/{certification_uuid}")
async def api_update_certification(
request: Request,
certification_uuid: str,
certification_object: CertificationUpdate,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> CertificationRead:
"""
Update certification by certification_id
"""
return await update_certification(
request, certification_uuid, certification_object, current_user, db_session
)
@router.delete("/{certification_uuid}")
async def api_delete_certification(
request: Request,
certification_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Delete certification by certification_id
"""
return await delete_certification(
request, certification_uuid, current_user, db_session
)
@router.get("/user/course/{course_uuid}")
async def api_get_user_certificates_for_course(
request: Request,
course_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> List[dict]:
"""
Get all certificates for the current user in a specific course with certification details
"""
return await get_user_certificates_for_course(
request, course_uuid, current_user, db_session
)
@router.get("/certificate/{user_certification_uuid}")
async def api_get_certificate_by_user_certification_uuid(
request: Request,
user_certification_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> dict:
"""
Get a certificate by user_certification_uuid with certification and course details
"""
return await get_certificate_by_user_certification_uuid(
request, user_certification_uuid, current_user, db_session
)
@router.get("/user/all")
async def api_get_all_user_certificates(
request: Request,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> List[dict]:
"""
Get all certificates obtained by the current user with complete linked information
"""
return await get_all_user_certificates(
request, current_user, db_session
)

View file

@ -13,7 +13,6 @@ from src.db.courses.courses import (
CourseRead, CourseRead,
CourseUpdate, CourseUpdate,
FullCourseRead, FullCourseRead,
FullCourseReadWithTrail,
ThumbnailType, ThumbnailType,
) )
from src.security.auth import get_current_user from src.security.auth import get_current_user

View file

@ -44,6 +44,7 @@ from src.services.courses.activities.uploads.tasks_ref_files import (
upload_reference_file, upload_reference_file,
) )
from src.services.trail.trail import check_trail_presence from src.services.trail.trail import check_trail_presence
from src.services.courses.certifications import check_course_completion_and_create_certificate
## > Assignments CRUD ## > Assignments CRUD
@ -1237,6 +1238,12 @@ async def create_assignment_submission(
db_session.commit() db_session.commit()
db_session.refresh(trailstep) db_session.refresh(trailstep)
# Check if all activities in the course are completed and create certificate if so
if course and course.id and user and user.id:
await check_course_completion_and_create_certificate(
request, user.id, course.id, db_session
)
# return assignment user submission read # return assignment user submission read
return AssignmentUserSubmissionRead.model_validate(assignment_user_submission) return AssignmentUserSubmissionRead.model_validate(assignment_user_submission)
@ -1658,6 +1665,12 @@ async def mark_activity_as_done_for_user(
db_session.commit() db_session.commit()
db_session.refresh(trailstep) db_session.refresh(trailstep)
# Check if all activities in the course are completed and create certificate if so
if course and course.id:
await check_course_completion_and_create_certificate(
request, int(user_id), course.id, db_session
)
# return OK # return OK
return {"message": "Activity marked as done for user"} return {"message": "Activity marked as done for user"}

View file

@ -0,0 +1,541 @@
from typing import List, Literal
from uuid import uuid4
from datetime import datetime
from sqlmodel import Session, select
from fastapi import HTTPException, Request
from src.db.courses.certifications import (
Certifications,
CertificationCreate,
CertificationRead,
CertificationUpdate,
CertificateUser,
CertificateUserRead,
)
from src.db.courses.courses import Course
from src.db.courses.chapter_activities import ChapterActivity
from src.db.trail_steps import TrailStep
from src.db.users import PublicUser, AnonymousUser
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
####################################################
# CRUD
####################################################
async def create_certification(
request: Request,
certification_object: CertificationCreate,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> CertificationRead:
"""Create a new certification for a course"""
# Check if course exists
statement = select(Course).where(Course.id == certification_object.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "create", db_session)
# Create certification
certification = Certifications(
course_id=certification_object.course_id,
config=certification_object.config or {},
certification_uuid=str(f"certification_{uuid4()}"),
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
# Insert certification in DB
db_session.add(certification)
db_session.commit()
db_session.refresh(certification)
return CertificationRead(**certification.model_dump())
async def get_certification(
request: Request,
certification_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> CertificationRead:
"""Get a single certification by certification_id"""
statement = select(Certifications).where(Certifications.certification_uuid == certification_uuid)
certification = db_session.exec(statement).first()
if not certification:
raise HTTPException(
status_code=404,
detail="Certification not found",
)
# Get course for RBAC check
statement = select(Course).where(Course.id == certification.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
return CertificationRead(**certification.model_dump())
async def get_certifications_by_course(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> List[CertificationRead]:
"""Get all certifications for a course"""
# Get course for RBAC check
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await rbac_check(request, course_uuid, current_user, "read", db_session)
# Get certifications for this course
statement = select(Certifications).where(Certifications.course_id == course.id)
certifications = db_session.exec(statement).all()
return [CertificationRead(**certification.model_dump()) for certification in certifications]
async def update_certification(
request: Request,
certification_uuid: str,
certification_object: CertificationUpdate,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> CertificationRead:
"""Update a certification"""
statement = select(Certifications).where(Certifications.certification_uuid == certification_uuid)
certification = db_session.exec(statement).first()
if not certification:
raise HTTPException(
status_code=404,
detail="Certification not found",
)
# Get course for RBAC check
statement = select(Course).where(Course.id == certification.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Update only the fields that were passed in
for var, value in vars(certification_object).items():
if value is not None:
setattr(certification, var, value)
# Update the update_date
certification.update_date = str(datetime.now())
db_session.add(certification)
db_session.commit()
db_session.refresh(certification)
return CertificationRead(**certification.model_dump())
async def delete_certification(
request: Request,
certification_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> dict:
"""Delete a certification"""
statement = select(Certifications).where(Certifications.certification_uuid == certification_uuid)
certification = db_session.exec(statement).first()
if not certification:
raise HTTPException(
status_code=404,
detail="Certification not found",
)
# Get course for RBAC check
statement = select(Course).where(Course.id == certification.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session)
db_session.delete(certification)
db_session.commit()
return {"detail": "Certification deleted successfully"}
####################################################
# Certificate User Functions
####################################################
async def create_certificate_user(
request: Request,
user_id: int,
certification_id: int,
db_session: Session,
) -> CertificateUserRead:
"""Create a certificate user link"""
# Check if certification exists
statement = select(Certifications).where(Certifications.id == certification_id)
certification = db_session.exec(statement).first()
if not certification:
raise HTTPException(
status_code=404,
detail="Certification not found",
)
# Check if certificate user already exists
statement = select(CertificateUser).where(
CertificateUser.user_id == user_id,
CertificateUser.certification_id == certification_id
)
existing_certificate_user = db_session.exec(statement).first()
if existing_certificate_user:
raise HTTPException(
status_code=400,
detail="User already has a certificate for this course",
)
# Generate readable certificate user UUID
current_year = datetime.now().year
current_month = datetime.now().month
current_day = datetime.now().day
# Get user to extract user_uuid
from src.db.users import User
statement = select(User).where(User.id == user_id)
user = db_session.exec(statement).first()
if not user:
raise HTTPException(
status_code=404,
detail="User not found",
)
# Extract last 4 characters from user_uuid for uniqueness (since all start with "user_")
user_uuid_short = user.user_uuid[-4:] if user.user_uuid else "USER"
# Generate random 2-letter prefix
import random
import string
random_prefix = ''.join(random.choices(string.ascii_uppercase, k=2))
# Get the count of existing certificate users for this user today
today_user_prefix = f"{random_prefix}-{current_year}{current_month:02d}{current_day:02d}-{user_uuid_short}-"
statement = select(CertificateUser).where(
CertificateUser.user_certification_uuid.startswith(today_user_prefix)
)
existing_certificates = db_session.exec(statement).all()
# Generate next sequential number for this user today
next_number = len(existing_certificates) + 1
certificate_number = f"{next_number:03d}" # Format as 3-digit number with leading zeros
user_certification_uuid = f"{today_user_prefix}{certificate_number}"
# Create certificate user
certificate_user = CertificateUser(
user_id=user_id,
certification_id=certification_id,
user_certification_uuid=user_certification_uuid,
created_at=str(datetime.now()),
updated_at=str(datetime.now()),
)
db_session.add(certificate_user)
db_session.commit()
db_session.refresh(certificate_user)
return CertificateUserRead(**certificate_user.model_dump())
async def get_user_certificates_for_course(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> List[dict]:
"""Get all certificates for a user in a specific course with certification details"""
# Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await rbac_check(request, course_uuid, current_user, "read", db_session)
# Get all certifications for this course
statement = select(Certifications).where(Certifications.course_id == course.id)
certifications = db_session.exec(statement).all()
if not certifications:
return []
# Get all certificate users for this user and these certifications
certification_ids = [cert.id for cert in certifications if cert.id]
if not certification_ids:
return []
# Query certificate users for this user and these certifications
result = []
for cert_id in certification_ids:
statement = select(CertificateUser).where(
CertificateUser.user_id == current_user.id,
CertificateUser.certification_id == cert_id
)
cert_user = db_session.exec(statement).first()
if cert_user:
# Get the associated certification
statement = select(Certifications).where(Certifications.id == cert_id)
certification = db_session.exec(statement).first()
result.append({
"certificate_user": CertificateUserRead(**cert_user.model_dump()),
"certification": CertificationRead(**certification.model_dump()) if certification else None
})
return result
async def check_course_completion_and_create_certificate(
request: Request,
user_id: int,
course_id: int,
db_session: Session,
) -> bool:
"""Check if all activities in a course are completed and create certificate if so"""
# Get all activities in the course
statement = select(ChapterActivity).where(ChapterActivity.course_id == course_id)
course_activities = db_session.exec(statement).all()
if not course_activities:
return False # No activities in course
# Get all completed activities for this user in this course
statement = select(TrailStep).where(
TrailStep.user_id == user_id,
TrailStep.course_id == course_id,
TrailStep.complete == True
)
completed_activities = db_session.exec(statement).all()
# Check if all activities are completed
if len(completed_activities) >= len(course_activities):
# All activities completed, check if certification exists for this course
statement = select(Certifications).where(Certifications.course_id == course_id)
certification = db_session.exec(statement).first()
if certification and certification.id:
# Create certificate user link
try:
await create_certificate_user(request, user_id, certification.id, db_session)
return True
except HTTPException as e:
if e.status_code == 400 and "already has a certificate" in e.detail:
# Certificate already exists, which is fine
return True
else:
raise e
return False
async def get_certificate_by_user_certification_uuid(
request: Request,
user_certification_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> dict:
"""Get a certificate by user_certification_uuid with certification details"""
# Get certificate user by user_certification_uuid
statement = select(CertificateUser).where(
CertificateUser.user_certification_uuid == user_certification_uuid
)
certificate_user = db_session.exec(statement).first()
if not certificate_user:
raise HTTPException(
status_code=404,
detail="Certificate not found",
)
# Get the associated certification
statement = select(Certifications).where(Certifications.id == certificate_user.certification_id)
certification = db_session.exec(statement).first()
if not certification:
raise HTTPException(
status_code=404,
detail="Certification not found",
)
# Get course information
statement = select(Course).where(Course.id == certification.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# No RBAC check - allow anyone to access certificates by UUID
return {
"certificate_user": CertificateUserRead(**certificate_user.model_dump()),
"certification": CertificationRead(**certification.model_dump()),
"course": {
"id": course.id,
"course_uuid": course.course_uuid,
"name": course.name,
"description": course.description,
"thumbnail_image": course.thumbnail_image,
}
}
async def get_all_user_certificates(
request: Request,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> List[dict]:
"""Get all certificates for the current user with complete linked information"""
# Get all certificate users for this user
statement = select(CertificateUser).where(CertificateUser.user_id == current_user.id)
certificate_users = db_session.exec(statement).all()
if not certificate_users:
return []
result = []
for cert_user in certificate_users:
# Get the associated certification
statement = select(Certifications).where(Certifications.id == cert_user.certification_id)
certification = db_session.exec(statement).first()
if not certification:
continue
# Get course information
statement = select(Course).where(Course.id == certification.course_id)
course = db_session.exec(statement).first()
if not course:
continue
# Get user information
from src.db.users import User
statement = select(User).where(User.id == cert_user.user_id)
user = db_session.exec(statement).first()
result.append({
"certificate_user": CertificateUserRead(**cert_user.model_dump()),
"certification": CertificationRead(**certification.model_dump()),
"course": {
"id": course.id,
"course_uuid": course.course_uuid,
"name": course.name,
"description": course.description,
"thumbnail_image": course.thumbnail_image,
},
"user": {
"id": user.id if user else None,
"user_uuid": user.user_uuid if user else None,
"username": user.username if user else None,
"email": user.email if user else None,
"first_name": user.first_name if user else None,
"last_name": user.last_name if user else None,
} if user else None
})
return result
####################################################
# RBAC Utils
####################################################
async def rbac_check(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if action == "read":
if current_user.id == 0: # Anonymous user
await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
)
else:
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_uuid,
db_session,
)

View file

@ -9,7 +9,6 @@ from src.security.features_utils.usage import (
decrease_feature_usage, decrease_feature_usage,
increase_feature_usage, increase_feature_usage,
) )
from src.services.trail.trail import get_user_trail_with_orgid
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.db.users import PublicUser, AnonymousUser, User, UserRead from src.db.users import PublicUser, AnonymousUser, User, UserRead
from src.db.courses.courses import ( from src.db.courses.courses import (
@ -29,7 +28,6 @@ from src.security.rbac.rbac import (
from src.services.courses.thumbnails import upload_thumbnail from src.services.courses.thumbnails import upload_thumbnail
from fastapi import HTTPException, Request, UploadFile from fastapi import HTTPException, Request, UploadFile
from datetime import datetime from datetime import datetime
import asyncio
async def get_course( async def get_course(

View file

@ -9,6 +9,7 @@ from src.db.trail_runs import TrailRun, TrailRunRead
from src.db.trail_steps import TrailStep from src.db.trail_steps import TrailStep
from src.db.trails import Trail, TrailCreate, TrailRead from src.db.trails import Trail, TrailCreate, TrailRead
from src.db.users import AnonymousUser, PublicUser from src.db.users import AnonymousUser, PublicUser
from src.services.courses.certifications import check_course_completion_and_create_certificate
async def create_user_trail( async def create_user_trail(
@ -68,7 +69,7 @@ async def get_user_trails(
for trail_run in trail_runs: for trail_run in trail_runs:
statement = select(Course).where(Course.id == trail_run.course_id) statement = select(Course).where(Course.id == trail_run.course_id)
course = db_session.exec(statement).first() course = db_session.exec(statement).first()
trail_run.course = course trail_run.course = course.model_dump() if course else {}
# Add number of activities (steps) in a course # Add number of activities (steps) in a course
statement = select(ChapterActivity).where( statement = select(ChapterActivity).where(
@ -153,7 +154,7 @@ async def get_user_trail_with_orgid(
for trail_run in trail_runs: for trail_run in trail_runs:
statement = select(Course).where(Course.id == trail_run.course_id) statement = select(Course).where(Course.id == trail_run.course_id)
course = db_session.exec(statement).first() course = db_session.exec(statement).first()
trail_run.course = course trail_run.course = course.model_dump() if course else {}
# Add number of activities (steps) in a course # Add number of activities (steps) in a course
statement = select(ChapterActivity).where( statement = select(ChapterActivity).where(
@ -255,6 +256,12 @@ async def add_activity_to_trail(
db_session.commit() db_session.commit()
db_session.refresh(trailstep) db_session.refresh(trailstep)
# Check if all activities in the course are completed and create certificate if so
if course and course.id:
await check_course_completion_and_create_certificate(
request, user.id, course.id, db_session
)
statement = select(TrailRun).where(TrailRun.trail_id == trail.id , TrailRun.user_id == user.id) statement = select(TrailRun).where(TrailRun.trail_id == trail.id , TrailRun.user_id == user.id)
trail_runs = db_session.exec(statement).all() trail_runs = db_session.exec(statement).all()

1
apps/web/.gitignore vendored
View file

@ -44,6 +44,5 @@ next.config.original.js
# Sentry Config File # Sentry Config File
.sentryclirc .sentryclirc
certificates
# Sentry Config File # Sentry Config File
.env.sentry-build-plugin .env.sentry-build-plugin

View file

@ -0,0 +1,15 @@
import CertificateVerificationPage from '@components/Pages/Certificate/CertificateVerificationPage';
import React from 'react';
interface CertificateVerifyPageProps {
params: Promise<{
uuid: string;
}>;
}
const CertificateVerifyPage: React.FC<CertificateVerifyPageProps> = async ({ params }) => {
const { uuid } = await params;
return <CertificateVerificationPage certificateUuid={uuid} />;
};
export default CertificateVerifyPage;

View file

@ -521,6 +521,8 @@ function ActivityClient(props: ActivityClientProps) {
orgslug={orgslug} orgslug={orgslug}
courseUuid={course.course_uuid} courseUuid={course.course_uuid}
thumbnailImage={course.thumbnail_image} thumbnailImage={course.thumbnail_image}
course={course}
trailData={trailData}
/> />
) : ( ) : (
<div className="space-y-4 pt-0"> <div className="space-y-4 pt-0">

View file

@ -43,9 +43,13 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
access_token || null access_token || null
) )
// Check if this is the course end page
const isCourseEnd = params.activityid === 'end';
const pageTitle = isCourseEnd ? `Congratulations — ${course_meta.name} Course` : activity.name + `${course_meta.name} Course`;
// SEO // SEO
return { return {
title: activity.name + `${course_meta.name} Course`, title: pageTitle,
description: course_meta.description, description: course_meta.description,
keywords: course_meta.learnings, keywords: course_meta.learnings,
robots: { robots: {
@ -59,7 +63,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
}, },
}, },
openGraph: { openGraph: {
title: activity.name + `${course_meta.name} Course`, title: pageTitle,
description: course_meta.description, description: course_meta.description,
publishedTime: course_meta.creation_date, publishedTime: course_meta.creation_date,
tags: course_meta.learnings, tags: course_meta.learnings,

View file

@ -276,6 +276,7 @@ const CourseClient = (props: any) => {
course_uuid={props.course.course_uuid} course_uuid={props.course.course_uuid}
orgslug={orgslug} orgslug={orgslug}
course={course} course={course}
trailData={trailData}
/> />
)} )}

View file

@ -3,6 +3,7 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import PageLoading from '@components/Objects/Loaders/PageLoading' import PageLoading from '@components/Objects/Loaders/PageLoading'
import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement' import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement'
import UserCertificates from '@components/Pages/Trail/UserCertificates'
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle' import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { getAPIUrl } from '@services/config/config' import { getAPIUrl } from '@services/config/config'
@ -13,6 +14,7 @@ import { removeCourse } from '@services/courses/activity'
import { revalidateTags } from '@services/utils/ts/requests' import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { BookOpen } from 'lucide-react'
function Trail(params: any) { function Trail(params: any) {
let orgslug = params.orgslug let orgslug = params.orgslug
@ -84,20 +86,45 @@ function Trail(params: any) {
/> />
)} )}
</div> </div>
{!trail ? (
<PageLoading></PageLoading> <div className="space-y-8">
) : ( {/* Progress Section */}
<div className="space-y-6"> <div className="bg-white rounded-xl shadow-sm p-6">
{trail.runs.map((run: any) => ( <div className="flex items-center space-x-3 mb-6">
<TrailCourseElement <BookOpen className="w-6 h-6 text-blue-500" />
key={run.course.course_uuid} <h2 className="text-xl font-semibold text-gray-900">My Progress</h2>
run={run} {trail?.runs && (
course={run.course} <span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
orgslug={orgslug} {trail.runs.length}
/> </span>
))} )}
</div>
{!trail ? (
<PageLoading></PageLoading>
) : trail.runs.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">No courses in progress</p>
<p className="text-sm text-gray-400 mt-1">Start a course to see your progress here</p>
</div>
) : (
<div className="space-y-6">
{trail.runs.map((run: any) => (
<TrailCourseElement
key={run.course.course_uuid}
run={run}
course={run.course}
orgslug={orgslug}
/>
))}
</div>
)}
</div> </div>
)}
{/* Certificates Section */}
<UserCertificates orgslug={orgslug} />
</div>
</GeneralWrapperStyled> </GeneralWrapperStyled>
) )
} }

View file

@ -5,11 +5,12 @@ import { CourseProvider } from '../../../../../../../../components/Contexts/Cour
import Link from 'next/link' import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop' import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users } from 'lucide-react' import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users, Award } from 'lucide-react'
import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure' import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure'
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess' import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors' import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors'
import EditCourseCertification from '@components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification'
export type CourseOverviewParams = { export type CourseOverviewParams = {
orgslug: string orgslug: string
courseuuid: string courseuuid: string
@ -102,6 +103,24 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
</div> </div>
</div> </div>
</Link> </Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/certification`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'certification'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Award size={16} />
<div>Certification</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
@ -117,6 +136,8 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')} {params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')} {params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')} {params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
{params.subpage == 'certification' ? (<EditCourseCertification orgslug={params.orgslug} />) : ('')}
</div> </div>
</motion.div> </motion.div>
</CourseProvider> </CourseProvider>

View file

@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { mutate } from 'swr' import { mutate } from 'swr'
import { updateCourse } from '@services/courses/courses' import { updateCourse } from '@services/courses/courses'
import { updateCertification } from '@services/courses/certifications'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
function SaveState(props: { orgslug: string }) { function SaveState(props: { orgslug: string }) {
@ -32,6 +33,8 @@ function SaveState(props: { orgslug: string }) {
// Course metadata // Course metadata
await changeMetadataBackend() await changeMetadataBackend()
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
// Certification data (if present)
await saveCertificationData()
await revalidateTags(['courses'], props.orgslug) await revalidateTags(['courses'], props.orgslug)
dispatchCourse({ type: 'setIsSaved' }) dispatchCourse({ type: 'setIsSaved' })
} finally { } finally {
@ -66,6 +69,24 @@ function SaveState(props: { orgslug: string }) {
dispatchCourse({ type: 'setIsSaved' }) dispatchCourse({ type: 'setIsSaved' })
} }
// Certification data
const saveCertificationData = async () => {
if (course.courseStructure._certificationData) {
const certData = course.courseStructure._certificationData;
try {
await updateCertification(
certData.certification_uuid,
certData.config,
session.data?.tokens?.access_token
);
console.log('Certification data saved successfully');
} catch (error) {
console.error('Failed to save certification data:', error);
// Don't throw error to prevent breaking the main save flow
}
}
}
const handleCourseOrder = (course_structure: any) => { const handleCourseOrder = (course_structure: any) => {
const chapters = course_structure.chapters const chapters = course_structure.chapters
const chapter_order_by_ids = chapters.map((chapter: any) => { const chapter_order_by_ids = chapters.map((chapter: any) => {

View file

@ -0,0 +1,572 @@
import React, { useEffect, useState } from 'react';
import { Award, CheckCircle, QrCode, Building, User, Calendar, Hash } from 'lucide-react';
import QRCode from 'qrcode';
import { useOrg } from '@components/Contexts/OrgContext';
import { getOrgLogoMediaDirectory } from '@services/media/media';
interface CertificatePreviewProps {
certificationName: string;
certificationDescription: string;
certificationType: string;
certificatePattern: string;
certificateInstructor?: string;
certificateId?: string;
awardedDate?: string;
qrCodeLink?: string;
}
const CertificatePreview: React.FC<CertificatePreviewProps> = ({
certificationName,
certificationDescription,
certificationType,
certificatePattern,
certificateInstructor,
certificateId,
awardedDate,
qrCodeLink
}) => {
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const org = useOrg() as any;
// Generate QR code
useEffect(() => {
const generateQRCode = async () => {
try {
const certificateData = qrCodeLink || `${certificateId}`;
const qrUrl = await QRCode.toDataURL(certificateData, {
width: 185,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M',
type: 'image/png'
});
setQrCodeUrl(qrUrl);
} catch (error) {
console.error('Error generating QR code:', error);
}
};
generateQRCode();
}, [certificateId, qrCodeLink]);
// Function to get theme colors for each pattern
const getPatternTheme = (pattern: string) => {
switch (pattern) {
case 'royal':
return {
primary: 'text-amber-700',
secondary: 'text-amber-600',
icon: 'text-amber-600',
badge: 'bg-amber-50 text-amber-700 border-amber-200'
};
case 'tech':
return {
primary: 'text-cyan-700',
secondary: 'text-cyan-600',
icon: 'text-cyan-600',
badge: 'bg-cyan-50 text-cyan-700 border-cyan-200'
};
case 'nature':
return {
primary: 'text-green-700',
secondary: 'text-green-600',
icon: 'text-green-600',
badge: 'bg-green-50 text-green-700 border-green-200'
};
case 'geometric':
return {
primary: 'text-purple-700',
secondary: 'text-purple-600',
icon: 'text-purple-600',
badge: 'bg-purple-50 text-purple-700 border-purple-200'
};
case 'vintage':
return {
primary: 'text-orange-700',
secondary: 'text-orange-600',
icon: 'text-orange-600',
badge: 'bg-orange-50 text-orange-700 border-orange-200'
};
case 'waves':
return {
primary: 'text-blue-700',
secondary: 'text-blue-600',
icon: 'text-blue-600',
badge: 'bg-blue-50 text-blue-700 border-blue-200'
};
case 'minimal':
return {
primary: 'text-gray-700',
secondary: 'text-gray-600',
icon: 'text-gray-600',
badge: 'bg-gray-50 text-gray-700 border-gray-200'
};
case 'professional':
return {
primary: 'text-slate-700',
secondary: 'text-slate-600',
icon: 'text-slate-600',
badge: 'bg-slate-50 text-slate-700 border-slate-200'
};
case 'academic':
return {
primary: 'text-indigo-700',
secondary: 'text-indigo-600',
icon: 'text-indigo-600',
badge: 'bg-indigo-50 text-indigo-700 border-indigo-200'
};
case 'modern':
return {
primary: 'text-blue-700',
secondary: 'text-blue-600',
icon: 'text-blue-600',
badge: 'bg-blue-50 text-blue-700 border-blue-200'
};
default:
return {
primary: 'text-gray-700',
secondary: 'text-gray-600',
icon: 'text-gray-600',
badge: 'bg-gray-50 text-gray-700 border-gray-200'
};
}
};
// Function to render different certificate patterns
const renderCertificatePattern = (pattern: string) => {
switch (pattern) {
case 'royal':
return (
<>
{/* Royal ornate border with crown elements */}
<div className="absolute inset-3 border-4 border-amber-200 rounded-lg opacity-60"></div>
<div className="absolute inset-4 border-2 border-amber-300 rounded-md opacity-40"></div>
{/* Crown-like decorations in corners */}
<div className="absolute top-1 left-1/2 transform -translate-x-1/2">
<div className="w-8 h-4 bg-amber-200 opacity-50" style={{
clipPath: 'polygon(0% 100%, 20% 0%, 40% 100%, 60% 0%, 80% 100%, 100% 0%, 100% 100%)'
}}></div>
</div>
<div className="absolute bottom-1 left-1/2 transform -translate-x-1/2 rotate-180">
<div className="w-8 h-4 bg-amber-200 opacity-50" style={{
clipPath: 'polygon(0% 100%, 20% 0%, 40% 100%, 60% 0%, 80% 100%, 100% 0%, 100% 100%)'
}}></div>
</div>
{/* Royal background pattern */}
<div className="absolute inset-0 opacity-3">
<div className="w-full h-full" style={{
backgroundImage: `radial-gradient(circle at 25% 25%, #f59e0b 2px, transparent 2px), radial-gradient(circle at 75% 75%, #f59e0b 1px, transparent 1px)`,
backgroundSize: '16px 16px'
}}></div>
</div>
</>
);
case 'tech':
return (
<>
{/* Tech circuit board borders */}
<div className="absolute inset-3 border-2 border-cyan-200 opacity-50"></div>
{/* Circuit-like corner elements */}
<div className="absolute top-3 left-3 w-6 h-6 border-l-2 border-t-2 border-cyan-300 opacity-60"></div>
<div className="absolute top-3 left-5 w-2 h-2 bg-cyan-300 opacity-60"></div>
<div className="absolute top-5 left-3 w-2 h-2 bg-cyan-300 opacity-60"></div>
<div className="absolute top-3 right-3 w-6 h-6 border-r-2 border-t-2 border-cyan-300 opacity-60"></div>
<div className="absolute top-3 right-5 w-2 h-2 bg-cyan-300 opacity-60"></div>
<div className="absolute top-5 right-3 w-2 h-2 bg-cyan-300 opacity-60"></div>
<div className="absolute bottom-3 left-3 w-6 h-6 border-l-2 border-b-2 border-cyan-300 opacity-60"></div>
<div className="absolute bottom-3 left-5 w-2 h-2 bg-cyan-300 opacity-60"></div>
<div className="absolute bottom-5 left-3 w-2 h-2 bg-cyan-300 opacity-60"></div>
<div className="absolute bottom-3 right-3 w-6 h-6 border-r-2 border-b-2 border-cyan-300 opacity-60"></div>
<div className="absolute bottom-3 right-5 w-2 h-2 bg-cyan-300 opacity-60"></div>
<div className="absolute bottom-5 right-3 w-2 h-2 bg-cyan-300 opacity-60"></div>
{/* Tech grid background */}
<div className="absolute inset-0 opacity-4">
<div className="w-full h-full" style={{
backgroundImage: `linear-gradient(90deg, #06b6d4 1px, transparent 1px), linear-gradient(0deg, #06b6d4 1px, transparent 1px)`,
backgroundSize: '8px 8px'
}}></div>
</div>
</>
);
case 'nature':
return (
<>
{/* Nature organic border */}
<div className="absolute inset-3 border-2 border-green-200 rounded-2xl opacity-50"></div>
{/* Leaf-like decorations */}
<div className="absolute top-2 left-2 w-4 h-6 bg-green-200 opacity-50 rounded-full transform rotate-45"></div>
<div className="absolute top-2 left-4 w-3 h-4 bg-green-300 opacity-40 rounded-full transform rotate-12"></div>
<div className="absolute top-2 right-2 w-4 h-6 bg-green-200 opacity-50 rounded-full transform -rotate-45"></div>
<div className="absolute top-2 right-4 w-3 h-4 bg-green-300 opacity-40 rounded-full transform -rotate-12"></div>
<div className="absolute bottom-2 left-2 w-4 h-6 bg-green-200 opacity-50 rounded-full transform -rotate-45"></div>
<div className="absolute bottom-2 left-4 w-3 h-4 bg-green-300 opacity-40 rounded-full transform -rotate-12"></div>
<div className="absolute bottom-2 right-2 w-4 h-6 bg-green-200 opacity-50 rounded-full transform rotate-45"></div>
<div className="absolute bottom-2 right-4 w-3 h-4 bg-green-300 opacity-40 rounded-full transform rotate-12"></div>
{/* Organic background pattern */}
<div className="absolute inset-0 opacity-3">
<div className="w-full h-full" style={{
backgroundImage: `radial-gradient(ellipse at 30% 30%, #10b981 1px, transparent 1px), radial-gradient(ellipse at 70% 70%, #10b981 0.5px, transparent 0.5px)`,
backgroundSize: '12px 8px'
}}></div>
</div>
</>
);
case 'geometric':
return (
<>
{/* Geometric angular borders */}
<div className="absolute inset-2 border-2 border-purple-200 opacity-50" style={{
clipPath: 'polygon(0 10px, 10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px))'
}}></div>
{/* Geometric corner elements */}
<div className="absolute top-1 left-1 w-6 h-6 border-2 border-purple-300 opacity-60 transform rotate-45"></div>
<div className="absolute top-1 right-1 w-6 h-6 border-2 border-purple-300 opacity-60 transform rotate-45"></div>
<div className="absolute bottom-1 left-1 w-6 h-6 border-2 border-purple-300 opacity-60 transform rotate-45"></div>
<div className="absolute bottom-1 right-1 w-6 h-6 border-2 border-purple-300 opacity-60 transform rotate-45"></div>
{/* Abstract geometric shapes */}
<div className="absolute top-1/4 left-1 w-2 h-8 bg-purple-200 opacity-30 transform rotate-12"></div>
<div className="absolute top-1/4 right-1 w-2 h-8 bg-purple-200 opacity-30 transform -rotate-12"></div>
<div className="absolute bottom-1/4 left-1 w-2 h-8 bg-purple-200 opacity-30 transform -rotate-12"></div>
<div className="absolute bottom-1/4 right-1 w-2 h-8 bg-purple-200 opacity-30 transform rotate-12"></div>
{/* Geometric background */}
<div className="absolute inset-0 opacity-4">
<div className="w-full h-full" style={{
backgroundImage: `linear-gradient(45deg, #8b5cf6 25%, transparent 25%), linear-gradient(-45deg, #8b5cf6 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #8b5cf6 75%), linear-gradient(-45deg, transparent 75%, #8b5cf6 75%)`,
backgroundSize: '6px 6px'
}}></div>
</div>
</>
);
case 'vintage':
return (
<>
{/* Art deco style borders */}
<div className="absolute inset-2 border-2 border-orange-200 opacity-50"></div>
<div className="absolute inset-3 border border-orange-300 opacity-40"></div>
{/* Art deco corner decorations */}
<div className="absolute top-2 left-2 w-8 h-8 border-2 border-orange-300 opacity-50" style={{
clipPath: 'polygon(0 0, 100% 0, 100% 50%, 50% 100%, 0 100%)'
}}></div>
<div className="absolute top-2 right-2 w-8 h-8 border-2 border-orange-300 opacity-50" style={{
clipPath: 'polygon(0 0, 100% 0, 100% 100%, 50% 100%, 0 50%)'
}}></div>
<div className="absolute bottom-2 left-2 w-8 h-8 border-2 border-orange-300 opacity-50" style={{
clipPath: 'polygon(0 0, 50% 0, 100% 50%, 100% 100%, 0 100%)'
}}></div>
<div className="absolute bottom-2 right-2 w-8 h-8 border-2 border-orange-300 opacity-50" style={{
clipPath: 'polygon(0 50%, 50% 0, 100% 0, 100% 100%, 0 100%)'
}}></div>
{/* Art deco sunburst pattern */}
<div className="absolute inset-0 opacity-3">
<div className="w-full h-full" style={{
backgroundImage: `repeating-conic-gradient(from 0deg at 50% 50%, #f97316 0deg, #f97316 2deg, transparent 2deg, transparent 8deg)`,
backgroundSize: '100% 100%'
}}></div>
</div>
</>
);
case 'waves':
return (
<>
{/* Flowing wave borders */}
<div className="absolute inset-2 border-2 border-blue-200 rounded-3xl opacity-50"></div>
{/* Wave decorations */}
<div className="absolute top-2 left-0 right-0 h-4 opacity-30" style={{
background: `radial-gradient(ellipse at center, #3b82f6 30%, transparent 30%)`,
backgroundSize: '20px 8px'
}}></div>
<div className="absolute bottom-2 left-0 right-0 h-4 opacity-30" style={{
background: `radial-gradient(ellipse at center, #3b82f6 30%, transparent 30%)`,
backgroundSize: '20px 8px'
}}></div>
{/* Side wave patterns */}
<div className="absolute left-2 top-0 bottom-0 w-4 opacity-30" style={{
background: `radial-gradient(ellipse at center, #3b82f6 30%, transparent 30%)`,
backgroundSize: '8px 20px'
}}></div>
<div className="absolute right-2 top-0 bottom-0 w-4 opacity-30" style={{
background: `radial-gradient(ellipse at center, #3b82f6 30%, transparent 30%)`,
backgroundSize: '8px 20px'
}}></div>
{/* Wave background */}
<div className="absolute inset-0 opacity-4">
<div className="w-full h-full" style={{
backgroundImage: `repeating-linear-gradient(45deg, #3b82f6 0px, #3b82f6 1px, transparent 1px, transparent 8px), repeating-linear-gradient(-45deg, #3b82f6 0px, #3b82f6 1px, transparent 1px, transparent 8px)`,
backgroundSize: '12px 12px'
}}></div>
</div>
</>
);
case 'minimal':
return (
<>
{/* Minimal clean border */}
<div className="absolute inset-6 border border-gray-300 opacity-60"></div>
{/* Subtle corner accents */}
<div className="absolute top-5 left-5 w-3 h-3 border-l border-t border-gray-400 opacity-40"></div>
<div className="absolute top-5 right-5 w-3 h-3 border-r border-t border-gray-400 opacity-40"></div>
<div className="absolute bottom-5 left-5 w-3 h-3 border-l border-b border-gray-400 opacity-40"></div>
<div className="absolute bottom-5 right-5 w-3 h-3 border-r border-b border-gray-400 opacity-40"></div>
</>
);
case 'professional':
return (
<>
{/* Professional double border */}
<div className="absolute inset-2 border-2 border-slate-300 opacity-50"></div>
<div className="absolute inset-3 border border-slate-400 opacity-40"></div>
{/* Professional corner brackets */}
<div className="absolute top-2 left-2 w-6 h-6 border-l-2 border-t-2 border-slate-400 opacity-60"></div>
<div className="absolute top-2 right-2 w-6 h-6 border-r-2 border-t-2 border-slate-400 opacity-60"></div>
<div className="absolute bottom-2 left-2 w-6 h-6 border-l-2 border-b-2 border-slate-400 opacity-60"></div>
<div className="absolute bottom-2 right-2 w-6 h-6 border-r-2 border-b-2 border-slate-400 opacity-60"></div>
{/* Subtle professional background */}
<div className="absolute inset-0 opacity-2">
<div className="w-full h-full" style={{
backgroundImage: `linear-gradient(90deg, #64748b 1px, transparent 1px), linear-gradient(0deg, #64748b 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}></div>
</div>
</>
);
case 'academic':
return (
<>
{/* Academic traditional border */}
<div className="absolute inset-2 border-3 border-indigo-300 opacity-50"></div>
<div className="absolute inset-3 border border-indigo-400 opacity-40"></div>
{/* Academic shield-like corners */}
<div className="absolute top-2 left-2 w-8 h-8 border-2 border-indigo-400 opacity-50 rounded-tl-lg"></div>
<div className="absolute top-2 right-2 w-8 h-8 border-2 border-indigo-400 opacity-50 rounded-tr-lg"></div>
<div className="absolute bottom-2 left-2 w-8 h-8 border-2 border-indigo-400 opacity-50 rounded-bl-lg"></div>
<div className="absolute bottom-2 right-2 w-8 h-8 border-2 border-indigo-400 opacity-50 rounded-br-lg"></div>
{/* Academic laurel-like decorations */}
<div className="absolute top-1/2 left-1 transform -translate-y-1/2">
<div className="w-1 h-6 bg-indigo-300 opacity-40 rounded-full"></div>
</div>
<div className="absolute top-1/2 right-1 transform -translate-y-1/2">
<div className="w-1 h-6 bg-indigo-300 opacity-40 rounded-full"></div>
</div>
{/* Academic background pattern */}
<div className="absolute inset-0 opacity-3">
<div className="w-full h-full" style={{
backgroundImage: `radial-gradient(circle at 50% 50%, #6366f1 1px, transparent 1px)`,
backgroundSize: '15px 15px'
}}></div>
</div>
</>
);
case 'modern':
return (
<>
{/* Modern clean asymmetric border */}
<div className="absolute inset-2 border-2 border-gray-300 opacity-50" style={{
clipPath: 'polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px))'
}}></div>
{/* Modern accent lines */}
<div className="absolute top-2 left-2 w-8 h-0.5 bg-blue-400 opacity-60"></div>
<div className="absolute top-2 left-2 w-0.5 h-8 bg-blue-400 opacity-60"></div>
<div className="absolute bottom-2 right-2 w-8 h-0.5 bg-blue-400 opacity-60"></div>
<div className="absolute bottom-2 right-2 w-0.5 h-8 bg-blue-400 opacity-60"></div>
{/* Modern dot accents */}
<div className="absolute top-4 right-4 w-2 h-2 bg-blue-400 opacity-50 rounded-full"></div>
<div className="absolute bottom-4 left-4 w-2 h-2 bg-blue-400 opacity-50 rounded-full"></div>
{/* Modern subtle background */}
<div className="absolute inset-0 opacity-2">
<div className="w-full h-full" style={{
backgroundImage: `linear-gradient(135deg, #3b82f6 0%, transparent 1%), linear-gradient(225deg, #3b82f6 0%, transparent 1%)`,
backgroundSize: '12px 12px'
}}></div>
</div>
</>
);
default:
return null;
}
};
const theme = getPatternTheme(certificatePattern);
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-4 w-full h-full">
<div className="bg-white rounded-lg shadow-sm p-6 relative overflow-hidden w-full h-full flex flex-col">
{/* Dynamic Certificate Pattern */}
{renderCertificatePattern(certificatePattern)}
{/* Certificate ID - Top Left */}
<div className="absolute top-4 left-4 sm:top-6 sm:left-6 z-20">
<div className="flex items-center space-x-1">
<Hash className={`w-3 h-3 sm:w-4 sm:h-4 ${theme.icon}`} />
<span className={`text-xs sm:text-sm ${theme.secondary} font-medium`}>ID: {certificateId || 'LH-2024-001'}</span>
</div>
</div>
{/* QR Code Box - Top Right */}
<div className="absolute top-4 right-4 sm:top-6 sm:right-6 z-20">
<div className={`w-16 h-16 sm:w-24 sm:h-24 border-2 ${theme.secondary.replace('text-', 'border-')} rounded-md bg-white/90 backdrop-blur-sm p-1`}>
{qrCodeUrl ? (
<img
src={qrCodeUrl}
alt="Certificate QR Code"
className="w-full h-full object-contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<QrCode className={`w-8 h-8 sm:w-12 sm:h-12 ${theme.icon}`} />
</div>
)}
</div>
</div>
{/* Main Content */}
<div className="relative z-10 flex-1 flex flex-col items-center justify-center text-center space-y-3 px-6 py-6">
{/* Header with decorative line */}
<div className="flex items-center justify-center space-x-2 mb-2">
<div className={`w-6 sm:w-8 h-px bg-gradient-to-r from-transparent ${theme.secondary.replace('text-', 'to-')}`}></div>
<div className={`text-xs sm:text-sm ${theme.secondary} font-medium uppercase tracking-wider`}>Certificate</div>
<div className={`w-6 sm:w-8 h-px bg-gradient-to-l from-transparent ${theme.secondary.replace('text-', 'to-')}`}></div>
</div>
{/* Award Icon with decorative elements */}
<div className="flex justify-center relative">
<div className={`w-12 h-12 sm:w-16 sm:h-16 bg-gradient-to-br ${theme.icon.replace('text-', 'from-')}-100 ${theme.icon.replace('text-', 'to-')}-200 rounded-full flex items-center justify-center relative`}>
<Award className={`w-6 h-6 sm:w-8 sm:h-8 ${theme.icon}`} />
{/* Decorative rays */}
<div className="absolute inset-0 rounded-full">
<div className={`absolute top-0 left-1/2 w-px h-2 sm:h-3 ${theme.secondary.replace('text-', 'bg-')} transform -translate-x-1/2 -translate-y-1 opacity-60`}></div>
<div className={`absolute bottom-0 left-1/2 w-px h-2 sm:h-3 ${theme.secondary.replace('text-', 'bg-')} transform -translate-x-1/2 translate-y-1 opacity-60`}></div>
<div className={`absolute left-0 top-1/2 w-2 sm:w-3 h-px ${theme.secondary.replace('text-', 'bg-')} transform -translate-y-1/2 -translate-x-1 opacity-60`}></div>
<div className={`absolute right-0 top-1/2 w-2 sm:w-3 h-px ${theme.secondary.replace('text-', 'bg-')} transform -translate-y-1/2 translate-x-1 opacity-60`}></div>
</div>
</div>
</div>
{/* Certificate Content */}
<div className="flex flex-col justify-center items-center flex-1 max-w-full">
<h4 className={`font-bold text-sm sm:text-base ${theme.primary} mb-2 text-center`}>
{certificationName || 'Certification Name'}
</h4>
<p className={`text-xs sm:text-sm ${theme.secondary} text-center leading-relaxed max-w-xs sm:max-w-sm`}>
{certificationDescription || 'Certification description will appear here...'}
</p>
</div>
{/* Decorative divider */}
<div className="flex items-center justify-center space-x-1 py-1">
<div className={`w-2 h-px ${theme.secondary.replace('text-', 'bg-')} opacity-50`}></div>
<div className={`w-1 h-1 ${theme.primary.replace('text-', 'bg-')} rounded-full opacity-60`}></div>
<div className={`w-2 h-px ${theme.secondary.replace('text-', 'bg-')} opacity-50`}></div>
</div>
{/* Certification Type Badge */}
<div className={`inline-flex items-center space-x-1 text-xs sm:text-sm ${theme.badge} px-3 py-1 rounded-full border`}>
<CheckCircle size={12} />
<span className="font-medium">
{certificationType === 'completion' ? 'Course Completion' :
certificationType === 'achievement' ? 'Achievement Based' :
certificationType === 'assessment' ? 'Assessment Based' :
certificationType === 'participation' ? 'Participation' :
certificationType === 'mastery' ? 'Skill Mastery' :
certificationType === 'professional' ? 'Professional Development' :
certificationType === 'continuing' ? 'Continuing Education' :
certificationType === 'workshop' ? 'Workshop Attendance' :
certificationType === 'specialization' ? 'Specialization' : 'Course Completion'}
</span>
</div>
</div>
{/* Bottom Section */}
<div className="relative z-10 mt-auto p-6 pt-8">
<div className="flex items-end justify-between w-full">
{/* Left: Teacher/Organization Signature */}
<div className="flex flex-col items-start space-y-1 flex-1">
<div className="flex items-center space-x-1">
<User className={`w-2.5 h-2.5 sm:w-3 sm:h-3 ${theme.icon}`} />
<span className={`text-xs ${theme.secondary} font-medium`}>Instructor</span>
</div>
<div className={`text-xs ${theme.primary} font-semibold`}>
{certificateInstructor || 'Dr. Jane Smith'}
</div>
<div className={`h-px w-10 sm:w-12 ${theme.secondary.replace('text-', 'bg-')} opacity-50`}></div>
</div>
{/* Center: Logo */}
<div className="flex flex-col items-center space-y-1 flex-1">
<div className={`w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center`}>
{org?.logo_image ? (
<img
src={`${getOrgLogoMediaDirectory(org.org_uuid, org?.logo_image)}`}
alt="Organization Logo"
className="w-full h-full object-contain"
/>
) : (
<div className={`w-full h-full ${theme.icon.replace('text-', 'bg-')}-100 rounded-full flex items-center justify-center`}>
<Building className={`w-4 h-4 sm:w-5 sm:h-5 ${theme.icon}`} />
</div>
)}
</div>
<div className={`text-xs ${theme.secondary} font-medium`}>
{org?.name || 'LearnHouse'}
</div>
</div>
{/* Right: Award Date */}
<div className="flex flex-col items-end space-y-1 flex-1">
<div className="flex items-center space-x-1">
<Calendar className={`w-2.5 h-2.5 sm:w-3 sm:h-3 ${theme.icon}`} />
<span className={`text-xs ${theme.secondary} font-medium`}>Awarded</span>
</div>
<div className={`text-xs ${theme.primary} font-semibold`}>
{awardedDate || 'Dec 15, 2024'}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default CertificatePreview;

View file

@ -0,0 +1,518 @@
import {
FormField,
FormLabelAndMessage,
Input,
Textarea,
} from '@components/Objects/StyledElements/Form/Form';
import { useFormik } from 'formik';
import { AlertTriangle, Award, FileText, Settings } from 'lucide-react';
import CertificatePreview from './CertificatePreview';
import * as Form from '@radix-ui/react-form';
import React, { useEffect, useState } from 'react';
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import {
createCertification,
updateCertification,
deleteCertification
} from '@services/courses/certifications';
import {
CustomSelect,
CustomSelectContent,
CustomSelectItem,
CustomSelectTrigger,
CustomSelectValue,
} from "../EditCourseGeneral/CustomSelect";
import useSWR, { mutate } from 'swr';
import { getAPIUrl } from '@services/config/config';
import toast from 'react-hot-toast';
type EditCourseCertificationProps = {
orgslug: string
course_uuid?: string
}
const validate = (values: any) => {
const errors = {} as any;
if (values.enable_certification && !values.certification_name) {
errors.certification_name = 'Required when certification is enabled';
} else if (values.certification_name && values.certification_name.length > 100) {
errors.certification_name = 'Must be 100 characters or less';
}
if (values.enable_certification && !values.certification_description) {
errors.certification_description = 'Required when certification is enabled';
} else if (values.certification_description && values.certification_description.length > 500) {
errors.certification_description = 'Must be 500 characters or less';
}
return errors;
};
function EditCourseCertification(props: EditCourseCertificationProps) {
const [error, setError] = useState('');
const [isCreating, setIsCreating] = useState(false);
const course = useCourse();
const dispatchCourse = useCourseDispatch() as any;
const { isLoading, courseStructure } = course as any;
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
// Fetch existing certifications
const { data: certifications, error: certificationsError, mutate: mutateCertifications } = useSWR(
courseStructure?.course_uuid && access_token ?
`certifications/course/${courseStructure.course_uuid}` : null,
async () => {
if (!courseStructure?.course_uuid || !access_token) return null;
const result = await fetch(
`${getAPIUrl()}certifications/course/${courseStructure.course_uuid}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${access_token}`,
},
credentials: 'include',
}
);
const response = await result.json();
if (result.status === 200) {
return {
success: true,
data: response,
status: result.status,
HTTPmessage: result.statusText,
};
} else {
return {
success: false,
data: response,
status: result.status,
HTTPmessage: result.statusText,
};
}
}
);
const existingCertification = certifications?.data?.[0]; // Assuming one certification per course
const hasExistingCertification = !!existingCertification;
// Create initial values object
const getInitialValues = () => {
// Helper function to get instructor name from authors
const getInstructorName = () => {
if (courseStructure?.authors && courseStructure.authors.length > 0) {
const author = courseStructure.authors[0];
const firstName = author.first_name || '';
const lastName = author.last_name || '';
// Only return if at least one name exists
if (firstName || lastName) {
return `${firstName} ${lastName}`.trim();
}
}
return '';
};
// Use existing certification data if available, otherwise fall back to course data
const config = existingCertification?.config || {};
return {
enable_certification: hasExistingCertification,
certification_name: config.certification_name || courseStructure?.name || '',
certification_description: config.certification_description || courseStructure?.description || '',
certification_type: config.certification_type || 'completion',
certificate_pattern: config.certificate_pattern || 'professional',
certificate_instructor: config.certificate_instructor || getInstructorName(),
};
};
const formik = useFormik({
initialValues: getInitialValues(),
validate,
onSubmit: async values => {
// This is no longer used - saving is handled by the main Save button
},
enableReinitialize: true,
}) as any;
// Handle enabling/disabling certification
const handleCertificationToggle = async (enabled: boolean) => {
if (enabled && !hasExistingCertification) {
// Create new certification
setIsCreating(true);
try {
const config = {
certification_name: formik.values.certification_name || courseStructure?.name || '',
certification_description: formik.values.certification_description || courseStructure?.description || '',
certification_type: formik.values.certification_type || 'completion',
certificate_pattern: formik.values.certificate_pattern || 'professional',
certificate_instructor: formik.values.certificate_instructor || '',
};
const result = await createCertification(
courseStructure.id,
config,
access_token
);
// createCertification uses errorHandling which returns JSON directly on success
if (result) {
toast.success('Certification created successfully');
mutateCertifications();
formik.setFieldValue('enable_certification', true);
} else {
throw new Error('Failed to create certification');
}
} catch (e) {
setError('Failed to create certification.');
toast.error('Failed to create certification');
formik.setFieldValue('enable_certification', false);
} finally {
setIsCreating(false);
}
} else if (!enabled && hasExistingCertification) {
// Delete existing certification
try {
const result = await deleteCertification(
existingCertification.certification_uuid,
access_token
);
// deleteCertification uses errorHandling which returns JSON directly on success
if (result) {
toast.success('Certification removed successfully');
mutateCertifications();
formik.setFieldValue('enable_certification', false);
} else {
throw new Error('Failed to delete certification');
}
} catch (e) {
setError('Failed to remove certification.');
toast.error('Failed to remove certification');
formik.setFieldValue('enable_certification', true);
}
} else {
formik.setFieldValue('enable_certification', enabled);
}
};
// Reset form when certifications data changes
useEffect(() => {
if (certifications && !isLoading) {
const newValues = getInitialValues();
formik.resetForm({ values: newValues });
}
}, [certifications, isLoading]);
// Handle form changes - update course context with certification data
useEffect(() => {
if (!isLoading && hasExistingCertification) {
const formikValues = formik.values as any;
const initialValues = formik.initialValues as any;
const valuesChanged = Object.keys(formikValues).some(
key => formikValues[key] !== initialValues[key]
);
if (valuesChanged) {
dispatchCourse({ type: 'setIsNotSaved' });
// Store certification data in course context so it gets saved with the main save button
const updatedCourse = {
...courseStructure,
// Store certification data for the main save functionality
_certificationData: {
certification_uuid: existingCertification.certification_uuid,
config: {
certification_name: formikValues.certification_name,
certification_description: formikValues.certification_description,
certification_type: formikValues.certification_type,
certificate_pattern: formikValues.certificate_pattern,
certificate_instructor: formikValues.certificate_instructor,
}
}
};
dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse });
}
}
}, [formik.values, isLoading, hasExistingCertification, existingCertification]);
if (isLoading || !courseStructure || (courseStructure.course_uuid && access_token && certifications === undefined)) {
return <div>Loading...</div>;
}
if (certificationsError) {
return <div>Error loading certifications</div>;
}
return (
<div>
{courseStructure && (
<div>
<div className="h-6"></div>
<div className="mx-4 sm:mx-10 bg-white rounded-xl shadow-xs px-4 py-4">
{/* Header Section */}
<div className="flex items-center justify-between bg-gray-50 px-3 sm:px-5 py-3 rounded-md mb-3">
<div className="flex flex-col -space-y-1">
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Course Certification</h1>
<h2 className="text-gray-500 text-xs sm:text-sm">
Enable and configure certificates for students who complete this course
</h2>
</div>
<div className="flex items-center space-x-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={formik.values.enable_certification}
onChange={(e) => handleCertificationToggle(e.target.checked)}
disabled={isCreating}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
{isCreating && (
<div className="animate-spin">
<Settings size={16} />
</div>
)}
</div>
</div>
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 mb-6 transition-all shadow-xs">
<AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div>
</div>
)}
{/* Certification Configuration - Only show if enabled and has existing certification */}
{formik.values.enable_certification && hasExistingCertification && (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Form Section */}
<div className="lg:col-span-3">
<Form.Root className="space-y-6">
{/* Basic Information Section */}
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
<h3 className="font-bold text-md text-gray-800 flex items-center gap-2">
<FileText size={16} />
Basic Information
</h3>
<p className="text-gray-500 text-xs sm:text-sm">
Configure the basic details of your certification
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Certification Name */}
<FormField name="certification_name">
<FormLabelAndMessage
label="Certification Name"
message={formik.errors.certification_name}
/>
<Form.Control asChild>
<Input
style={{ backgroundColor: 'white' }}
onChange={formik.handleChange}
value={formik.values.certification_name}
type="text"
placeholder="e.g., Advanced JavaScript Certification"
required
/>
</Form.Control>
</FormField>
{/* Certification Type */}
<FormField name="certification_type">
<FormLabelAndMessage label="Certification Type" />
<Form.Control asChild>
<CustomSelect
value={formik.values.certification_type}
onValueChange={(value) => {
if (!value) return;
formik.setFieldValue('certification_type', value);
}}
>
<CustomSelectTrigger className="w-full bg-white">
<CustomSelectValue>
{formik.values.certification_type === 'completion' ? 'Course Completion' :
formik.values.certification_type === 'achievement' ? 'Achievement Based' :
formik.values.certification_type === 'assessment' ? 'Assessment Based' :
formik.values.certification_type === 'participation' ? 'Participation' :
formik.values.certification_type === 'mastery' ? 'Skill Mastery' :
formik.values.certification_type === 'professional' ? 'Professional Development' :
formik.values.certification_type === 'continuing' ? 'Continuing Education' :
formik.values.certification_type === 'workshop' ? 'Workshop Attendance' :
formik.values.certification_type === 'specialization' ? 'Specialization' : 'Course Completion'}
</CustomSelectValue>
</CustomSelectTrigger>
<CustomSelectContent>
<CustomSelectItem value="completion">Course Completion</CustomSelectItem>
<CustomSelectItem value="achievement">Achievement Based</CustomSelectItem>
<CustomSelectItem value="assessment">Assessment Based</CustomSelectItem>
<CustomSelectItem value="participation">Participation</CustomSelectItem>
<CustomSelectItem value="mastery">Skill Mastery</CustomSelectItem>
<CustomSelectItem value="professional">Professional Development</CustomSelectItem>
<CustomSelectItem value="continuing">Continuing Education</CustomSelectItem>
<CustomSelectItem value="workshop">Workshop Attendance</CustomSelectItem>
<CustomSelectItem value="specialization">Specialization</CustomSelectItem>
</CustomSelectContent>
</CustomSelect>
</Form.Control>
</FormField>
</div>
{/* Certification Description */}
<FormField name="certification_description">
<FormLabelAndMessage
label="Certification Description"
message={formik.errors.certification_description}
/>
<Form.Control asChild>
<Textarea
style={{ backgroundColor: 'white', height: '120px', minHeight: '120px' }}
onChange={formik.handleChange}
value={formik.values.certification_description}
placeholder="Describe what this certification represents and its value..."
required
/>
</Form.Control>
</FormField>
{/* Certificate Design Section */}
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
<h3 className="font-bold text-md text-gray-800 flex items-center gap-2">
<Award size={16} />
Certificate Design
</h3>
<p className="text-gray-500 text-xs sm:text-sm">
Choose a decorative pattern for your certificate
</p>
</div>
{/* Pattern Selection */}
<FormField name="certificate_pattern">
<FormLabelAndMessage label="Certificate Pattern" />
<Form.Control asChild>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{[
{ value: 'royal', name: 'Royal', description: 'Ornate with crown motifs' },
{ value: 'tech', name: 'Tech', description: 'Circuit-inspired patterns' },
{ value: 'nature', name: 'Nature', description: 'Organic leaf patterns' },
{ value: 'geometric', name: 'Geometric', description: 'Abstract shapes & lines' },
{ value: 'vintage', name: 'Vintage', description: 'Art deco styling' },
{ value: 'waves', name: 'Waves', description: 'Flowing water patterns' },
{ value: 'minimal', name: 'Minimal', description: 'Clean and simple' },
{ value: 'professional', name: 'Professional', description: 'Business-ready design' },
{ value: 'academic', name: 'Academic', description: 'Traditional university style' },
{ value: 'modern', name: 'Modern', description: 'Contemporary clean lines' }
].map((pattern) => (
<div
key={pattern.value}
className={`p-3 border-2 rounded-lg cursor-pointer transition-all ${
formik.values.certificate_pattern === pattern.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => formik.setFieldValue('certificate_pattern', pattern.value)}
>
<div className="text-center">
<div className="text-sm font-medium text-gray-900">{pattern.name}</div>
<div className="text-xs text-gray-500 mt-1">{pattern.description}</div>
</div>
</div>
))}
</div>
</Form.Control>
</FormField>
{/* Custom Instructor */}
<FormField name="certificate_instructor">
<FormLabelAndMessage label="Instructor Name (Optional)" />
<Form.Control asChild>
<Input
style={{ backgroundColor: 'white' }}
onChange={formik.handleChange}
value={formik.values.certificate_instructor}
type="text"
placeholder="e.g., Dr. Jane Smith"
/>
</Form.Control>
</FormField>
</Form.Root>
</div>
{/* Preview Section */}
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-xs border border-gray-200 sticky top-6 min-h-[320px]">
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-t-xl mb-3">
<h3 className="font-bold text-md text-gray-800 flex items-center gap-2">
<Award size={16} />
Certificate Preview
</h3>
<p className="text-gray-500 text-xs sm:text-sm">
Live preview of your certificate
</p>
</div>
<div className="p-4">
<CertificatePreview
certificationName={formik.values.certification_name}
certificationDescription={formik.values.certification_description}
certificationType={formik.values.certification_type}
certificatePattern={formik.values.certificate_pattern}
certificateInstructor={formik.values.certificate_instructor}
/>
</div>
</div>
</div>
</div>
)}
{/* Disabled State */}
{!formik.values.enable_certification && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<Award className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="font-medium text-gray-700 mb-2">No Certification Configured</h3>
<p className="text-sm text-gray-500 mb-4">
Enable certification to provide students with certificates upon course completion.
</p>
<button
type="button"
onClick={() => handleCertificationToggle(true)}
disabled={isCreating}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Award size={16} />
{isCreating ? 'Creating...' : 'Enable Certification'}
</button>
</div>
)}
{/* Creating State - when toggle is on but no certification exists yet */}
{formik.values.enable_certification && !hasExistingCertification && isCreating && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-8 text-center">
<div className="animate-spin mx-auto mb-4">
<Settings className="w-16 h-16 text-blue-500" />
</div>
<h3 className="font-medium text-blue-700 mb-2">Creating Certification...</h3>
<p className="text-sm text-blue-600">
Please wait while we set up your course certification.
</p>
</div>
)}
</div>
</div>
)}
</div>
);
}
export default EditCourseCertification;

View file

@ -0,0 +1,167 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
interface CustomSelectProps {
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
children: React.ReactNode;
}
interface CustomSelectItemProps {
value: string;
children: React.ReactNode;
className?: string;
}
interface CustomSelectTriggerProps {
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
interface CustomSelectContentProps {
children: React.ReactNode;
className?: string;
}
const CustomSelectContext = React.createContext<{
isOpen: boolean;
setIsOpen: (open: boolean) => void;
selectedValue: string;
setSelectedValue: (value: string) => void;
onValueChange: (value: string) => void;
disabled?: boolean;
} | null>(null);
export const CustomSelect: React.FC<CustomSelectProps> = ({
value,
onValueChange,
placeholder,
className = '',
disabled = false,
children
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
useEffect(() => {
setSelectedValue(value);
}, [value]);
const handleValueChange = (newValue: string) => {
if (disabled) return;
setSelectedValue(newValue);
onValueChange(newValue);
setIsOpen(false);
};
return (
<CustomSelectContext.Provider
value={{
isOpen,
setIsOpen,
selectedValue,
setSelectedValue,
onValueChange: handleValueChange,
disabled
}}
>
<div className={`relative ${className}`}>
{children}
</div>
</CustomSelectContext.Provider>
);
};
export const CustomSelectTrigger: React.FC<CustomSelectTriggerProps> = ({
children,
className = '',
disabled = false
}) => {
const context = React.useContext(CustomSelectContext);
if (!context) {
throw new Error('CustomSelectTrigger must be used within CustomSelect');
}
const { isOpen, setIsOpen, disabled: contextDisabled } = context;
const isDisabled = disabled || contextDisabled;
return (
<button
type="button"
disabled={isDisabled}
className={`flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 ${className}`}
onClick={() => !isDisabled && setIsOpen(!isOpen)}
>
{children}
<ChevronDown className={`h-4 w-4 opacity-50 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
);
};
export const CustomSelectContent: React.FC<CustomSelectContentProps> = ({
children,
className = ''
}) => {
const context = React.useContext(CustomSelectContext);
if (!context) {
throw new Error('CustomSelectContent must be used within CustomSelect');
}
const { isOpen, disabled } = context;
if (!isOpen || disabled) return null;
return (
<div className={`absolute z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 ${className}`}>
<div className="p-1">
{children}
</div>
</div>
);
};
export const CustomSelectItem: React.FC<CustomSelectItemProps> = ({
value,
children,
className = ''
}) => {
const context = React.useContext(CustomSelectContext);
if (!context) {
throw new Error('CustomSelectItem must be used within CustomSelect');
}
const { selectedValue, onValueChange, disabled } = context;
return (
<div
className={`relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
onClick={() => !disabled && onValueChange(value)}
>
{children}
{selectedValue === value && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
)}
</div>
);
};
export const CustomSelectValue: React.FC<{ children?: React.ReactNode }> = ({
children
}) => {
const context = React.useContext(CustomSelectContext);
if (!context) {
throw new Error('CustomSelectValue must be used within CustomSelect');
}
const { selectedValue } = context;
return <span>{children || selectedValue}</span>;
};

View file

@ -13,12 +13,12 @@ import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext
import FormTagInput from '@components/Objects/StyledElements/Form/TagInput'; import FormTagInput from '@components/Objects/StyledElements/Form/TagInput';
import LearningItemsList from './LearningItemsList'; import LearningItemsList from './LearningItemsList';
import { import {
Select, CustomSelect,
SelectContent, CustomSelectContent,
SelectItem, CustomSelectItem,
SelectTrigger, CustomSelectTrigger,
SelectValue, CustomSelectValue,
} from "@components/ui/select"; } from "./CustomSelect";
type EditCourseStructureProps = { type EditCourseStructureProps = {
orgslug: string orgslug: string
@ -245,26 +245,26 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
<FormField name="thumbnail_type"> <FormField name="thumbnail_type">
<FormLabelAndMessage label="Thumbnail Type" /> <FormLabelAndMessage label="Thumbnail Type" />
<Form.Control asChild> <Form.Control asChild>
<Select <CustomSelect
value={formik.values.thumbnail_type} value={formik.values.thumbnail_type}
onValueChange={(value) => { onValueChange={(value) => {
if (!value) return; if (!value) return;
formik.setFieldValue('thumbnail_type', value); formik.setFieldValue('thumbnail_type', value);
}} }}
> >
<SelectTrigger className="w-full bg-white"> <CustomSelectTrigger className="w-full bg-white">
<SelectValue> <CustomSelectValue>
{formik.values.thumbnail_type === 'image' ? 'Image' : {formik.values.thumbnail_type === 'image' ? 'Image' :
formik.values.thumbnail_type === 'video' ? 'Video' : formik.values.thumbnail_type === 'video' ? 'Video' :
formik.values.thumbnail_type === 'both' ? 'Both' : 'Image'} formik.values.thumbnail_type === 'both' ? 'Both' : 'Image'}
</SelectValue> </CustomSelectValue>
</SelectTrigger> </CustomSelectTrigger>
<SelectContent> <CustomSelectContent>
<SelectItem value="image">Image</SelectItem> <CustomSelectItem value="image">Image</CustomSelectItem>
<SelectItem value="video">Video</SelectItem> <CustomSelectItem value="video">Video</CustomSelectItem>
<SelectItem value="both">Both</SelectItem> <CustomSelectItem value="both">Both</CustomSelectItem>
</SelectContent> </CustomSelectContent>
</Select> </CustomSelect>
</Form.Control> </Form.Control>
</FormField> </FormField>

View file

@ -52,7 +52,7 @@ const LinkItem = (props: any) => {
{props.type == 'trail' && ( {props.type == 'trail' && (
<> <>
<Signpost size={20} />{' '} <Signpost size={20} />{' '}
<span>Trail</span> <span>Progress</span>
</> </>
)} )}
</li> </li>

View file

@ -1,79 +1,603 @@
import React from 'react'; import React, { useMemo, useEffect, useState } from 'react';
import ReactConfetti from 'react-confetti'; import ReactConfetti from 'react-confetti';
import { Trophy, ArrowLeft } from 'lucide-react'; import { Trophy, ArrowLeft, BookOpen, Target, Download, Shield } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { getCourseThumbnailMediaDirectory } from '@services/media/media'; import { getCourseThumbnailMediaDirectory } from '@services/media/media';
import { useWindowSize } from 'usehooks-ts'; import { useWindowSize } from 'usehooks-ts';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { getUserCertificates } from '@services/courses/certifications';
import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import QRCode from 'qrcode';
interface CourseEndViewProps { interface CourseEndViewProps {
courseName: string; courseName: string;
orgslug: string; orgslug: string;
courseUuid: string; courseUuid: string;
thumbnailImage: string; thumbnailImage: string;
course: any;
trailData: any;
} }
const CourseEndView: React.FC<CourseEndViewProps> = ({ courseName, orgslug, courseUuid, thumbnailImage }) => { const CourseEndView: React.FC<CourseEndViewProps> = ({
courseName,
orgslug,
courseUuid,
thumbnailImage,
course,
trailData
}) => {
const { width, height } = useWindowSize(); const { width, height } = useWindowSize();
const org = useOrg() as any; const org = useOrg() as any;
const session = useLHSession() as any;
const [userCertificate, setUserCertificate] = useState<any>(null);
const [isLoadingCertificate, setIsLoadingCertificate] = useState(false);
const [certificateError, setCertificateError] = useState<string | null>(null);
const qrCodeLink = getUriWithOrg(orgslug, `/certificates/${userCertificate?.certificate_user.user_certification_uuid}/verify`);
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4 relative overflow-hidden">
<div className="fixed inset-0 pointer-events-none"> // Check if course is actually completed
<ReactConfetti const isCourseCompleted = useMemo(() => {
width={width} if (!trailData || !course) return false;
height={height}
numberOfPieces={200} // Flatten all activities
recycle={false} const allActivities = course.chapters.flatMap((chapter: any) =>
colors={['#6366f1', '#10b981', '#3b82f6']} chapter.activities.map((activity: any) => ({
/> ...activity,
</div> chapterId: chapter.id
}))
);
// Check if all activities are completed
const isActivityDone = (activity: any) => {
const cleanCourseUuid = course.course_uuid?.replace('course_', '');
const run = trailData?.runs?.find(
(run: any) => {
const cleanRunCourseUuid = run.course?.course_uuid?.replace('course_', '');
return cleanRunCourseUuid === cleanCourseUuid;
}
);
<div className="bg-white rounded-2xl p-8 nice-shadow max-w-2xl w-full space-y-6 relative z-10"> if (run) {
<div className="flex flex-col items-center space-y-6"> return run.steps.find(
{thumbnailImage && ( (step: any) => step.activity_id === activity.id && step.complete === true
<img );
className="w-[200px] h-[114px] rounded-lg shadow-md object-cover" }
src={`${getCourseThumbnailMediaDirectory( return false;
org?.org_uuid, };
courseUuid,
thumbnailImage const totalActivities = allActivities.length;
)}`} const completedActivities = allActivities.filter((activity: any) => isActivityDone(activity)).length;
alt={courseName} return totalActivities > 0 && completedActivities === totalActivities;
/> }, [trailData, course]);
)}
// Fetch user certificate when course is completed
useEffect(() => {
const fetchUserCertificate = async () => {
if (!isCourseCompleted) return;
if (!session?.data?.tokens?.access_token) {
setCertificateError('Authentication required to view certificate');
return;
}
setIsLoadingCertificate(true);
setCertificateError(null);
try {
const cleanCourseUuid = courseUuid.replace('course_', '');
const result = await getUserCertificates(
`course_${cleanCourseUuid}`,
session.data.tokens.access_token
);
if (result.success && result.data && result.data.length > 0) {
setUserCertificate(result.data[0]);
} else {
setCertificateError('No certificate found for this course');
}
} catch (error) {
console.error('Error fetching user certificate:', error);
setCertificateError('Failed to load certificate. Please try again later.');
} finally {
setIsLoadingCertificate(false);
}
};
fetchUserCertificate();
}, [isCourseCompleted, courseUuid, session?.data?.tokens?.access_token]);
// Generate PDF using canvas
const downloadCertificate = async () => {
if (!userCertificate) return;
try {
// Create a temporary div for the certificate
const certificateDiv = document.createElement('div');
certificateDiv.style.position = 'absolute';
certificateDiv.style.left = '-9999px';
certificateDiv.style.top = '0';
certificateDiv.style.width = '800px';
certificateDiv.style.height = '600px';
certificateDiv.style.background = 'white';
certificateDiv.style.padding = '40px';
certificateDiv.style.fontFamily = 'Arial, sans-serif';
certificateDiv.style.textAlign = 'center';
certificateDiv.style.display = 'flex';
certificateDiv.style.flexDirection = 'column';
certificateDiv.style.justifyContent = 'center';
certificateDiv.style.alignItems = 'center';
certificateDiv.style.position = 'relative';
certificateDiv.style.overflow = 'hidden';
// Get theme colors based on pattern
const getPatternTheme = (pattern: string) => {
switch (pattern) {
case 'royal':
return { primary: '#b45309', secondary: '#d97706', icon: '#d97706' };
case 'tech':
return { primary: '#0e7490', secondary: '#0891b2', icon: '#0891b2' };
case 'nature':
return { primary: '#15803d', secondary: '#16a34a', icon: '#16a34a' };
case 'geometric':
return { primary: '#7c3aed', secondary: '#9333ea', icon: '#9333ea' };
case 'vintage':
return { primary: '#c2410c', secondary: '#ea580c', icon: '#ea580c' };
case 'waves':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
case 'minimal':
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
case 'professional':
return { primary: '#334155', secondary: '#475569', icon: '#475569' };
case 'academic':
return { primary: '#3730a3', secondary: '#4338ca', icon: '#4338ca' };
case 'modern':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
default:
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
}
};
const theme = getPatternTheme(userCertificate.certification.config.certificate_pattern);
const certificateId = userCertificate.certificate_user.user_certification_uuid;
const qrCodeData = qrCodeLink;
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(qrCodeData, {
width: 120,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M',
type: 'image/png'
});
// Create certificate content
certificateDiv.innerHTML = `
<div style="
position: absolute;
top: 20px;
left: 20px;
font-size: 12px;
color: ${theme.secondary};
font-weight: 500;
">ID: ${certificateId}</div>
<div style="
position: absolute;
top: 20px;
right: 20px;
width: 80px;
height: 80px;
border: 2px solid ${theme.secondary};
border-radius: 8px;
background: white;
display: flex;
align-items: center;
justify-content: center;
">
<img src="${qrCodeDataUrl}" alt="QR Code" style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 30px;
font-size: 14px;
color: ${theme.secondary};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
">
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
Certificate
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
</div>
<div style="
width: 80px;
height: 80px;
background: linear-gradient(135deg, ${theme.icon}20 0%, ${theme.icon}40 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 30px;
font-size: 40px;
line-height: 1;
">🏆</div>
<div style="
font-size: 32px;
font-weight: bold;
color: ${theme.primary};
margin-bottom: 20px;
line-height: 1.2;
max-width: 600px;
">${userCertificate.certification.config.certification_name}</div>
<div style="
font-size: 18px;
color: #6b7280;
margin-bottom: 30px;
line-height: 1.5;
max-width: 500px;
">${userCertificate.certification.config.certification_description || 'This is to certify that the course has been successfully completed.'}</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin: 20px 0;
">
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
<div style="width: 4px; height: 4px; background: ${theme.primary}; border-radius: 50%; opacity: 0.6;"></div>
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
</div>
<div style="
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
color: ${theme.primary};
background: ${theme.icon}10;
padding: 12px 24px;
border-radius: 20px;
border: 1px solid ${theme.icon}20;
font-weight: 500;
margin-bottom: 30px;
white-space: nowrap;
">
<span style="font-weight: bold; font-size: 18px;"></span>
<span>${userCertificate.certification.config.certification_type === 'completion' ? 'Course Completion' :
userCertificate.certification.config.certification_type === 'achievement' ? 'Achievement Based' :
userCertificate.certification.config.certification_type === 'assessment' ? 'Assessment Based' :
userCertificate.certification.config.certification_type === 'participation' ? 'Participation' :
userCertificate.certification.config.certification_type === 'mastery' ? 'Skill Mastery' :
userCertificate.certification.config.certification_type === 'professional' ? 'Professional Development' :
userCertificate.certification.config.certification_type === 'continuing' ? 'Continuing Education' :
userCertificate.certification.config.certification_type === 'workshop' ? 'Workshop Attendance' :
userCertificate.certification.config.certification_type === 'specialization' ? 'Specialization' : 'Course Completion'}</span>
</div>
<div style="
margin-top: 30px;
padding: 24px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
max-width: 400px;
">
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Certificate ID:</strong> ${certificateId}
</div>
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Awarded:</strong> ${new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
${userCertificate.certification.config.certificate_instructor ?
`<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Instructor:</strong> ${userCertificate.certification.config.certificate_instructor}
</div>` : ''
}
</div>
<div style="
margin-top: 20px;
font-size: 12px;
color: #6b7280;
">
This certificate can be verified at ${qrCodeLink}
</div>
`;
// Add to document temporarily
document.body.appendChild(certificateDiv);
// Convert to canvas
const canvas = await html2canvas(certificateDiv, {
width: 800,
height: 600,
scale: 2, // Higher resolution
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
// Remove temporary div
document.body.removeChild(certificateDiv);
// Create PDF
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('landscape', 'mm', 'a4');
// Calculate dimensions to center the certificate
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgWidth = 280; // mm
const imgHeight = 210; // mm
// Center the image
const x = (pdfWidth - imgWidth) / 2;
const y = (pdfHeight - imgHeight) / 2;
pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight);
// Save the PDF
const fileName = `${userCertificate.certification.config.certification_name.replace(/[^a-zA-Z0-9]/g, '_')}_Certificate.pdf`;
pdf.save(fileName);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
}
};
// Calculate progress for incomplete courses
const progressInfo = useMemo(() => {
if (!trailData || !course || isCourseCompleted) return null;
const allActivities = course.chapters.flatMap((chapter: any) =>
chapter.activities.map((activity: any) => ({
...activity,
chapterId: chapter.id
}))
);
const isActivityDone = (activity: any) => {
const cleanCourseUuid = course.course_uuid?.replace('course_', '');
const run = trailData?.runs?.find(
(run: any) => {
const cleanRunCourseUuid = run.course?.course_uuid?.replace('course_', '');
return cleanRunCourseUuid === cleanCourseUuid;
}
);
if (run) {
return run.steps.find(
(step: any) => step.activity_id === activity.id && step.complete === true
);
}
return false;
};
const totalActivities = allActivities.length;
const completedActivities = allActivities.filter((activity: any) => isActivityDone(activity)).length;
const progressPercentage = Math.round((completedActivities / totalActivities) * 100);
return {
completed: completedActivities,
total: totalActivities,
percentage: progressPercentage
};
}, [trailData, course, isCourseCompleted]);
if (isCourseCompleted) {
// Show congratulations for completed course
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4 relative overflow-hidden">
<div className="fixed inset-0 pointer-events-none">
<ReactConfetti
width={width}
height={height}
numberOfPieces={200}
recycle={false}
colors={['#6366f1', '#10b981', '#3b82f6']}
/>
</div>
<div className="bg-white rounded-2xl p-8 nice-shadow max-w-4xl w-full space-y-6 relative z-10">
<div className="flex flex-col items-center space-y-6">
{thumbnailImage && (
<img
className="w-[200px] h-[114px] rounded-lg shadow-md object-cover"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
courseUuid,
thumbnailImage
)}`}
alt={courseName}
/>
)}
<div className="bg-emerald-100 p-4 rounded-full">
<Trophy className="w-16 h-16 text-emerald-600" />
</div>
</div>
<div className="bg-emerald-100 p-4 rounded-full"> <h1 className="text-4xl font-bold text-gray-900">
<Trophy className="w-16 h-16 text-emerald-600" /> Congratulations! 🎉
</h1>
<p className="text-xl text-gray-600">
You've successfully completed
<span className="font-semibold text-gray-900"> {courseName}</span>
</p>
<p className="text-gray-500">
Your dedication and hard work have paid off. You've mastered all the content in this course.
</p>
{/* Certificate Display */}
{isLoadingCertificate ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Loading your certificate...</span>
</div>
) : certificateError ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<p className="text-yellow-800">
{certificateError}
</p>
</div>
) : userCertificate ? (
<div className="space-y-4">
<h2 className="text-2xl font-semibold text-gray-900">Your Certificate</h2>
<div className="max-w-2xl mx-auto" id="certificate-preview">
<div id="certificate-content">
<CertificatePreview
certificationName={userCertificate.certification.config.certification_name}
certificationDescription={userCertificate.certification.config.certification_description}
certificationType={userCertificate.certification.config.certification_type}
certificatePattern={userCertificate.certification.config.certificate_pattern}
certificateInstructor={userCertificate.certification.config.certificate_instructor}
certificateId={userCertificate.certificate_user.user_certification_uuid}
awardedDate={new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
qrCodeLink={qrCodeLink}
/>
</div>
</div>
<div className="flex justify-center space-x-4">
<button
onClick={downloadCertificate}
className="inline-flex items-center space-x-2 bg-green-600 text-white px-6 py-3 rounded-full hover:bg-green-700 transition duration-200"
>
<Download className="w-5 h-5" />
<span>Download Certificate PDF</span>
</button>
<Link
href={getUriWithOrg(orgslug, `/certificates/${userCertificate.certificate_user.user_certification_uuid}/verify`)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200"
>
<Shield className="w-5 h-5" />
<span>Verify Certificate</span>
</Link>
</div>
</div>
) : (
<div className="bg-gray-50 rounded-lg p-6">
<p className="text-gray-600">
No certificate is available for this course. Contact your instructor for more information.
</p>
</div>
)}
<div className="pt-6">
<Link
href={getUriWithOrg(orgslug, `/course/${courseUuid.replace('course_', '')}`)}
className="inline-flex items-center space-x-2 bg-gray-800 text-white px-6 py-3 rounded-full hover:bg-gray-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
</div> </div>
</div> </div>
</div>
<h1 className="text-4xl font-bold text-gray-900"> );
Congratulations! 🎉 } else {
</h1> // Show progress and encouragement for incomplete course
return (
<p className="text-xl text-gray-600"> <div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
You've successfully completed <div className="bg-white rounded-2xl p-8 nice-shadow max-w-2xl w-full space-y-6">
<span className="font-semibold text-gray-900"> {courseName}</span> <div className="flex flex-col items-center space-y-6">
</p> {thumbnailImage && (
<img
<p className="text-gray-500"> className="w-[200px] h-[114px] rounded-lg shadow-md object-cover"
Your dedication and hard work have paid off. You've mastered all the content in this course. src={`${getCourseThumbnailMediaDirectory(
</p> org?.org_uuid,
courseUuid,
thumbnailImage
)}`}
alt={courseName}
/>
)}
<div className="bg-blue-100 p-4 rounded-full">
<Target className="w-16 h-16 text-blue-600" />
</div>
</div>
<h1 className="text-4xl font-bold text-gray-900">
Keep Going! 💪
</h1>
<p className="text-xl text-gray-600">
You're making great progress in
<span className="font-semibold text-gray-900"> {courseName}</span>
</p>
{progressInfo && (
<div className="bg-gray-50 rounded-lg p-6 space-y-4">
<div className="flex items-center justify-center space-x-2">
<BookOpen className="w-5 h-5 text-gray-600" />
<span className="text-lg font-semibold text-gray-700">Course Progress</span>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-gray-600">Progress</span>
<span className="font-semibold text-gray-900">{progressInfo.percentage}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-500"
style={{ width: `${progressInfo.percentage}%` }}
></div>
</div>
<div className="text-sm text-gray-500">
{progressInfo.completed} of {progressInfo.total} activities completed
</div>
</div>
</div>
)}
<p className="text-gray-500">
You're doing great! Complete the remaining activities to unlock your course completion certificate.
</p>
<div className="pt-6"> <div className="pt-6">
<Link <Link
href={getUriWithOrg(orgslug, '') + `/course/${courseUuid.replace('course_', '')}`} href={getUriWithOrg(orgslug, `/course/${courseUuid.replace('course_', '')}`)}
className="inline-flex items-center space-x-2 bg-gray-800 text-white px-6 py-3 rounded-full hover:bg-gray-700 transition duration-200" className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
<span>Back to Course</span> <span>Continue Learning</span>
</Link> </Link>
</div>
</div> </div>
</div> </div>
</div> );
); }
}; };
export default CourseEndView; export default CourseEndView;

View file

@ -0,0 +1,426 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { getUserCertificates } from '@services/courses/certifications';
import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview';
import { ArrowLeft, Download } from 'lucide-react';
import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import QRCode from 'qrcode';
interface CertificatePageProps {
orgslug: string;
courseid: string;
qrCodeLink: string;
}
const CertificatePage: React.FC<CertificatePageProps> = ({ orgslug, courseid, qrCodeLink }) => {
const session = useLHSession() as any;
const [userCertificate, setUserCertificate] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch user certificate
useEffect(() => {
const fetchCertificate = async () => {
if (!session?.data?.tokens?.access_token) {
setError('Authentication required to view certificate');
setIsLoading(false);
return;
}
try {
const cleanCourseId = courseid.replace('course_', '');
const result = await getUserCertificates(
`course_${cleanCourseId}`,
session.data.tokens.access_token
);
if (result.success && result.data && result.data.length > 0) {
setUserCertificate(result.data[0]);
} else {
setError('No certificate found for this course');
}
} catch (error) {
console.error('Error fetching certificate:', error);
setError('Failed to load certificate. Please try again later.');
} finally {
setIsLoading(false);
}
};
fetchCertificate();
}, [courseid, session?.data?.tokens?.access_token]);
// Generate PDF using canvas
const downloadCertificate = async () => {
if (!userCertificate) return;
try {
// Create a temporary div for the certificate
const certificateDiv = document.createElement('div');
certificateDiv.style.position = 'absolute';
certificateDiv.style.left = '-9999px';
certificateDiv.style.top = '0';
certificateDiv.style.width = '800px';
certificateDiv.style.height = '600px';
certificateDiv.style.background = 'white';
certificateDiv.style.padding = '40px';
certificateDiv.style.fontFamily = 'Arial, sans-serif';
certificateDiv.style.textAlign = 'center';
certificateDiv.style.display = 'flex';
certificateDiv.style.flexDirection = 'column';
certificateDiv.style.justifyContent = 'center';
certificateDiv.style.alignItems = 'center';
certificateDiv.style.position = 'relative';
certificateDiv.style.overflow = 'hidden';
// Get theme colors based on pattern
const getPatternTheme = (pattern: string) => {
switch (pattern) {
case 'royal':
return { primary: '#b45309', secondary: '#d97706', icon: '#d97706' };
case 'tech':
return { primary: '#0e7490', secondary: '#0891b2', icon: '#0891b2' };
case 'nature':
return { primary: '#15803d', secondary: '#16a34a', icon: '#16a34a' };
case 'geometric':
return { primary: '#7c3aed', secondary: '#9333ea', icon: '#9333ea' };
case 'vintage':
return { primary: '#c2410c', secondary: '#ea580c', icon: '#ea580c' };
case 'waves':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
case 'minimal':
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
case 'professional':
return { primary: '#334155', secondary: '#475569', icon: '#475569' };
case 'academic':
return { primary: '#3730a3', secondary: '#4338ca', icon: '#4338ca' };
case 'modern':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
default:
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
}
};
const theme = getPatternTheme(userCertificate.certification.config.certificate_pattern);
const certificateId = userCertificate.certificate_user.user_certification_uuid;
const qrCodeData = qrCodeLink ;
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(qrCodeData, {
width: 120,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M',
type: 'image/png'
});
// Create certificate content
certificateDiv.innerHTML = `
<div style="
position: absolute;
top: 20px;
left: 20px;
font-size: 12px;
color: ${theme.secondary};
font-weight: 500;
">ID: ${certificateId}</div>
<div style="
position: absolute;
top: 20px;
right: 20px;
width: 80px;
height: 80px;
border: 2px solid ${theme.secondary};
border-radius: 8px;
background: white;
display: flex;
align-items: center;
justify-content: center;
">
<img src="${qrCodeDataUrl}" alt="QR Code" style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 30px;
font-size: 14px;
color: ${theme.secondary};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
">
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
Certificate
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
</div>
<div style="
width: 80px;
height: 80px;
background: linear-gradient(135deg, ${theme.icon}20 0%, ${theme.icon}40 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 30px;
font-size: 40px;
line-height: 1;
">🏆</div>
<div style="
font-size: 32px;
font-weight: bold;
color: ${theme.primary};
margin-bottom: 20px;
line-height: 1.2;
max-width: 600px;
">${userCertificate.certification.config.certification_name}</div>
<div style="
font-size: 18px;
color: #6b7280;
margin-bottom: 30px;
line-height: 1.5;
max-width: 500px;
">${userCertificate.certification.config.certification_description || 'This is to certify that the course has been successfully completed.'}</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin: 20px 0;
">
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
<div style="width: 4px; height: 4px; background: ${theme.primary}; border-radius: 50%; opacity: 0.6;"></div>
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
</div>
<div style="
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
color: ${theme.primary};
background: ${theme.icon}10;
padding: 12px 24px;
border-radius: 20px;
border: 1px solid ${theme.icon}20;
font-weight: 500;
margin-bottom: 30px;
white-space: nowrap;
">
<span style="font-weight: bold; font-size: 18px;"></span>
<span>${userCertificate.certification.config.certification_type === 'completion' ? 'Course Completion' :
userCertificate.certification.config.certification_type === 'achievement' ? 'Achievement Based' :
userCertificate.certification.config.certification_type === 'assessment' ? 'Assessment Based' :
userCertificate.certification.config.certification_type === 'participation' ? 'Participation' :
userCertificate.certification.config.certification_type === 'mastery' ? 'Skill Mastery' :
userCertificate.certification.config.certification_type === 'professional' ? 'Professional Development' :
userCertificate.certification.config.certification_type === 'continuing' ? 'Continuing Education' :
userCertificate.certification.config.certification_type === 'workshop' ? 'Workshop Attendance' :
userCertificate.certification.config.certification_type === 'specialization' ? 'Specialization' : 'Course Completion'}</span>
</div>
<div style="
margin-top: 30px;
padding: 24px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
max-width: 400px;
">
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Certificate ID:</strong> ${certificateId}
</div>
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Awarded:</strong> ${new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
${userCertificate.certification.config.certificate_instructor ?
`<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Instructor:</strong> ${userCertificate.certification.config.certificate_instructor}
</div>` : ''
}
</div>
<div style="
margin-top: 20px;
font-size: 12px;
color: #6b7280;
">
This certificate can be verified at ${qrCodeData.replace('https://', '').replace('http://', '')}
</div>
`;
// Add to document temporarily
document.body.appendChild(certificateDiv);
// Convert to canvas
const canvas = await html2canvas(certificateDiv, {
width: 800,
height: 600,
scale: 2, // Higher resolution
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
// Remove temporary div
document.body.removeChild(certificateDiv);
// Create PDF
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('landscape', 'mm', 'a4');
// Calculate dimensions to center the certificate
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgWidth = 280; // mm
const imgHeight = 210; // mm
// Center the image
const x = (pdfWidth - imgWidth) / 2;
const y = (pdfHeight - imgHeight) / 2;
pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight);
// Save the PDF
const fileName = `${userCertificate.certification.config.certification_name.replace(/[^a-zA-Z0-9]/g, '_')}_Certificate.pdf`;
pdf.save(fileName);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading certificate...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-red-800 mb-2">Certificate Not Available</h2>
<p className="text-red-600 mb-4">{error}</p>
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseid}`}
className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
</div>
</div>
</div>
);
}
if (!userCertificate) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-yellow-800 mb-2">No Certificate Found</h2>
<p className="text-yellow-600 mb-4">
No certificate is available for this course. Please contact your instructor for more information.
</p>
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseid}`}
className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseid}`}
className="inline-flex items-center space-x-2 text-gray-600 hover:text-gray-900 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
<div className="flex items-center space-x-4">
<button
onClick={downloadCertificate}
className="inline-flex items-center space-x-2 bg-green-600 text-white px-6 py-3 rounded-full hover:bg-green-700 transition duration-200"
>
<Download className="w-5 h-5" />
<span>Download PDF</span>
</button>
</div>
</div>
{/* Certificate Display */}
<div className="bg-white rounded-2xl shadow-lg p-8">
<div className="max-w-2xl mx-auto">
<CertificatePreview
certificationName={userCertificate.certification.config.certification_name}
certificationDescription={userCertificate.certification.config.certification_description}
certificationType={userCertificate.certification.config.certification_type}
certificatePattern={userCertificate.certification.config.certificate_pattern}
certificateInstructor={userCertificate.certification.config.certificate_instructor}
certificateId={userCertificate.certificate_user.user_certification_uuid}
awardedDate={new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
qrCodeLink={qrCodeLink}
/>
</div>
</div>
{/* Instructions */}
<div className="mt-8 text-center text-gray-600">
<p className="mb-2">
Click "Download PDF" to generate and download a high-quality certificate PDF.
</p>
<p className="text-sm">
The PDF includes a scannable QR code for certificate verification.
</p>
</div>
</div>
</div>
);
};
export default CertificatePage;

View file

@ -0,0 +1,364 @@
'use client';
import React, { useEffect, useState } from 'react';
import { getCertificateByUuid } from '@services/courses/certifications';
import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview';
import { Shield, CheckCircle, XCircle, AlertTriangle, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config';
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
import { useOrg } from '@components/Contexts/OrgContext';
interface CertificateVerificationPageProps {
certificateUuid: string;
}
const CertificateVerificationPage: React.FC<CertificateVerificationPageProps> = ({ certificateUuid }) => {
const [certificateData, setCertificateData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [verificationStatus, setVerificationStatus] = useState<'valid' | 'invalid' | 'loading'>('loading');
const org = useOrg() as any;
// Fetch certificate data
useEffect(() => {
const fetchCertificate = async () => {
try {
const result = await getCertificateByUuid(certificateUuid);
if (result.success && result.data) {
setCertificateData(result.data);
setVerificationStatus('valid');
} else {
setError('Certificate not found');
setVerificationStatus('invalid');
}
} catch (error) {
console.error('Error fetching certificate:', error);
setError('Failed to verify certificate. Please try again later.');
setVerificationStatus('invalid');
} finally {
setIsLoading(false);
}
};
fetchCertificate();
}, [certificateUuid]);
const getVerificationStatusIcon = () => {
switch (verificationStatus) {
case 'valid':
return <CheckCircle className="w-8 h-8 text-green-600" />;
case 'invalid':
return <XCircle className="w-8 h-8 text-red-600" />;
case 'loading':
return <AlertTriangle className="w-8 h-8 text-yellow-600" />;
default:
return <AlertTriangle className="w-8 h-8 text-yellow-600" />;
}
};
const getVerificationStatusText = () => {
switch (verificationStatus) {
case 'valid':
return 'Certificate Verified';
case 'invalid':
return 'Certificate Not Found';
case 'loading':
return 'Verifying Certificate...';
default:
return 'Verification Status Unknown';
}
};
const getVerificationStatusColor = () => {
switch (verificationStatus) {
case 'valid':
return 'text-green-600 bg-green-50 border-green-200';
case 'invalid':
return 'text-red-600 bg-red-50 border-red-200';
case 'loading':
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
default:
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
}
};
if (isLoading) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50">
<div className="bg-white rounded-2xl p-8 nice-shadow max-w-4xl w-full space-y-6">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Verifying Certificate</h1>
<p className="text-gray-600">Please wait while we verify the certificate...</p>
</div>
</div>
</div>
);
}
if (error || verificationStatus === 'invalid') {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50">
<div className="bg-white rounded-2xl p-8 nice-shadow max-w-2xl w-full space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="bg-red-100 p-4 rounded-full">
<XCircle className="w-16 h-16 text-red-600" />
</div>
<h1 className="text-3xl font-bold text-gray-900 text-center">
Certificate Not Found
</h1>
<p className="text-gray-600 text-center">
The certificate with ID <span className="font-mono bg-gray-100 px-2 py-1 rounded">{certificateUuid}</span> could not be found in our system.
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 w-full">
<p className="text-red-800 text-sm">
This could mean:
</p>
<ul className="text-red-700 text-sm mt-2 list-disc list-inside space-y-1">
<li>The certificate ID is incorrect</li>
<li>The certificate has been revoked</li>
<li>The certificate has expired</li>
<li>The certificate was issued by a different organization</li>
</ul>
</div>
<div className="pt-4">
<Link
href="/"
className="inline-flex items-center space-x-2 bg-gray-800 text-white px-6 py-3 rounded-full hover:bg-gray-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Go Home</span>
</Link>
</div>
</div>
</div>
</div>
);
}
if (!certificateData) {
return null;
}
const qrCodeLink = getUriWithOrg(org?.org_slug || '', `/certificates/${certificateData.certificate_user.user_certification_uuid}/verify`);
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
{/* Header */}
<div className="bg-white rounded-2xl p-6 mb-8 nice-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="bg-green-100 p-3 rounded-full">
<Shield className="w-8 h-8 text-green-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Certificate Verification</h1>
<p className="text-gray-600">Verify the authenticity of this certificate</p>
</div>
</div>
<div className={`flex items-center space-x-3 px-4 py-2 rounded-full border ${getVerificationStatusColor()}`}>
{getVerificationStatusIcon()}
<span className="font-semibold">{getVerificationStatusText()}</span>
</div>
</div>
</div>
{/* Certificate Details */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Certificate Preview and Course Info */}
<div className="lg:col-span-2 space-y-6">
{/* Certificate Preview */}
<div className="bg-white rounded-2xl p-6 nice-shadow">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Certificate Preview</h2>
<div className="max-w-2xl mx-auto" id="certificate-preview">
<CertificatePreview
certificationName={certificateData.certification.config.certification_name}
certificationDescription={certificateData.certification.config.certification_description}
certificationType={certificateData.certification.config.certification_type}
certificatePattern={certificateData.certification.config.certificate_pattern}
certificateInstructor={certificateData.certification.config.certificate_instructor}
certificateId={certificateData.certificate_user.user_certification_uuid}
awardedDate={new Date(certificateData.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
qrCodeLink={qrCodeLink}
/>
</div>
</div>
{/* Course Information */}
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden p-4">
<div className="flex items-start space-x-4">
{/* Course Thumbnail */}
<div className="flex-shrink-0">
<div className="w-20 h-12 bg-gray-100 rounded-lg overflow-hidden ring-1 ring-inset ring-black/10">
{certificateData.course.thumbnail_image ? (
<img
src={getCourseThumbnailMediaDirectory(
org?.org_uuid,
certificateData.course.course_uuid,
certificateData.course.thumbnail_image
)}
alt={`${certificateData.course.name} thumbnail`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
)}
</div>
</div>
{/* Course Details */}
<div className="flex-1 min-w-0">
<div className="space-y-1">
<div>
<h4 className="font-semibold text-gray-900 text-base leading-tight">{certificateData.course.name}</h4>
{certificateData.course.description && (
<p className="text-sm text-gray-600 line-clamp-2 mt-1">{certificateData.course.description}</p>
)}
</div>
{certificateData.course.authors && certificateData.course.authors.length > 0 && (
<div className="flex items-center space-x-1 text-sm text-neutral-400 font-normal">
<span>By:</span>
<div className="flex items-center space-x-1">
{certificateData.course.authors
.filter((author: any) => author.authorship_status === 'ACTIVE')
.slice(0, 2)
.map((author: any, index: number) => (
<span key={author.user.user_uuid} className="text-neutral-600">
{author.user.first_name} {author.user.last_name}
{index < Math.min(2, certificateData.course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length - 1) && ', '}
</span>
))}
{certificateData.course.authors.filter((author: any) => author.authorship_status === 'ACTIVE').length > 2 && (
<span className="text-neutral-400">
+{certificateData.course.authors.filter((author: any) => author.authorship_status === 'ACTIVE').length - 2} more
</span>
)}
</div>
</div>
)}
</div>
</div>
{/* View Course Link */}
<div className="flex-shrink-0">
<Link
href={getUriWithOrg(org?.org_slug || '', `/course/${certificateData.course.course_uuid.replace('course_', '')}`)}
className="inline-flex items-center space-x-1 text-neutral-400 hover:text-neutral-600 transition-colors text-sm"
>
<span>View Course</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
</div>
</div>
{/* Certificate Details */}
<div className="space-y-6">
<div className="bg-white rounded-2xl p-6 nice-shadow">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Certificate Information</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Certificate ID</label>
<div className="bg-gray-50 p-3 rounded-lg">
<code className="text-sm text-gray-900 break-all">
{certificateData.certificate_user.user_certification_uuid}
</code>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Course Name</label>
<div className="bg-gray-50 p-3 rounded-lg">
<span className="text-gray-900">{certificateData.course.name}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Certification Type</label>
<div className="bg-gray-50 p-3 rounded-lg">
<span className="text-gray-900 capitalize">
{certificateData.certification.config.certification_type.replace('_', ' ')}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Awarded Date</label>
<div className="bg-gray-50 p-3 rounded-lg">
<span className="text-gray-900">
{new Date(certificateData.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
</div>
{certificateData.certification.config.certificate_instructor && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Instructor</label>
<div className="bg-gray-50 p-3 rounded-lg">
<span className="text-gray-900">{certificateData.certification.config.certificate_instructor}</span>
</div>
</div>
)}
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-6">
<div className="flex items-center space-x-3 mb-3">
<Shield className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold text-blue-800">Security Information</h3>
</div>
<ul className="text-blue-700 text-sm space-y-2">
<li> Certificate verified against our secure database</li>
<li> QR code contains verification link</li>
<li> Certificate ID is cryptographically secure</li>
<li> Timestamp verified and authenticated</li>
</ul>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-8 text-center">
<Link
href="/"
className="inline-flex items-center space-x-2 bg-gray-800 text-white px-6 py-3 rounded-full hover:bg-gray-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Go Home</span>
</Link>
</div>
</div>
</div>
);
};
export default CertificateVerificationPage;

View file

@ -1,5 +1,5 @@
'use client' 'use client'
import { BookOpenCheck, Check, FileText, Layers, Video, ChevronLeft, ChevronRight } from 'lucide-react' import { BookOpenCheck, Check, FileText, Layers, Video, ChevronLeft, ChevronRight, Trophy } from 'lucide-react'
import React, { useMemo, memo, useState } from 'react' import React, { useMemo, memo, useState } from 'react'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
@ -124,6 +124,58 @@ const ChapterTooltipContent = memo(({
ChapterTooltipContent.displayName = 'ChapterTooltipContent'; ChapterTooltipContent.displayName = 'ChapterTooltipContent';
// Add certification badge component
const CertificationBadge = memo(({
courseid,
orgslug,
isCompleted
}: {
courseid: string,
orgslug: string,
isCompleted: boolean
}) => (
<ToolTip
sideOffset={8}
unstyled
content={
<div className="bg-white rounded-lg nice-shadow py-3 px-4 min-w-[200px] animate-in fade-in duration-200">
<div className="flex items-center gap-2">
<Trophy size={16} className="text-yellow-500" />
<span className="text-sm font-medium text-gray-900">
{isCompleted ? 'Course Completed!' : 'Course Completion'}
</span>
</div>
<div className="mt-1">
<span className="text-sm text-gray-700">
{isCompleted
? 'View your completion certificate'
: 'Complete all activities to unlock your certificate'
}
</span>
</div>
</div>
}
>
<Link
href={`${getUriWithOrg(orgslug, '')}/course/${courseid}/activity/end`}
prefetch={false}
className={`mx-2 h-[20px] flex items-center cursor-pointer focus:outline-none transition-all ${
isCompleted ? 'opacity-100' : 'opacity-50 cursor-not-allowed'
}`}
>
<div className={`w-[20px] h-[20px] rounded-full flex items-center justify-center text-xs font-medium transition-colors ${
isCompleted
? 'bg-yellow-500 text-white hover:bg-yellow-600'
: 'bg-gray-100 text-gray-400'
}`}>
<Trophy size={12} />
</div>
</Link>
</ToolTip>
));
CertificationBadge.displayName = 'CertificationBadge';
function ActivityIndicators(props: Props) { function ActivityIndicators(props: Props) {
const course = props.course const course = props.course
const orgslug = props.orgslug const orgslug = props.orgslug
@ -218,6 +270,13 @@ function ActivityIndicators(props: Props) {
}, 0) }, 0)
}, [isActivityDone]); }, [isActivityDone]);
// Check if all activities are completed
const isCourseCompleted = useMemo(() => {
const totalActivities = allActivities.length;
const completedActivities = allActivities.filter((activity: any) => isActivityDone(activity)).length;
return totalActivities > 0 && completedActivities === totalActivities;
}, [allActivities, isActivityDone]);
return ( return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{enableNavigation && ( {enableNavigation && (
@ -317,6 +376,13 @@ function ActivityIndicators(props: Props) {
</React.Fragment> </React.Fragment>
) )
})} })}
{/* Certification Badge */}
<CertificationBadge
courseid={courseid}
orgslug={orgslug}
isCompleted={isCourseCompleted}
/>
</div> </div>
{enableNavigation && ( {enableNavigation && (

View file

@ -5,10 +5,12 @@ import { removeCourse } from '@services/courses/activity'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { revalidateTags } from '@services/utils/ts/requests' import { revalidateTags } from '@services/utils/ts/requests'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getUserCertificates } from '@services/courses/certifications'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { mutate } from 'swr' import { mutate } from 'swr'
import { Award, ExternalLink } from 'lucide-react'
interface TrailCourseElementProps { interface TrailCourseElementProps {
course: any course: any
@ -29,6 +31,9 @@ function TrailCourseElement(props: TrailCourseElementProps) {
const course_progress = Math.round( const course_progress = Math.round(
(course_completed_steps / course_total_steps) * 100 (course_completed_steps / course_total_steps) * 100
) )
const [courseCertificate, setCourseCertificate] = useState<any>(null)
const [isLoadingCertificate, setIsLoadingCertificate] = useState(false)
async function quitCourse(course_uuid: string) { async function quitCourse(course_uuid: string) {
// Close activity // Close activity
@ -41,6 +46,31 @@ function TrailCourseElement(props: TrailCourseElementProps) {
mutate(`${getAPIUrl()}trail/org/${orgID}/trail`) mutate(`${getAPIUrl()}trail/org/${orgID}/trail`)
} }
// Fetch certificate for this course
useEffect(() => {
const fetchCourseCertificate = async () => {
if (!access_token || course_progress < 100) return;
setIsLoadingCertificate(true);
try {
const result = await getUserCertificates(
props.course.course_uuid,
access_token
);
if (result.success && result.data && result.data.length > 0) {
setCourseCertificate(result.data[0]);
}
} catch (error) {
console.error('Error fetching course certificate:', error);
} finally {
setIsLoadingCertificate(false);
}
};
fetchCourseCertificate();
}, [access_token, course_progress, props.course.course_uuid]);
useEffect(() => {}, [props.course, org]) useEffect(() => {}, [props.course, org])
return ( return (
@ -90,6 +120,41 @@ function TrailCourseElement(props: TrailCourseElementProps) {
></div> ></div>
</div> </div>
</div> </div>
{/* Certificate Section */}
{course_progress === 100 && (
<div className="mt-2 pt-2 border-t border-gray-100">
{isLoadingCertificate ? (
<div className="flex items-center space-x-1 text-xs text-gray-500">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-yellow-500"></div>
<span>Loading...</span>
</div>
) : courseCertificate ? (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<Award className="w-3 h-3 text-yellow-500" />
<span className="text-xs font-medium text-gray-700">
Certificate
</span>
</div>
<Link
href={getUriWithOrg(props.orgslug, `/certificates/${courseCertificate.certificate_user.user_certification_uuid}/verify`)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-xs font-medium"
>
<span>Verify</span>
<ExternalLink className="w-3 h-3" />
</Link>
</div>
) : (
<div className="flex items-center space-x-1 text-xs text-gray-500">
<Award className="w-3 h-3 text-gray-300" />
<span>No certificate</span>
</div>
)}
</div>
)}
</div> </div>
</div> </div>
) )

View file

@ -0,0 +1,148 @@
'use client'
import React from 'react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import { getAllUserCertificates } from '@services/courses/certifications'
import { getUriWithOrg } from '@services/config/config'
import { Award, ExternalLink, Calendar, Hash, Building } from 'lucide-react'
import Link from 'next/link'
import useSWR from 'swr'
import { swrFetcher } from '@services/utils/ts/requests'
import { getAPIUrl } from '@services/config/config'
interface UserCertificatesProps {
orgslug: string
}
const UserCertificates: React.FC<UserCertificatesProps> = ({ orgslug }) => {
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any
const { data: certificates, error, isLoading } = useSWR(
access_token ? `${getAPIUrl()}certifications/user/all` : null,
(url) => swrFetcher(url, access_token)
)
if (isLoading) {
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-4">
<Award className="w-6 h-6 text-yellow-500" />
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
</div>
<div className="animate-pulse space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-gray-100 h-20 rounded-lg"></div>
))}
</div>
</div>
)
}
if (error) {
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-4">
<Award className="w-6 h-6 text-yellow-500" />
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
</div>
<div className="text-center py-8">
<p className="text-gray-500">Failed to load certificates</p>
</div>
</div>
)
}
// Handle the actual API response structure - certificates are returned as an array directly
const certificatesData = Array.isArray(certificates) ? certificates : certificates?.data || []
if (!certificatesData || certificatesData.length === 0) {
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-4">
<Award className="w-6 h-6 text-yellow-500" />
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
</div>
<div className="text-center py-8">
<Award className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">No certificates earned yet</p>
<p className="text-sm text-gray-400 mt-1">Complete courses to earn certificates</p>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-6">
<Award className="w-6 h-6 text-yellow-500" />
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
<span className="bg-yellow-100 text-yellow-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{certificatesData.length}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{certificatesData.map((certificate: any) => {
const verificationLink = getUriWithOrg(orgslug, `/certificates/${certificate.certificate_user.user_certification_uuid}/verify`)
const awardedDate = new Date(certificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
return (
<div key={certificate.certificate_user.user_certification_uuid} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Award className="w-4 h-4 text-yellow-500" />
<h3 className="font-semibold text-gray-900 text-sm truncate">
{certificate.certification.config.certification_name}
</h3>
</div>
<div className="space-y-2 text-xs text-gray-600">
<div className="flex items-center space-x-2">
<Building className="w-3 h-3" />
<span className="truncate">{certificate.course.name}</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="w-3 h-3" />
<span>Awarded {awardedDate}</span>
</div>
<div className="flex items-center space-x-2">
<Hash className="w-3 h-3" />
<span className="font-mono text-xs bg-gray-100 px-2 py-1 rounded truncate">
{certificate.certificate_user.user_certification_uuid}
</span>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="text-xs text-gray-500 capitalize">
{certificate.certification.config.certification_type.replace('_', ' ')}
</div>
<Link
href={verificationLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-xs font-medium"
>
<span>Verify</span>
<ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
</div>
)
})}
</div>
</div>
)
}
export default UserCertificates

View file

@ -50,6 +50,7 @@
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.7", "@tiptap/starter-kit": "^2.11.7",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/html2canvas": "^1.0.0",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -62,14 +63,18 @@
"framer-motion": "^12.6.3", "framer-motion": "^12.6.3",
"get-youtube-id": "^1.0.1", "get-youtube-id": "^1.0.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.1",
"jspdf-html2canvas": "^1.5.2",
"katex": "^0.16.21", "katex": "^0.16.21",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"next": "15.3.3", "next": "15.3.5",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"nextjs-toploader": "^1.6.12", "nextjs-toploader": "^1.6.12",
"plyr": "^3.7.8", "plyr": "^3.7.8",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"qrcode": "^1.5.4",
"randomcolor": "^0.6.2", "randomcolor": "^0.6.2",
"re-resizable": "^6.11.2", "re-resizable": "^6.11.2",
"react": "19.0.0", "react": "19.0.0",
@ -94,6 +99,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.3", "@tailwindcss/postcss": "^4.1.3",
"@types/node": "20.12.2", "@types/node": "20.12.2",
"@types/qrcode": "^1.5.5",
"@types/react": "19.0.10", "@types/react": "19.0.10",
"@types/react-dom": "19.0.4", "@types/react-dom": "19.0.4",
"@types/react-katex": "^3.0.4", "@types/react-katex": "^3.0.4",

483
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
import { getAPIUrl } from '@services/config/config'
import {
RequestBodyWithAuthHeader,
errorHandling,
getResponseMetadata,
} from '@services/utils/ts/requests'
/*
This file includes certification-related API calls
GET requests are called from the frontend using SWR (https://swr.vercel.app/)
*/
export async function getCourseCertifications(
course_uuid: string,
next: any,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/course/${course_uuid}`,
RequestBodyWithAuthHeader('GET', null, next, access_token)
)
const res = await getResponseMetadata(result)
return res
}
export async function createCertification(
course_id: number,
config: any,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/`,
RequestBodyWithAuthHeader('POST', { course_id, config }, null, access_token)
)
const res = await errorHandling(result)
return res
}
export async function updateCertification(
certification_uuid: string,
config: any,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/${certification_uuid}`,
RequestBodyWithAuthHeader('PUT', { config }, null, access_token)
)
const res = await errorHandling(result)
return res
}
export async function deleteCertification(
certification_uuid: string,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/${certification_uuid}`,
RequestBodyWithAuthHeader('DELETE', null, null, access_token)
)
const res = await errorHandling(result)
return res
}
export async function getUserCertificates(
course_uuid: string,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/user/course/${course_uuid}`,
RequestBodyWithAuthHeader('GET', null, null, access_token)
)
const res = await getResponseMetadata(result)
return res
}
export async function getCertificateByUuid(
user_certification_uuid: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/certificate/${user_certification_uuid}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
)
const res = await getResponseMetadata(result)
return res
}
export async function getAllUserCertificates(
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/user/all`,
RequestBodyWithAuthHeader('GET', null, null, access_token)
)
const res = await getResponseMetadata(result)
return res
}