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,
|
||||
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
|
||||
)
|
||||
|
|
@ -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
1
apps/web/.gitignore
vendored
|
|
@ -44,6 +44,5 @@ next.config.original.js
|
|||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
certificates
|
||||
# Sentry Config File
|
||||
.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}
|
||||
orgslug={orgslug}
|
||||
course={course}
|
||||
trailData={trailData}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
{!trail ? (
|
||||
<PageLoading></PageLoading>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{trail.runs.map((run: any) => (
|
||||
<TrailCourseElement
|
||||
key={run.course.course_uuid}
|
||||
run={run}
|
||||
course={run.course}
|
||||
orgslug={orgslug}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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) => (
|
||||
<TrailCourseElement
|
||||
key={run.course.course_uuid}
|
||||
run={run}
|
||||
course={run.course}
|
||||
orgslug={orgslug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificates Section */}
|
||||
<UserCertificates orgslug={orgslug} />
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const LinkItem = (props: any) => {
|
|||
{props.type == 'trail' && (
|
||||
<>
|
||||
<Signpost size={20} />{' '}
|
||||
<span>Trail</span>
|
||||
<span>Progress</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="lg:col-span-2">
|
||||
{/* Certificate Preview and Course Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Certificate Preview */}
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -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<any>(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) {
|
|||
></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>
|
||||
)
|
||||
|
|
|
|||
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)
|
||||
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