diff --git a/apps/api/src/routers/courses/certifications.py b/apps/api/src/routers/courses/certifications.py index 24c9b659..d526e350 100644 --- a/apps/api/src/routers/courses/certifications.py +++ b/apps/api/src/routers/courses/certifications.py @@ -18,6 +18,7 @@ from src.services.courses.certifications import ( delete_certification, get_user_certificates_for_course, get_certificate_by_user_certification_uuid, + get_all_user_certificates, ) router = APIRouter() @@ -126,4 +127,18 @@ async def api_get_certificate_by_user_certification_uuid( """ 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/services/courses/certifications.py b/apps/api/src/services/courses/certifications.py index d664e19b..7ed819ad 100644 --- a/apps/api/src/services/courses/certifications.py +++ b/apps/api/src/services/courses/certifications.py @@ -454,6 +454,64 @@ async def get_certificate_by_user_certification_uuid( } +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 #################################################### 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/certificates/[certificateUuid]/verify/page.tsx b/apps/web/app/certificates/[certificateUuid]/verify/page.tsx new file mode 100644 index 00000000..c9a212db --- /dev/null +++ b/apps/web/app/certificates/[certificateUuid]/verify/page.tsx @@ -0,0 +1,11 @@ +import CertificateVerificationPage from '@components/Pages/Certificate/CertificateVerificationPage' + +interface CertificateVerifyPageProps { + params: { + certificateUuid: string + } +} + +export default function CertificateVerifyPage({ params }: CertificateVerifyPageProps) { + return +} \ No newline at end of file diff --git a/apps/web/app/certificates/layout.tsx b/apps/web/app/certificates/layout.tsx new file mode 100644 index 00000000..13f6b595 --- /dev/null +++ b/apps/web/app/certificates/layout.tsx @@ -0,0 +1,13 @@ +export default function CertificatesLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+
+ {children} +
+
+ ) +} \ No newline at end of file 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..be09a76d --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/certificates/[uuid]/verify/page.tsx @@ -0,0 +1,14 @@ +import CertificateVerificationPage from '@components/Pages/Certificate/CertificateVerificationPage'; +import React from 'react'; + +interface CertificateVerifyPageProps { + params: { + uuid: string; + }; +} + +const CertificateVerifyPage: React.FC = ({ params }) => { + return ; +}; + +export default CertificateVerifyPage; \ No newline at end of file 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/components/Objects/Menus/OrgMenuLinks.tsx b/apps/web/components/Objects/Menus/OrgMenuLinks.tsx index 3566ff34..710777fd 100644 --- a/apps/web/components/Objects/Menus/OrgMenuLinks.tsx +++ b/apps/web/components/Objects/Menus/OrgMenuLinks.tsx @@ -52,7 +52,7 @@ const LinkItem = (props: any) => { {props.type == 'trail' && ( <> {' '} - Trail + Progress )} diff --git a/apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx b/apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx index f7cf3f01..a17e1e43 100644 --- a/apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx +++ b/apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx @@ -6,6 +6,7 @@ import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCer 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 { @@ -174,8 +175,9 @@ const CertificateVerificationPage: React.FC = {/* Certificate Details */}
- {/* Certificate Preview */} -
+ {/* Certificate Preview and Course Info */} +
+ {/* Certificate Preview */}

Certificate Preview

@@ -195,6 +197,81 @@ const CertificateVerificationPage: React.FC = />
+ + {/* Course Information */} +
+
+ {/* Course Thumbnail */} +
+
+ {certificateData.course.thumbnail_image ? ( + {`${certificateData.course.name} + ) : ( +
+ + + +
+ )} +
+
+ + {/* Course Details */} +
+
+
+

{certificateData.course.name}

+ {certificateData.course.description && ( +

{certificateData.course.description}

+ )} +
+ + {certificateData.course.authors && certificateData.course.authors.length > 0 && ( +
+ By: +
+ {certificateData.course.authors + .filter((author: any) => author.authorship_status === 'ACTIVE') + .slice(0, 2) + .map((author: any, index: number) => ( + + {author.user.first_name} {author.user.last_name} + {index < Math.min(2, certificateData.course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length - 1) && ', '} + + ))} + {certificateData.course.authors.filter((author: any) => author.authorship_status === 'ACTIVE').length > 2 && ( + + +{certificateData.course.authors.filter((author: any) => author.authorship_status === 'ACTIVE').length - 2} more + + )} +
+
+ )} +
+
+ + {/* View Course Link */} +
+ + View Course + + + + +
+
+
{/* Certificate Details */} @@ -254,8 +331,6 @@ const CertificateVerificationPage: React.FC =
- -
diff --git a/apps/web/components/Pages/Trail/TrailCourseElement.tsx b/apps/web/components/Pages/Trail/TrailCourseElement.tsx index fe6ab206..c89d9eb0 100644 --- a/apps/web/components/Pages/Trail/TrailCourseElement.tsx +++ b/apps/web/components/Pages/Trail/TrailCourseElement.tsx @@ -5,10 +5,12 @@ import { removeCourse } from '@services/courses/activity' import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { revalidateTags } from '@services/utils/ts/requests' import { useLHSession } from '@components/Contexts/LHSessionContext' +import { getUserCertificates } from '@services/courses/certifications' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { mutate } from 'swr' +import { Award, ExternalLink } from 'lucide-react' interface TrailCourseElementProps { course: any @@ -29,6 +31,9 @@ function TrailCourseElement(props: TrailCourseElementProps) { const course_progress = Math.round( (course_completed_steps / course_total_steps) * 100 ) + + const [courseCertificate, setCourseCertificate] = useState(null) + const [isLoadingCertificate, setIsLoadingCertificate] = useState(false) async function quitCourse(course_uuid: string) { // Close activity @@ -41,6 +46,31 @@ function TrailCourseElement(props: TrailCourseElementProps) { 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]) return ( @@ -90,6 +120,41 @@ function TrailCourseElement(props: TrailCourseElementProps) { >
+ + {/* Certificate Section */} + {course_progress === 100 && ( +
+ {isLoadingCertificate ? ( +
+
+ Loading... +
+ ) : courseCertificate ? ( +
+
+ + + Certificate + +
+ + Verify + + +
+ ) : ( +
+ + No certificate +
+ )} +
+ )} ) diff --git a/apps/web/components/Pages/Trail/UserCertificates.tsx b/apps/web/components/Pages/Trail/UserCertificates.tsx new file mode 100644 index 00000000..8612ec85 --- /dev/null +++ b/apps/web/components/Pages/Trail/UserCertificates.tsx @@ -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 = ({ 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 ( +
+
+ +

My Certificates

+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ) + } + + if (error) { + return ( +
+
+ +

My Certificates

+
+
+

Failed to load certificates

+
+
+ ) + } + + // 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 ( +
+
+ +

My Certificates

+
+
+ +

No certificates earned yet

+

Complete courses to earn certificates

+
+
+ ) + } + + return ( +
+
+ +

My Certificates

+ + {certificatesData.length} + +
+ +
+ {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 ( +
+
+
+ +

+ {certificate.certification.config.certification_name} +

+
+ +
+
+ + {certificate.course.name} +
+ +
+ + Awarded {awardedDate} +
+ +
+ + + {certificate.certificate_user.user_certification_uuid} + +
+
+ +
+
+ {certificate.certification.config.certification_type.replace('_', ' ')} +
+ + Verify + + +
+
+
+ ) + })} +
+
+ ) +} + +export default UserCertificates \ No newline at end of file diff --git a/apps/web/services/courses/certifications.ts b/apps/web/services/courses/certifications.ts index 5df4497e..ad15bcd7 100644 --- a/apps/web/services/courses/certifications.ts +++ b/apps/web/services/courses/certifications.ts @@ -87,4 +87,15 @@ export async function getCertificateByUuid( ) 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 } \ No newline at end of file