mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #494 from learnhouse/feat/course-certificates
Course certificates
This commit is contained in:
commit
5bbf15ee41
30 changed files with 4342 additions and 147 deletions
63
apps/api/src/db/courses/certifications.py
Normal file
63
apps/api/src/db/courses/certifications.py
Normal 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
|
||||||
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
143
apps/api/src/routers/courses/certifications.py
Normal file
143
apps/api/src/routers/courses/certifications.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
||||||
|
|
|
||||||
541
apps/api/src/services/courses/certifications.py
Normal file
541
apps/api/src/services/courses/certifications.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
1
apps/web/.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
426
apps/web/components/Pages/Certificate/CertificatePage.tsx
Normal file
426
apps/web/components/Pages/Certificate/CertificatePage.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
148
apps/web/components/Pages/Trail/UserCertificates.tsx
Normal file
148
apps/web/components/Pages/Trail/UserCertificates.tsx
Normal 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
|
||||||
|
|
@ -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
483
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
101
apps/web/services/courses/certifications.ts
Normal file
101
apps/web/services/courses/certifications.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue