feat: course certificate

feat/course
Arik Chakma 4 weeks ago
parent fad187b862
commit f7130b42da
  1. 118
      src/components/Course/Certificate.tsx
  2. 78
      src/components/Course/CertificateView.tsx
  3. 12
      src/lib/course.ts
  4. 27
      src/pages/learn/[courseId]/certificate.astro
  5. 10
      src/styles/global.css

@ -0,0 +1,118 @@
import { useRef, useState } from 'react';
import { Modal } from '../Modal';
import { DateTime } from 'luxon';
import { Loader2 } from 'lucide-react';
type CertificateModalProps = {
onClose: () => void;
userName: string;
courseName: string;
lessonsCount: number;
quizzesCount: number;
challengesCount: number;
issuedDate: string;
};
export function CertificateModal(props: CertificateModalProps) {
const {
userName,
courseName,
lessonsCount,
quizzesCount,
challengesCount,
onClose,
issuedDate,
} = props;
const certificateRef = useRef<HTMLDivElement>(null);
const issuedAt = DateTime.fromISO(issuedDate).toFormat('MMMM dd, yyyy');
const [isLoading, setIsLoading] = useState(false);
const handleDownloadCertificate = async () => {
if (!certificateRef.current) {
return;
}
setIsLoading(true);
const certificate = certificateRef.current;
const domtoimage = (await import('dom-to-image')).default;
if (!domtoimage) {
throw new Error('Unable to download image');
}
const scale = 4;
const dataUrl = await domtoimage.toJpeg(certificate, {
height: certificate.offsetHeight * scale,
width: certificate.offsetWidth * scale,
bgcolor: 'white',
style: {
transform: 'scale(' + scale + ')',
transformOrigin: 'top left',
},
});
const link = document.createElement('a');
link.download = 'certificate.jpg';
link.href = dataUrl;
link.click();
setIsLoading(false);
};
return (
<Modal
onClose={onClose}
wrapperClassName="max-w-3xl"
bodyClassName="overflow-hidden bg-transparent shadow-none"
>
<div className="rounded-xl bg-white text-black" ref={certificateRef}>
<div className="flex w-full items-center justify-between gap-2 bg-black p-2 text-white">
<div className="flex items-center gap-1 font-medium">
<img src="/images/brand.svg" alt="roadmap.sh" className="size-7" />
roadmap.sh
</div>
<span className="text-xs">
Issued on <span className="font-medium">{issuedAt}</span>
</span>
</div>
<div className="certificate-bg flex flex-col items-center py-14 font-mono text-gray-600">
<span>Certificate of Completion</span>
<h3 className="my-2 font-[balsamiq] text-4xl font-medium text-black">
{userName}
</h3>
<span>Complete a course on roadmap.sh</span>
<h3 className="mt-10 font-[balsamiq] text-5xl font-bold text-black">
{courseName}
</h3>
<div className="mt-4 flex items-center gap-2">
<span>{lessonsCount} lessons</span>
<span>-</span>
<span>{quizzesCount} quizzes</span>
<span>-</span>
<span>{challengesCount} challenge</span>
</div>
<div className="mt-20 flex w-full flex-col items-center justify-center">
<span className="font-[balsamiq] text-2xl text-black underline underline-offset-[6px]">
Kamran Ahmed
</span>
<h4 className="text-base font-medium text-black">Kamran Ahmed</h4>
<span className="text-xs">Founder, roadmap.sh</span>
</div>
</div>
</div>
<button
className="absolute bottom-4 right-4 flex items-center gap-1 rounded-lg bg-black px-2 py-1 text-white disabled:cursor-progress"
onClick={handleDownloadCertificate}
disabled={isLoading}
>
{isLoading && <Loader2 className="size-4 animate-spin stroke-[2.5]" />}
Download Certificate
</button>
</Modal>
);
}

