mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: adapt trail page to show the user certificates
This commit is contained in:
parent
f01f7efb06
commit
d58336795a
13 changed files with 457 additions and 20 deletions
|
|
@ -18,6 +18,7 @@ from src.services.courses.certifications import (
|
||||||
delete_certification,
|
delete_certification,
|
||||||
get_user_certificates_for_course,
|
get_user_certificates_for_course,
|
||||||
get_certificate_by_user_certification_uuid,
|
get_certificate_by_user_certification_uuid,
|
||||||
|
get_all_user_certificates,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -126,4 +127,18 @@ async def api_get_certificate_by_user_certification_uuid(
|
||||||
"""
|
"""
|
||||||
return await get_certificate_by_user_certification_uuid(
|
return await get_certificate_by_user_certification_uuid(
|
||||||
request, user_certification_uuid, current_user, db_session
|
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
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
# RBAC Utils
|
||||||
####################################################
|
####################################################
|
||||||
|
|
|
||||||
1
apps/web/.gitignore
vendored
1
apps/web/.gitignore
vendored
|
|
@ -44,6 +44,5 @@ next.config.original.js
|
||||||
# Sentry Config File
|
# Sentry Config File
|
||||||
.sentryclirc
|
.sentryclirc
|
||||||
|
|
||||||
certificates
|
|
||||||
# Sentry Config File
|
# Sentry Config File
|
||||||
.env.sentry-build-plugin
|
.env.sentry-build-plugin
|
||||||
|
|
|
||||||
11
apps/web/app/certificates/[certificateUuid]/verify/page.tsx
Normal file
11
apps/web/app/certificates/[certificateUuid]/verify/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
13
apps/web/app/certificates/layout.tsx
Normal file
13
apps/web/app/certificates/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -276,6 +276,7 @@ const CourseClient = (props: any) => {
|
||||||
course_uuid={props.course.course_uuid}
|
course_uuid={props.course.course_uuid}
|
||||||
orgslug={orgslug}
|
orgslug={orgslug}
|
||||||
course={course}
|
course={course}
|
||||||
|
trailData={trailData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
import { useOrg } from '@components/Contexts/OrgContext'
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
import PageLoading from '@components/Objects/Loaders/PageLoading'
|
import PageLoading from '@components/Objects/Loaders/PageLoading'
|
||||||
import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement'
|
import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement'
|
||||||
|
import UserCertificates from '@components/Pages/Trail/UserCertificates'
|
||||||
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
|
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
|
||||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||||
import { getAPIUrl } from '@services/config/config'
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
|
@ -13,6 +14,7 @@ import { removeCourse } from '@services/courses/activity'
|
||||||
import { revalidateTags } from '@services/utils/ts/requests'
|
import { revalidateTags } from '@services/utils/ts/requests'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||||
|
import { BookOpen } from 'lucide-react'
|
||||||
|
|
||||||
function Trail(params: any) {
|
function Trail(params: any) {
|
||||||
let orgslug = params.orgslug
|
let orgslug = params.orgslug
|
||||||
|
|
@ -84,20 +86,45 @@ function Trail(params: any) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!trail ? (
|
|
||||||
<PageLoading></PageLoading>
|
<div className="space-y-8">
|
||||||
) : (
|
{/* Progress Section */}
|
||||||
<div className="space-y-6">
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||||
{trail.runs.map((run: any) => (
|
<div className="flex items-center space-x-3 mb-6">
|
||||||
<TrailCourseElement
|
<BookOpen className="w-6 h-6 text-blue-500" />
|
||||||
key={run.course.course_uuid}
|
<h2 className="text-xl font-semibold text-gray-900">My Progress</h2>
|
||||||
run={run}
|
{trail?.runs && (
|
||||||
course={run.course}
|
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||||
orgslug={orgslug}
|
{trail.runs.length}
|
||||||
/>
|
</span>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!trail ? (
|
||||||
|
<PageLoading></PageLoading>
|
||||||
|
) : trail.runs.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<BookOpen className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500">No courses in progress</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Start a course to see your progress here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{trail.runs.map((run: any) => (
|
||||||
|
<TrailCourseElement
|
||||||
|
key={run.course.course_uuid}
|
||||||
|
run={run}
|
||||||
|
course={run.course}
|
||||||
|
orgslug={orgslug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Certificates Section */}
|
||||||
|
<UserCertificates orgslug={orgslug} />
|
||||||
|
</div>
|
||||||
</GeneralWrapperStyled>
|
</GeneralWrapperStyled>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ const LinkItem = (props: any) => {
|
||||||
{props.type == 'trail' && (
|
{props.type == 'trail' && (
|
||||||
<>
|
<>
|
||||||
<Signpost size={20} />{' '}
|
<Signpost size={20} />{' '}
|
||||||
<span>Trail</span>
|
<span>Progress</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCer
|
||||||
import { Shield, CheckCircle, XCircle, AlertTriangle, ArrowLeft } from 'lucide-react';
|
import { Shield, CheckCircle, XCircle, AlertTriangle, ArrowLeft } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getUriWithOrg } from '@services/config/config';
|
import { getUriWithOrg } from '@services/config/config';
|
||||||
|
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
|
||||||
import { useOrg } from '@components/Contexts/OrgContext';
|
import { useOrg } from '@components/Contexts/OrgContext';
|
||||||
|
|
||||||
interface CertificateVerificationPageProps {
|
interface CertificateVerificationPageProps {
|
||||||
|
|
@ -174,8 +175,9 @@ const CertificateVerificationPage: React.FC<CertificateVerificationPageProps> =
|
||||||
|
|
||||||
{/* Certificate Details */}
|
{/* Certificate Details */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Certificate Preview */}
|
{/* Certificate Preview and Course Info */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Certificate Preview */}
|
||||||
<div className="bg-white rounded-2xl p-6 nice-shadow">
|
<div className="bg-white rounded-2xl p-6 nice-shadow">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Certificate Preview</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Certificate Preview</h2>
|
||||||
<div className="max-w-2xl mx-auto" id="certificate-preview">
|
<div className="max-w-2xl mx-auto" id="certificate-preview">
|
||||||
|
|
@ -195,6 +197,81 @@ const CertificateVerificationPage: React.FC<CertificateVerificationPageProps> =
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Certificate Details */}
|
{/* Certificate Details */}
|
||||||
|
|
@ -254,8 +331,6 @@ const CertificateVerificationPage: React.FC<CertificateVerificationPageProps> =
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-6">
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="flex items-center space-x-3 mb-3">
|
||||||
<Shield className="w-6 h-6 text-blue-600" />
|
<Shield className="w-6 h-6 text-blue-600" />
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ import { removeCourse } from '@services/courses/activity'
|
||||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||||
import { revalidateTags } from '@services/utils/ts/requests'
|
import { revalidateTags } from '@services/utils/ts/requests'
|
||||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
import { getUserCertificates } from '@services/courses/certifications'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { mutate } from 'swr'
|
import { mutate } from 'swr'
|
||||||
|
import { Award, ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
interface TrailCourseElementProps {
|
interface TrailCourseElementProps {
|
||||||
course: any
|
course: any
|
||||||
|
|
@ -29,6 +31,9 @@ function TrailCourseElement(props: TrailCourseElementProps) {
|
||||||
const course_progress = Math.round(
|
const course_progress = Math.round(
|
||||||
(course_completed_steps / course_total_steps) * 100
|
(course_completed_steps / course_total_steps) * 100
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [courseCertificate, setCourseCertificate] = useState<any>(null)
|
||||||
|
const [isLoadingCertificate, setIsLoadingCertificate] = useState(false)
|
||||||
|
|
||||||
async function quitCourse(course_uuid: string) {
|
async function quitCourse(course_uuid: string) {
|
||||||
// Close activity
|
// Close activity
|
||||||
|
|
@ -41,6 +46,31 @@ function TrailCourseElement(props: TrailCourseElementProps) {
|
||||||
mutate(`${getAPIUrl()}trail/org/${orgID}/trail`)
|
mutate(`${getAPIUrl()}trail/org/${orgID}/trail`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch certificate for this course
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCourseCertificate = async () => {
|
||||||
|
if (!access_token || course_progress < 100) return;
|
||||||
|
|
||||||
|
setIsLoadingCertificate(true);
|
||||||
|
try {
|
||||||
|
const result = await getUserCertificates(
|
||||||
|
props.course.course_uuid,
|
||||||
|
access_token
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
|
setCourseCertificate(result.data[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching course certificate:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingCertificate(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCourseCertificate();
|
||||||
|
}, [access_token, course_progress, props.course.course_uuid]);
|
||||||
|
|
||||||
useEffect(() => {}, [props.course, org])
|
useEffect(() => {}, [props.course, org])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -90,6 +120,41 @@ function TrailCourseElement(props: TrailCourseElementProps) {
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Section */}
|
||||||
|
{course_progress === 100 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||||
|
{isLoadingCertificate ? (
|
||||||
|
<div className="flex items-center space-x-1 text-xs text-gray-500">
|
||||||
|
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-yellow-500"></div>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : courseCertificate ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Award className="w-3 h-3 text-yellow-500" />
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
Certificate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={getUriWithOrg(props.orgslug, `/certificates/${courseCertificate.certificate_user.user_certification_uuid}/verify`)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-xs font-medium"
|
||||||
|
>
|
||||||
|
<span>Verify</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-1 text-xs text-gray-500">
|
||||||
|
<Award className="w-3 h-3 text-gray-300" />
|
||||||
|
<span>No certificate</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
148
apps/web/components/Pages/Trail/UserCertificates.tsx
Normal file
148
apps/web/components/Pages/Trail/UserCertificates.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import { getAllUserCertificates } from '@services/courses/certifications'
|
||||||
|
import { getUriWithOrg } from '@services/config/config'
|
||||||
|
import { Award, ExternalLink, Calendar, Hash, Building } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { swrFetcher } from '@services/utils/ts/requests'
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
|
||||||
|
interface UserCertificatesProps {
|
||||||
|
orgslug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserCertificates: React.FC<UserCertificatesProps> = ({ orgslug }) => {
|
||||||
|
const session = useLHSession() as any
|
||||||
|
const access_token = session?.data?.tokens?.access_token
|
||||||
|
const org = useOrg() as any
|
||||||
|
|
||||||
|
const { data: certificates, error, isLoading } = useSWR(
|
||||||
|
access_token ? `${getAPIUrl()}certifications/user/all` : null,
|
||||||
|
(url) => swrFetcher(url, access_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<Award className="w-6 h-6 text-yellow-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
|
||||||
|
</div>
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="bg-gray-100 h-20 rounded-lg"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<Award className="w-6 h-6 text-yellow-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500">Failed to load certificates</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the actual API response structure - certificates are returned as an array directly
|
||||||
|
const certificatesData = Array.isArray(certificates) ? certificates : certificates?.data || []
|
||||||
|
|
||||||
|
if (!certificatesData || certificatesData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<Award className="w-6 h-6 text-yellow-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Award className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500">No certificates earned yet</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Complete courses to earn certificates</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-6">
|
||||||
|
<Award className="w-6 h-6 text-yellow-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">My Certificates</h2>
|
||||||
|
<span className="bg-yellow-100 text-yellow-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||||
|
{certificatesData.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{certificatesData.map((certificate: any) => {
|
||||||
|
const verificationLink = getUriWithOrg(orgslug, `/certificates/${certificate.certificate_user.user_certification_uuid}/verify`)
|
||||||
|
const awardedDate = new Date(certificate.certificate_user.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={certificate.certificate_user.user_certification_uuid} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Award className="w-4 h-4 text-yellow-500" />
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm truncate">
|
||||||
|
{certificate.certification.config.certification_name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs text-gray-600">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Building className="w-3 h-3" />
|
||||||
|
<span className="truncate">{certificate.course.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>Awarded {awardedDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Hash className="w-3 h-3" />
|
||||||
|
<span className="font-mono text-xs bg-gray-100 px-2 py-1 rounded truncate">
|
||||||
|
{certificate.certificate_user.user_certification_uuid}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||||
|
<div className="text-xs text-gray-500 capitalize">
|
||||||
|
{certificate.certification.config.certification_type.replace('_', ' ')}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={verificationLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-xs font-medium"
|
||||||
|
>
|
||||||
|
<span>Verify</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserCertificates
|
||||||
|
|
@ -87,4 +87,15 @@ export async function getCertificateByUuid(
|
||||||
)
|
)
|
||||||
const res = await getResponseMetadata(result)
|
const res = await getResponseMetadata(result)
|
||||||
return res
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUserCertificates(
|
||||||
|
access_token: string
|
||||||
|
) {
|
||||||
|
const result = await fetch(
|
||||||
|
`${getAPIUrl()}certifications/user/all`,
|
||||||
|
RequestBodyWithAuthHeader('GET', null, null, access_token)
|
||||||
|
)
|
||||||
|
const res = await getResponseMetadata(result)
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue