Skip to content

Commit

Permalink
Merge branch 'certificates' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
martineckardt committed Sep 10, 2024
2 parents 5dcba34 + d1f82af commit 0dd8e0c
Show file tree
Hide file tree
Showing 23 changed files with 821 additions and 477 deletions.
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

0 comments on commit 0dd8e0c

Please sign in to comment.