@ -4,16 +4,23 @@ import { RateCourseForm } from './RateCourseForm';
import type { ChapterFileType } from '../../lib/course';
import { useCourseProgress } from '../../hooks/use-course';
import { Loader2 } from 'lucide-react';
import { CertificateModal } from './Certificate';
import { useAuth } from '../../hooks/use-auth';
type CertificateViewProps = {
courseTitle: string;
chapters: ChapterFileType[];
currentCourseId: string;
};
export function CertificateView(props: CertificateViewProps) {
const { currentCourseId, chapters } = props;
const { currentCourseId, chapters, courseTitle } = props;
const user = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [showCertificateModal, setShowCertificateModal] = useState(false);
const [rating, setRating] = useState(0);
const [showRatingForm, setShowRatingForm] = useState(false);
const { data: courseProgress, status } = useCourseProgress(currentCourseId);
@ -44,8 +51,45 @@ export function CertificateView(props: CertificateViewProps) {
);
}, [allLessonLinks, completeLessonSet]);
const [rating, setRating] = useState(0);
const [showRatingForm, setShowRatingForm] = useState(false);
const {
chapters: chaptersCount,
lessons: lessonsCount,
quizzes: quizCount,
challenges: challengeCount,
} = useMemo(() => {
const counts = {
chapters: 0,
lessons: 0,
quizzes: 0,
challenges: 0,
};
for (const chapter of chapters.filter(
(chapter) => chapter.lessons.length > 0,
)) {
counts.chapters += 1;
for (const lesson of chapter.lessons) {
if (lesson.frontmatter.type === 'quiz') {
counts.quizzes += 1;
}
if (lesson.frontmatter.type === 'challenge') {
counts.challenges += 1;
}
if (
['lesson', 'lesson-challenge', 'lesson-quiz'].includes(
lesson.frontmatter.type,
)
) {
counts.lessons += 1;
}
}
}
return counts;
}, chapters);
useEffect(() => {
if (!courseProgress) {
@ -67,6 +111,21 @@ export function CertificateView(props: CertificateViewProps) {
/>
)}
{showCertificateModal && (
<CertificateModal
userName={user?.name || 'N/A'}
courseName={courseTitle}
lessonsCount={lessonsCount}
quizzesCount={quizCount}
challengesCount={challengeCount}
onClose={() => {
setShowCertificateModal(false);
}}
// FIXME: This should be the actual date of course completion
issuedDate={new Date().toISOString()}
/>
)}
<div className="mx-auto flex max-w-md flex-col items-center justify-center">
{isLoading && (
<Loader2 className="size-8 animate-spin stroke-[2.5] text-zinc-200" />
@ -80,14 +139,11 @@ export function CertificateView(props: CertificateViewProps) {
You finished the course. Download the completion certificate
below and share it with the world.
</p>
<button>
<a
target="_blank"
rel="noreferrer"
className="mt-8 block rounded-full bg-zinc-700 px-6 py-2.5 font-medium text-white"
>
Download Certificate
</a>
<button
className="mt-8 block rounded-full bg-zinc-700 px-6 py-2.5 font-medium text-white"
onClick={() => setShowCertificateModal(true)}
>
View Certificate
</button>
</div>

@ -6,12 +6,12 @@ export interface CourseFrontmatter {
description: string;
}
export type AllowedLessonType =
| 'lesson'
| 'challenge'
| 'quiz'
| 'lesson-challenge'
| 'lesson-quiz';
export type AllowedLessonType =
| 'lesson'
| 'lesson-challenge'
| 'lesson-quiz'
| 'challenge'
| 'quiz';
export type LessonFrontmatter = {
title: string;

@ -1,7 +1,7 @@
---
import { CertificateView } from '../../../components/Course/CertificateView';
import { CourseLayout } from '../../../components/Course/CourseLayout';
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import {
getAllCourses,
getChaptersByCourseId,
@ -54,7 +54,17 @@ const { courseId } = Astro.params;
const { course } = Astro.props;
---
<SkeletonLayout title={course.frontmatter.title}>
<BaseLayout title={course.frontmatter.title}>
<link
rel='preload'
href='/fonts/balsamiq.woff2'
as='font'
type='font/woff2'
crossorigin
slot='after-header'
/>
<div slot='page-header'></div>
<CourseLayout
activeCourseId={courseId}
title={course.frontmatter.title}
@ -62,9 +72,20 @@ const { course } = Astro.props;
client:load
>
<CertificateView
courseTitle={course.frontmatter.title}
chapters={course.chapters}
currentCourseId={courseId}
client:load
/>
</CourseLayout>
</SkeletonLayout>
<div slot='page-footer'></div>
</BaseLayout>
<style>
@font-face {
font-family: 'balsamiq';
src: url('/fonts/balsamiq.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
</style>

@ -141,3 +141,13 @@ a > code:before {
background-position: 100% 100%;
}
}
.certificate-bg {
background-color: white;
background-image: radial-gradient(#444df74f 0.5px, transparent 0.5px),
radial-gradient(#444df74f 0.5px, white 0.5px);
background-size: 20px 20px;
background-position:
0 0,
10px 10px;
}

Loading…
Cancel
Save