diff --git a/apps/api/src/db/courses/certifications.py b/apps/api/src/db/courses/certifications.py new file mode 100644 index 00000000..a2f7f055 --- /dev/null +++ b/apps/api/src/db/courses/certifications.py @@ -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 + diff --git a/apps/api/src/router.py b/apps/api/src/router.py index 72e03402..228df0e3 100644 --- a/apps/api/src/router.py +++ b/apps/api/src/router.py @@ -4,7 +4,7 @@ from src.routers import health from src.routers import usergroups from src.routers import dev, trail, users, auth, orgs, roles, search 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.ee import cloud_internal, payments from src.routers.install import install @@ -33,6 +33,9 @@ v1_router.include_router(activities.router, prefix="/activities", tags=["activit v1_router.include_router( 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(ai.router, prefix="/ai", tags=["ai"]) v1_router.include_router(payments.router, prefix="/payments", tags=["payments"]) diff --git a/apps/api/src/routers/courses/certifications.py b/apps/api/src/routers/courses/certifications.py new file mode 100644 index 00000000..e003cbe7 --- /dev/null +++ b/apps/api/src/routers/courses/certifications.py @@ -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 + ) \ No newline at end of file diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index fb526dcd..6e1938fb 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -13,7 +13,6 @@ from src.db.courses.courses import ( CourseRead, CourseUpdate, FullCourseRead, - FullCourseReadWithTrail, ThumbnailType, ) from src.security.auth import get_current_user diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py index 21c0619a..e936c800 100644 --- a/apps/api/src/services/courses/activities/assignments.py +++ b/apps/api/src/services/courses/activities/assignments.py @@ -44,6 +44,7 @@ from src.services.courses.activities.uploads.tasks_ref_files import ( upload_reference_file, ) from src.services.trail.trail import check_trail_presence +from src.services.courses.certifications import check_course_completion_and_create_certificate ## > Assignments CRUD @@ -1237,6 +1238,12 @@ async def create_assignment_submission( db_session.commit() 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 AssignmentUserSubmissionRead.model_validate(assignment_user_submission) @@ -1658,6 +1665,12 @@ async def mark_activity_as_done_for_user( db_session.commit() 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 {"message": "Activity marked as done for user"} diff --git a/apps/api/src/services/courses/certifications.py b/apps/api/src/services/courses/certifications.py new file mode 100644 index 00000000..af5a2b54 --- /dev/null +++ b/apps/api/src/services/courses/certifications.py @@ -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, + ) \ No newline at end of file diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 61cad71d..98030597 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -9,7 +9,6 @@ from src.security.features_utils.usage import ( decrease_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.users import PublicUser, AnonymousUser, User, UserRead 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 fastapi import HTTPException, Request, UploadFile from datetime import datetime -import asyncio async def get_course( diff --git a/apps/api/src/services/trail/trail.py b/apps/api/src/services/trail/trail.py index 30a5bffc..aadad108 100644 --- a/apps/api/src/services/trail/trail.py +++ b/apps/api/src/services/trail/trail.py @@ -9,6 +9,7 @@ from src.db.trail_runs import TrailRun, TrailRunRead from src.db.trail_steps import TrailStep from src.db.trails import Trail, TrailCreate, TrailRead from src.db.users import AnonymousUser, PublicUser +from src.services.courses.certifications import check_course_completion_and_create_certificate async def create_user_trail( @@ -68,7 +69,7 @@ async def get_user_trails( for trail_run in trail_runs: statement = select(Course).where(Course.id == trail_run.course_id) 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 statement = select(ChapterActivity).where( @@ -153,7 +154,7 @@ async def get_user_trail_with_orgid( for trail_run in trail_runs: statement = select(Course).where(Course.id == trail_run.course_id) 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 statement = select(ChapterActivity).where( @@ -255,6 +256,12 @@ async def add_activity_to_trail( db_session.commit() 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) trail_runs = db_session.exec(statement).all() diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 9c84ea46..fa410bd8 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -44,6 +44,5 @@ next.config.original.js # Sentry Config File .sentryclirc -certificates # Sentry Config File .env.sentry-build-plugin diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/certificates/[uuid]/verify/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/certificates/[uuid]/verify/page.tsx new file mode 100644 index 00000000..92387ddd --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/certificates/[uuid]/verify/page.tsx @@ -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 = async ({ params }) => { + const { uuid } = await params; + return ; +}; + +export default CertificateVerifyPage; \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index cb265b42..f868ef91 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -521,6 +521,8 @@ function ActivityClient(props: ActivityClientProps) { orgslug={orgslug} courseUuid={course.course_uuid} thumbnailImage={course.thumbnail_image} + course={course} + trailData={trailData} /> ) : (
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx index f49295a6..40ef79e4 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx @@ -43,9 +43,13 @@ export async function generateMetadata(props: MetadataProps): Promise 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 return { - title: activity.name + ` — ${course_meta.name} Course`, + title: pageTitle, description: course_meta.description, keywords: course_meta.learnings, robots: { @@ -59,7 +63,7 @@ export async function generateMetadata(props: MetadataProps): Promise }, }, openGraph: { - title: activity.name + ` — ${course_meta.name} Course`, + title: pageTitle, description: course_meta.description, publishedTime: course_meta.creation_date, tags: course_meta.learnings, diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index cd07ab92..c5c55c81 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -276,6 +276,7 @@ const CourseClient = (props: any) => { course_uuid={props.course.course_uuid} orgslug={orgslug} course={course} + trailData={trailData} /> )} diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx index 4536a2ff..9ef88479 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx @@ -3,6 +3,7 @@ import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' import PageLoading from '@components/Objects/Loaders/PageLoading' import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement' +import UserCertificates from '@components/Pages/Trail/UserCertificates' import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { getAPIUrl } from '@services/config/config' @@ -13,6 +14,7 @@ import { removeCourse } from '@services/courses/activity' import { revalidateTags } from '@services/utils/ts/requests' import { useRouter } from 'next/navigation' import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' +import { BookOpen } from 'lucide-react' function Trail(params: any) { let orgslug = params.orgslug @@ -84,20 +86,45 @@ function Trail(params: any) { /> )}
- {!trail ? ( - - ) : ( -
- {trail.runs.map((run: any) => ( - - ))} + +
+ {/* Progress Section */} +
+
+ +

My Progress

+ {trail?.runs && ( + + {trail.runs.length} + + )} +
+ + {!trail ? ( + + ) : trail.runs.length === 0 ? ( +
+ +

No courses in progress

+

Start a course to see your progress here

+
+ ) : ( +
+ {trail.runs.map((run: any) => ( + + ))} +
+ )}
- )} + + {/* Certificates Section */} + +
) } diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 0124cd5f..bb59b5b6 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -5,11 +5,12 @@ import { CourseProvider } from '../../../../../../../../components/Contexts/Cour import Link from 'next/link' import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop' 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 EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess' import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors' +import EditCourseCertification from '@components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification' export type CourseOverviewParams = { orgslug: string courseuuid: string @@ -102,6 +103,24 @@ function CourseOverviewPage(props: { params: Promise }) {
+ +
+
+ +
Certification
+
+
+ @@ -117,6 +136,8 @@ function CourseOverviewPage(props: { params: Promise }) { {params.subpage == 'general' ? () : ('')} {params.subpage == 'access' ? () : ('')} {params.subpage == 'contributors' ? () : ('')} + {params.subpage == 'certification' ? () : ('')} + diff --git a/apps/web/components/Dashboard/Misc/SaveState.tsx b/apps/web/components/Dashboard/Misc/SaveState.tsx index 672fd36c..a362d9e3 100644 --- a/apps/web/components/Dashboard/Misc/SaveState.tsx +++ b/apps/web/components/Dashboard/Misc/SaveState.tsx @@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation' import React, { useEffect, useState } from 'react' import { mutate } from 'swr' import { updateCourse } from '@services/courses/courses' +import { updateCertification } from '@services/courses/certifications' import { useLHSession } from '@components/Contexts/LHSessionContext' function SaveState(props: { orgslug: string }) { @@ -32,6 +33,8 @@ function SaveState(props: { orgslug: string }) { // Course metadata await changeMetadataBackend() mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) + // Certification data (if present) + await saveCertificationData() await revalidateTags(['courses'], props.orgslug) dispatchCourse({ type: 'setIsSaved' }) } finally { @@ -66,6 +69,24 @@ function SaveState(props: { orgslug: string }) { 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 chapters = course_structure.chapters const chapter_order_by_ids = chapters.map((chapter: any) => { diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview.tsx new file mode 100644 index 00000000..6af4fb2e --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview.tsx @@ -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 = ({ + certificationName, + certificationDescription, + certificationType, + certificatePattern, + certificateInstructor, + certificateId, + awardedDate, + qrCodeLink +}) => { + const [qrCodeUrl, setQrCodeUrl] = useState(''); + 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 */} +
+
+ + {/* Crown-like decorations in corners */} +
+
+
+
+
+
+ + {/* Royal background pattern */} +
+
+
+ + ); + + case 'tech': + return ( + <> + {/* Tech circuit board borders */} +
+ + {/* Circuit-like corner elements */} +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + {/* Tech grid background */} +
+
+
+ + ); + + case 'nature': + return ( + <> + {/* Nature organic border */} +
+ + {/* Leaf-like decorations */} +
+
+ +
+
+ +
+
+ +
+
+ + {/* Organic background pattern */} +
+
+
+ + ); + + case 'geometric': + return ( + <> + {/* Geometric angular borders */} +
+ + {/* Geometric corner elements */} +
+
+
+
+ + {/* Abstract geometric shapes */} +
+
+
+
+ + {/* Geometric background */} +
+
+
+ + ); + + case 'vintage': + return ( + <> + {/* Art deco style borders */} +
+
+ + {/* Art deco corner decorations */} +
+
+
+
+ + {/* Art deco sunburst pattern */} +
+
+
+ + ); + + case 'waves': + return ( + <> + {/* Flowing wave borders */} +
+ + {/* Wave decorations */} +
+
+ + {/* Side wave patterns */} +
+
+ + {/* Wave background */} +
+
+
+ + ); + + case 'minimal': + return ( + <> + {/* Minimal clean border */} +
+ + {/* Subtle corner accents */} +
+
+
+
+ + ); + + case 'professional': + return ( + <> + {/* Professional double border */} +
+
+ + {/* Professional corner brackets */} +
+
+
+
+ + {/* Subtle professional background */} +
+
+
+ + ); + + case 'academic': + return ( + <> + {/* Academic traditional border */} +
+
+ + {/* Academic shield-like corners */} +
+
+
+
+ + {/* Academic laurel-like decorations */} +
+
+
+
+
+
+ + {/* Academic background pattern */} +
+
+
+ + ); + + case 'modern': + return ( + <> + {/* Modern clean asymmetric border */} +
+ + {/* Modern accent lines */} +
+
+ +
+
+ + {/* Modern dot accents */} +
+
+ + {/* Modern subtle background */} +
+
+
+ + ); + + default: + return null; + } + }; + + const theme = getPatternTheme(certificatePattern); + + return ( +
+
+ {/* Dynamic Certificate Pattern */} + {renderCertificatePattern(certificatePattern)} + + {/* Certificate ID - Top Left */} +
+
+ + ID: {certificateId || 'LH-2024-001'} +
+
+ + {/* QR Code Box - Top Right */} +
+
+ {qrCodeUrl ? ( + Certificate QR Code + ) : ( +
+ +
+ )} +
+
+ + {/* Main Content */} +
+ {/* Header with decorative line */} +
+
+
Certificate
+
+
+ + {/* Award Icon with decorative elements */} +
+
+ + {/* Decorative rays */} +
+
+
+
+
+
+
+
+ + {/* Certificate Content */} +
+

+ {certificationName || 'Certification Name'} +

+

+ {certificationDescription || 'Certification description will appear here...'} +

+
+ + {/* Decorative divider */} +
+
+
+
+
+ + {/* Certification Type Badge */} +
+ + + {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'} + +
+
+ + {/* Bottom Section */} +
+
+ {/* Left: Teacher/Organization Signature */} +
+
+ + Instructor +
+
+ {certificateInstructor || 'Dr. Jane Smith'} +
+
+
+ + {/* Center: Logo */} +
+
+ {org?.logo_image ? ( + Organization Logo + ) : ( +
+ +
+ )} +
+
+ {org?.name || 'LearnHouse'} +
+
+ + {/* Right: Award Date */} +
+
+ + Awarded +
+
+ {awardedDate || 'Dec 15, 2024'} +
+
+
+
+
+
+ ); +}; + +export default CertificatePreview; \ No newline at end of file diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx new file mode 100644 index 00000000..48107b92 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx @@ -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
Loading...
; + } + + if (certificationsError) { + return
Error loading certifications
; + } + + return ( +
+ {courseStructure && ( +
+
+
+ {/* Header Section */} +
+
+

Course Certification

+

+ Enable and configure certificates for students who complete this course +

+
+
+ + {isCreating && ( +
+ +
+ )} +
+
+ + {error && ( +
+ +
{error}
+
+ )} + + {/* Certification Configuration - Only show if enabled and has existing certification */} + {formik.values.enable_certification && hasExistingCertification && ( +
+ {/* Form Section */} +
+ + {/* Basic Information Section */} +
+

+ + Basic Information +

+

+ Configure the basic details of your certification +

+
+ +
+ {/* Certification Name */} + + + + + + + + {/* Certification Type */} + + + + { + if (!value) return; + formik.setFieldValue('certification_type', value); + }} + > + + + {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'} + + + + Course Completion + Achievement Based + Assessment Based + Participation + Skill Mastery + Professional Development + Continuing Education + Workshop Attendance + Specialization + + + + +
+ + {/* Certification Description */} + + + +