feat: adapt trail page to show the user certificates

This commit is contained in:
swve 2025-07-20 11:03:44 +02:00
parent f01f7efb06
commit d58336795a
13 changed files with 457 additions and 20 deletions

View file

@ -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()
@ -127,3 +128,17 @@ 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
)

View file

@ -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
####################################################

1
apps/web/.gitignore vendored
View file

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

View file

@ -0,0 +1,11 @@
import CertificateVerificationPage from '@components/Pages/Certificate/CertificateVerificationPage'
interface CertificateVerifyPageProps {
params: {
certificateUuid: string
}
}
export default function CertificateVerifyPage({ params }: CertificateVerifyPageProps) {
return <CertificateVerificationPage certificateUuid={params.certificateUuid} />
}

View file

@ -0,0 +1,13 @@
export default function CertificatesLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-8">
{children}
</div>
</div>
)
}

View file

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

View file

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

View file

@ -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,8 +86,28 @@ function Trail(params: any) {
/>
)}
</div>
<div className="space-y-8">
{/* Progress Section */}
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-6">
<BookOpen className="w-6 h-6 text-blue-500" />
<h2 className="text-xl font-semibold text-gray-900">My Progress</h2>
{trail?.runs && (
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{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) => (
@ -98,6 +120,11 @@ function Trail(params: any) {
))}
</div>
)}
</div>
{/* Certificates Section */}
<UserCertificates orgslug={orgslug} />
</div>
</GeneralWrapperStyled>
)
}

View file

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

View file

@ -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<CertificateVerificationPageProps> =
{/* 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="lg:col-span-2">
<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">
@ -195,6 +197,81 @@ const CertificateVerificationPage: React.FC<CertificateVerificationPageProps> =
/>
</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 */}
@ -254,8 +331,6 @@ const CertificateVerificationPage: React.FC<CertificateVerificationPageProps> =
</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" />

View file

@ -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
@ -30,6 +32,9 @@ function TrailCourseElement(props: TrailCourseElementProps) {
(course_completed_steps / course_total_steps) * 100
)
const [courseCertificate, setCourseCertificate] = useState<any>(null)
const [isLoadingCertificate, setIsLoadingCertificate] = useState(false)
async function quitCourse(course_uuid: string) {
// Close activity
let activity = await removeCourse(course_uuid, props.orgslug,access_token)
@ -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) {
></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>
)

View file

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

View file

@ -88,3 +88,14 @@ 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
}