Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add: certificates feature on Academy #138

Merged
merged 10 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions app/api/generate-certificate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { PDFDocument } from 'pdf-lib';

const courseMapping: Record<string, string> = {
'avalanche-fundamentals': 'Avalanche Fundamentals',
};

function getCourseName(courseId: string): string {
return courseMapping[courseId] || courseId;
}

export async function POST(req: NextRequest) {
try {
const { courseId, userName } = await req.json();
if (!courseId || !userName) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); }
const courseName = getCourseName(courseId);
const protocol = req.headers.get('x-forwarded-proto') || 'http';
const host = req.headers.get('host') || 'localhost:3000';
const serverUrl = `${protocol}://${host}`;
const templateUrl = `${serverUrl}/certificates/AvalancheAcademy_Certificate.pdf`;
const templateResponse = await fetch(templateUrl);

if (!templateResponse.ok) { throw new Error(`Failed to fetch template`); }

const templateArrayBuffer = await templateResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(templateArrayBuffer);
const form = pdfDoc.getForm();

try {
// fills the form fields in our certificate template
form.getTextField('FullName').setText(userName);
form.getTextField('Class').setText(courseName);
form.getTextField('Awarded').setText(new Date().toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }));
form.getTextField('Id').setText(Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15));
} catch (error) {
throw new Error('Failed to fill form fields');
}

form.flatten();
const pdfBytes = await pdfDoc.save();
return new NextResponse(pdfBytes, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename=${courseId}_certificate.pdf`,
},
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate certificate, contact the Avalanche team.', details: (error as Error).message },
{ status: 500 }
);
}
}
241 changes: 241 additions & 0 deletions components/certificates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"use client";
import React, { useState, useEffect } from 'react';
import { getQuizResponse } from '@/utils/indexedDB';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import quizDataImport from '@/components/quizzes/quizData.json';
import Quiz from '@/components/quizzes/quiz';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Linkedin, Twitter, Award, Share2 } from 'lucide-react';

interface CertificatePageProps {
courseId: string;
}

interface QuizInfo {
id: string;
chapter: string;
question: string;
}

interface QuizData {
question: string;
options: string[];
correctAnswers: number[];
hint: string;
explanation: string;
chapter: string;
}

interface Course {
title: string;
quizzes: string[];
}

interface QuizDataStructure {
courses: {
[courseId: string]: Course;
};
quizzes: {
[quizId: string]: QuizData;
};
}

const quizData = quizDataImport as QuizDataStructure;

const CertificatePage: React.FC<CertificatePageProps> = ({ courseId }) => {
const [completedQuizzes, setCompletedQuizzes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [userName, setUserName] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [quizzes, setQuizzes] = useState<QuizInfo[]>([]);

useEffect(() => {
const fetchQuizzes = () => {
const courseQuizzes = quizData.courses[courseId]?.quizzes || [];
const quizzesWithChapters = courseQuizzes.map(quizId => ({
id: quizId,
chapter: quizData.quizzes[quizId]?.chapter || 'Unknown Chapter',
question: quizData.quizzes[quizId]?.question || ''
}));
setQuizzes(quizzesWithChapters);
};

fetchQuizzes();
}, [courseId]);

useEffect(() => {
const checkQuizCompletion = async () => {
const completed = await Promise.all(
quizzes.map(async (quiz) => {
const response = await getQuizResponse(quiz.id);
return response && response.isCorrect ? quiz.id : null;
})
);
setCompletedQuizzes(completed.filter((id): id is string => id !== null));
setIsLoading(false);
};

if (quizzes.length > 0) {
checkQuizCompletion();
}
}, [quizzes]);

const allQuizzesCompleted = completedQuizzes.length === quizzes.length;

const generateCertificate = async () => {
if (!userName.trim()) {
alert('Please enter your name');
return;
}

setIsGenerating(true);
try {
const response = await fetch('/api/generate-certificate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseId,
userName,
}),
});

if (!response.ok) {
throw new Error('Failed to generate certificate');
}

const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `${courseId}_certificate.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating certificate:', error);
alert('Failed to generate certificate. Please try again.');
} finally {
setIsGenerating(false);
}
};

const chapters = [...new Set(quizzes.map(quiz => quiz.chapter))];

const quizzesByChapter = chapters.reduce((acc, chapter) => {
acc[chapter] = quizzes.filter(quiz => quiz.chapter === chapter);
return acc;
}, {} as Record<string, QuizInfo[]>);

const shareOnLinkedIn = () => {
const url = `https://www.linkedin.com/in/eckardt/edit/forms/certification/new/?isFromA2p=true&name=Avalanche%20Fundamentals&organizationId=19104188&organizationName=Avalanche`;
window.open(url, '_blank');
};

const shareOnTwitter = () => {
const text = `I just completed the ${quizData.courses[courseId].title} course on Avalanche Academy!`;
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(window.location.href)}`;
window.open(url, '_blank');
};

if (isLoading) {
return <div>Loading...</div>;
}

return (
<div className="max-w-4xl mx-auto p-4">
{chapters.map((chapter) => {
const chapterQuizzes = quizzesByChapter[chapter];
const incompleteQuizzes = chapterQuizzes.filter(quiz => !completedQuizzes.includes(quiz.id));

if (incompleteQuizzes.length === 0) return null;

return (
<div key={chapter} className="mb-8">
<h3 className="text-xl font-medium mb-4">{chapter}</h3>
<Accordions type="single" collapsible>
{incompleteQuizzes.map((quiz) => (
<Accordion key={quiz.id} title={`${quiz.question}`}>
<Quiz quizId={quiz.id} />
</Accordion>
))}
</Accordions>
</div>
);
})}

{allQuizzesCompleted && (
<div className="mt-12 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<div className="flex items-center justify-center mb-6">
<Award className="w-16 h-16 text-green-500 mr-4" />
<h2 className="text-3xl font-bold text-gray-800 dark:text-white">Congratulations!</h2>
</div>
<p className="text-center text-gray-600 dark:text-gray-300 mb-8">
You've completed all quizzes for the {quizData.courses[courseId].title} course. Claim your certificate now!
</p>
<div className="mb-6">
<label htmlFor="userName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Enter your full name for the certificate:
</label>
<input
type="text"
id="userName"
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="John Doe"
/>
</div>
<button
className={cn(
buttonVariants({ variant: 'default' }),
'w-full mb-6 py-3 text-lg'
)}
onClick={generateCertificate}
disabled={isGenerating}
>
{isGenerating ? 'Generating Certificate...' : 'Generate My Certificate'}
</button>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<p className="text-center text-gray-600 dark:text-gray-300 mb-4">
Share your achievement:
</p>
<div className="flex justify-center space-x-4">
<button
className={cn(
buttonVariants({ variant: 'secondary' }),
'flex items-center px-4 py-2'
)}
onClick={shareOnLinkedIn}
>
<Linkedin className="mr-2 h-5 w-5" />
LinkedIn
</button>
<button
className={cn(
buttonVariants({ variant: 'secondary' }),
'flex items-center px-4 py-2'
)}
onClick={shareOnTwitter}
>
<Twitter className="mr-2 h-5 w-5" />
Twitter
</button>
</div>
</div>
</div>
)}
{!allQuizzesCompleted && (
<div className="mt-12 bg-muted rounded-lg shadow-lg p-8">
<Share2 className="w-8 h-8 mx-auto mb-2 text-yellow-500" />
Complete all quizzes to unlock your certificate and share your achievement!
</div>
)}
</div>
);
};

export default CertificatePage;
Loading
Loading