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

+ ) : (
+
+ )}
+
+
+
+ {/* 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 ? (
+
+ ) : courseCertificate ? (
+
+ ) : (
+
+ )}
+
+ )}
)
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 (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
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 (
+
+
+
+
+
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