Everything another Base44 app needs to reproduce the exact same certificate system — entities, components, backend function, and workflow. Download the guide and hand it to the other app's builder.
# Certificate of Completion — Full Replication Guide
This document explains exactly how certificates work in this app so another Base44 app can reproduce the same system end-to-end. It covers the data model, the visual certificate component, the admin editor, the backend function that issues certificates, and the workflow that triggers it automatically.
---
## How it works (overview)
1. An admin configures a **certificate template** once via the admin panel (title, workshop name, body text, signature image, etc.). That template lives in a single `CertificateSettings` record.
2. Users watch training modules and take quizzes. Progress is tracked in a `VideoProgress` entity (one record per module per user).
3. A **workflow** fires whenever a `VideoProgress` record is marked complete. It calls the `issue-certificate` backend function.
4. The backend function checks whether the user has completed **all** modules and passed every quiz. If yes, it snapshots the current template settings into a new `IssuedCertificate` record and emails the user.
5. The user views their certificate on the site via the `CertificateView` component, which reads the live `CertificateSettings` template and renders a printable, landscape A4-style certificate with the user's name, the date, the signature, and the logo. A **Download PDF** button generates a real PDF file via html2canvas + jsPDF.
---
## 1. Entities
You need two entities (plus a VideoProgress entity to track module completion). Create them in the Base44 builder or by writing the JSON schema files.
### CertificateSettings (the template — one record)
Path: `base44/entities/CertificateSettings.jsonc`
```jsonc
{
"name": "CertificateSettings",
"type": "object",
"properties": {
"signature_image_url": { "type": "string", "description": "URL of the signature image shown above the role title" },
"signature_title": { "type": "string", "default": "Executive Director", "description": "Title shown under the signature line" },
"certificate_title": { "type": "string", "default": "Certificate of Completion", "description": "Main heading on the certificate" },
"workshop_name": { "type": "string", "default": "Virtual Build & Program A Pool Workshop", "description": "Name of the completed workshop shown in the blue banner" },
"body_text": { "type": "string", "default": "and has demonstrated a commitment to thoughtful aquatic facility planning, sustainable programming, and stronger community impact.", "description": "Descriptive paragraph below the workshop banner" },
"footer_tagline": { "type": "string", "default": "Stronger Programs. Safer Communities. A Better Aquatic Future.", "description": "Tagline shown in the footer row" },
"website_url": { "type": "string", "default": "TotalAquatic.org", "description": "Website text shown in the footer" },
"logo_url": { "type": "string", "description": "Override the default logo URL (optional)" },
"description": { "type": "string", "maxLength": 1000, "description": "Optional description" }
},
"required": [],
"rls": {
"read": { "$or": [ { "created_by_id": "{{user.id}}" }, { "user_condition": { "role": "admin" } } ] },
"create": { "user_condition": { "role": "admin" } },
"update": { "user_condition": { "role": "admin" } },
"delete": { "user_condition": { "role": "admin" } }
}
}
```
### IssuedCertificate (one per user, created at issuance)
Path: `base44/entities/IssuedCertificate.jsonc`
```jsonc
{
"name": "IssuedCertificate",
"type": "object",
"properties": {
"user_name": { "type": "string", "description": "Name of the user who earned the certificate" },
"certificate_title": { "type": "string", "description": "Certificate title snapshot at issuance" },
"workshop_name": { "type": "string", "description": "Workshop name snapshot at issuance" },
"body_text": { "type": "string", "description": "Certificate body text snapshot at issuance" },
"signature_title": { "type": "string", "description": "Signature title snapshot at issuance" },
"issued_at": { "type": "string", "format": "date-time", "description": "When the certificate was issued" },
"description": { "type": "string", "maxLength": 1000, "description": "Optional description" }
},
"required": ["user_name"],
"rls": {
"read": { "$or": [ { "created_by_id": "{{user.id}}" }, { "user_condition": { "role": "admin" } } ] },
"create": { "user_condition": { "role": "admin" } },
"update": { "user_condition": { "role": "admin" } },
"delete": { "user_condition": { "role": "admin" } }
}
}
```
### VideoProgress (tracks module completion — triggers the workflow)
Path: `base44/entities/VideoProgress.jsonc`
```jsonc
{
"name": "VideoProgress",
"type": "object",
"properties": {
"video_id": { "type": "integer", "description": "Module video id" },
"video_title": { "type": "string", "description": "Module video title" },
"completed": { "type": "boolean", "default": false, "description": "Whether the user finished watching this video and passed the quiz" },
"completed_at": { "type": "string", "format": "date-time", "description": "When the video was marked complete" },
"quiz_score": { "type": "integer", "description": "Number of quiz questions answered correctly" },
"quiz_total": { "type": "integer", "description": "Total number of quiz questions for this module" }
},
"required": ["video_id", "video_title"],
"rls": {
"read": { "$or": [ { "created_by_id": "{{user.id}}" }, { "user_condition": { "role": "admin" } } ] },
"create": { "created_by_id": "{{user.id}}" },
"update": { "created_by_id": "{{user.id}}" },
"delete": { "$or": [ { "created_by_id": "{{user.id}}" }, { "user_condition": { "role": "admin" } } ] }
}
}
```
**Key design choice:** the issued certificate **snapshots** the template text at issuance. If an admin later changes the template, already-issued certificates keep their original wording. The visual component reads the *live* template for display, but the stored record preserves the historical snapshot.
---
## 2. The visual certificate component
Path: `src/components/knowledge/CertificateView.jsx`
Renders a white, landscape, A4-proportioned certificate with a double border, logo, title, user's name, workshop banner, body text, date, signature image, and footer. Reads the live `CertificateSettings` template and falls back to hardcoded defaults. Includes a **Download PDF** button (html2canvas + jsPDF) and a **Print** button.
```jsx
import { useRef, useState, useEffect } from 'react';
import { Award, Download, Pencil, Loader2, Printer } from 'lucide-react';
import { base44 } from '@/api/base44Client';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
const BAPP_LOGO = 'https://media.base44.com/images/public/6a21e3eab9bd459578e39d35/c3066d75a_TAETILOGOTRANSPARENT.png';
const DEFAULTS = {
signature_image_url: 'https://media.base44.com/images/public/6a21e3eab9bd459578e39d35/1055dd143_KMNSIG.png',
signature_title: 'Executive Director',
certificate_title: 'Certificate of Completion',
workshop_name: 'Virtual Build & Program A Pool Workshop',
body_text: 'and has demonstrated a commitment to thoughtful aquatic facility planning, sustainable programming, and stronger community impact.',
footer_tagline: 'Stronger Programs. Safer Communities. A Better Aquatic Future.',
website_url: 'TotalAquatic.org',
logo_url: '',
};
export default function CertificateView({ userName, completedAt, onEditName }) {
const certRef = useRef(null);
const [settings, setSettings] = useState(DEFAULTS);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
base44.entities.CertificateSettings.list()
.then((records) => {
if (records.length > 0) setSettings({ ...DEFAULTS, ...records[0] });
})
.catch(() => {});
}, []);
const s = settings;
const dateStr = completedAt
? new Date(completedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const handlePrint = () => {
const content = certRef.current.outerHTML;
const win = window.open('', '_blank');
win.document.write(`
<!DOCTYPE html><html><head>
<title>Certificate of Completion</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: white; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
@media print { body { min-height: unset; } @page { size: landscape; margin: 0; } }
</style>
</head><body>${content}</body></html>
`);
win.document.close();
win.focus();
setTimeout(() => { win.print(); win.close(); }, 500);
};
const handleDownload = async () => {
if (!certRef.current || downloading) return;
setDownloading(true);
try {
const canvas = await html2canvas(certRef.current, { scale: 2, useCORS: true, backgroundColor: '#ffffff' });
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const imgWidth = pageWidth;
const imgHeight = (canvas.height / canvas.width) * imgWidth;
const y = (pageHeight - imgHeight) / 2;
pdf.addImage(imgData, 'PNG', 0, y, imgWidth, imgHeight);
const safeName = (userName || 'certificate').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
pdf.save(`${safeName}-certificate.pdf`);
} catch (err) {
console.error('Certificate download failed:', err);
} finally {
setDownloading(false);
}
};
return (
<div className="my-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Award className="w-5 h-5 text-gold" />
<h2 className="font-heading text-white font-black uppercase text-lg tracking-tight">Certificate of Completion</h2>
</div>
<div className="flex items-center gap-2">
{onEditName && (
<button onClick={onEditName} className="flex items-center gap-1.5 px-4 py-2 bg-pool-surface border border-gold/30 text-gold font-heading text-xs font-black uppercase tracking-widest rounded hover:border-gold/60 transition-all">
<Pencil className="w-3.5 h-3.5" /> Change Name
</button>
)}
<button onClick={handleDownload} disabled={downloading} className="flex items-center gap-2 px-4 py-2 bg-gold text-midnight font-heading text-xs font-black uppercase tracking-widest rounded hover:bg-gold/90 transition-all disabled:opacity-50">
{downloading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
{downloading ? 'Generating…' : 'Download PDF'}
</button>
<button onClick={handlePrint} className="flex items-center gap-2 px-4 py-2 bg-pool-surface border border-gold/30 text-gold font-heading text-xs font-black uppercase tracking-widest rounded hover:border-gold/60 transition-all">
<Printer className="w-3.5 h-3.5" /> Print
</button>
</div>
</div>
{/* Certificate — inline styles so it renders identically in the print/PDF window */}
<div ref={certRef} style={{
fontFamily: "'Open Sans', sans-serif", background: 'white', width: '100%', maxWidth: '900px',
margin: '0 auto', aspectRatio: '1.414 / 1', border: '6px double #1a3a6b', padding: '32px 48px',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'space-between',
position: 'relative', boxSizing: 'border-box',
}}>
<div style={{ position: 'absolute', inset: '10px', border: '1.5px solid #1a3a6b', pointerEvents: 'none' }} />
{/* Top: Logo + title */}
<div style={{ textAlign: 'center' }}>
<img src={s.logo_url || BAPP_LOGO} alt="Logo" style={{ height: '72px', marginBottom: '12px' }} />
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: '2.4rem', fontWeight: 700, color: '#1a3a6b', letterSpacing: '0.04em', textTransform: 'uppercase', lineHeight: 1.1 }}>
{s.certificate_title}
</h1>
<div style={{ width: '320px', height: '2px', background: '#00aacc', margin: '14px auto 0' }} />
</div>
{/* Middle: Name + body */}
<div style={{ textAlign: 'center', width: '100%' }}>
<p style={{ fontSize: '0.75rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', color: '#555', marginBottom: '8px' }}>This Certifies That</p>
<p style={{ fontFamily: "'Playfair Display', serif", fontSize: '2.5rem', fontWeight: 700, color: '#1a7ab5', borderBottom: '1.5px solid #1a3a6b', borderTop: '1.5px solid #1a3a6b', padding: '6px 24px', display: 'inline-block', marginBottom: '16px' }}>
{userName || 'Participant'}
</p>
<p style={{ fontSize: '0.875rem', color: '#444', marginBottom: '10px' }}>has successfully completed the</p>
<div style={{ background: '#1a3a6b', color: 'white', padding: '10px 32px', display: 'inline-block', fontWeight: 700, fontSize: '1rem', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: '14px' }}>
{s.workshop_name}
</div>
<p style={{ fontSize: '0.82rem', color: '#555', maxWidth: '520px', margin: '0 auto', lineHeight: 1.6 }}>{s.body_text}</p>
</div>
{/* Bottom: Date + Signature + Footer */}
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: '24px' }}>
<div style={{ textAlign: 'center', minWidth: '200px' }}>
<div style={{ borderBottom: '1.5px solid #1a3a6b', paddingBottom: '4px', marginBottom: '4px' }}>
<span style={{ fontWeight: 700, color: '#1a3a6b', fontSize: '0.85rem', letterSpacing: '0.05em' }}>{dateStr}</span>
</div>
<p style={{ fontSize: '0.7rem', color: '#888', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Date of Completion</p>
</div>
<div style={{ textAlign: 'center', minWidth: '260px' }}>
<div style={{ borderBottom: '1.5px solid #1a3a6b', marginBottom: '4px', height: '70px', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
{s.signature_image_url && (
<img src={s.signature_image_url} alt="Signature" style={{ height: '68px', width: '100%', objectFit: 'contain', display: 'block' }} />
)}
</div>
<p style={{ fontSize: '0.7rem', color: '#888', textTransform: 'uppercase', letterSpacing: '0.1em' }}>{s.signature_title}</p>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<img src={s.logo_url || BAPP_LOGO} alt="Logo" style={{ height: '48px', opacity: 0.85 }} />
<div style={{ textAlign: 'center' }}>
<p style={{ fontWeight: 700, color: '#1a7ab5', fontSize: '0.75rem', letterSpacing: '0.1em', textTransform: 'uppercase' }}>{s.footer_tagline}</p>
<p style={{ fontWeight: 700, color: '#1a3a6b', fontSize: '0.8rem', marginTop: '2px' }}>{s.website_url}</p>
</div>
<div style={{ textAlign: 'right', fontSize: '0.65rem', color: '#aaa' }}>
Certificate ID:<br />{(userName || 'user').replace(/\s+/g, '-').toLowerCase()}-{Date.now().toString(36).toUpperCase()}
</div>
</div>
</div>
</div>
</div>
);
}
```
**How to use it:**
```jsx
<CertificateView userName={user?.full_name} completedAt={certificate?.issued_at} onEditName={() => navigate('/profile')} />
```
**Print:** opens a new window with the certificate HTML + Google Fonts + print CSS, triggers `window.print()`.
**Download PDF:** `html2canvas` captures the certificate node at 2x resolution, `jsPDF` places it into a landscape A4 PDF, and `pdf.save()` downloads the file. Both libraries are in the installed packages list (`html2canvas`, `jspdf`).
---
## 3. The admin editor component
Path: `src/components/admin/CertificateManager.jsx`
Lets an admin edit the single `CertificateSettings` record with a live preview. Loads the first record (or defaults), updates fields in local state, uploads the signature image via `UploadFile`, and saves (create if no record, update if one exists).
```jsx
import { useState, useEffect, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { Award, Save, Loader2, Upload, Eye } from 'lucide-react';
const DEFAULTS = {
signature_image_url: 'https://media.base44.com/images/public/6a21e3eab9bd459578e39d35/1055dd143_KMNSIG.png',
signature_title: 'Executive Director',
certificate_title: 'Certificate of Completion',
workshop_name: 'Virtual Build & Program A Pool Workshop',
body_text: 'and has demonstrated a commitment to thoughtful aquatic facility planning, sustainable programming, and stronger community impact.',
footer_tagline: 'Stronger Programs. Safer Communities. A Better Aquatic Future.',
website_url: 'TotalAquatic.org',
logo_url: '',
};
const BAPP_LOGO = 'https://media.base44.com/images/public/6a21e3eab9bd459578e39d35/c3066d75a_TAETILOGOTRANSPARENT.png';
export default function CertificateManager() {
const [settings, setSettings] = useState(null);
const [recordId, setRecordId] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [saved, setSaved] = useState(false);
const fileRef = useRef(null);
useEffect(() => {
base44.entities.CertificateSettings.list()
.then((records) => {
if (records.length > 0) { const r = records[0]; setRecordId(r.id); setSettings({ ...DEFAULTS, ...r }); }
else setSettings({ ...DEFAULTS });
})
.catch(() => setSettings({ ...DEFAULTS }))
.finally(() => setLoading(false));
}, []);
const handleChange = (field, value) => { setSettings((s) => ({ ...s, [field]: value })); setSaved(false); };
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
handleChange('signature_image_url', file_url);
} catch (err) { console.error('Upload failed', err); }
finally { setUploading(false); if (fileRef.current) fileRef.current.value = ''; }
};
const handleSave = async () => {
setSaving(true);
try {
if (recordId) { const u = await base44.entities.CertificateSettings.update(recordId, settings); setSettings({ ...DEFAULTS, ...u }); }
else { const c = await base44.entities.CertificateSettings.create(settings); setRecordId(c.id); setSettings({ ...DEFAULTS, ...c }); }
setSaved(true); setTimeout(() => setSaved(false), 3000);
} catch (err) { console.error('Save failed', err); }
finally { setSaving(false); }
};
if (loading) return <div className="flex justify-center py-12"><div className="w-8 h-8 border-4 border-gold/30 border-t-gold rounded-full animate-spin" /></div>;
// ... renders a two-column layout: editor inputs on the left, scaled live preview on the right.
// See the full CertificateManager.jsx in this app for the complete JSX.
}
```
The full component renders all fields (signature image upload + URL, signature title, certificate title, workshop name, body text, footer tagline, website text) with a live scaled preview that mirrors `CertificateView`.
---
## 4. The backend function that issues certificates
Path: `base44/functions/issue-certificate/entry.ts`
Called by the workflow (or manually). It:
1. Takes a `video_progress_id`.
2. Resolves the user from that record's `created_by_id`.
3. Lists all `VideoProgress` records for that user, checks every module is completed and every quiz passed (≥70%).
4. Checks idempotency — skips if a certificate already exists for this user.
5. Snapshots the current `CertificateSettings` template into a new `IssuedCertificate` record.
6. Sends a completion email to the user.
```typescript
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.31';
const MODULE_VIDEO_IDS = [1, 2, 3, 4, 5, 6];
const PASS_THRESHOLD = 0.7;
Deno.serve(async (req) => {
try {
const base44 = createClientFromRequest(req);
const { video_progress_id } = await req.json().catch(() => ({}));
if (!video_progress_id) return Response.json({ error: 'Missing video_progress_id' }, { status: 400 });
const triggerRecord = await base44.asServiceRole.entities.VideoProgress.get(video_progress_id);
const userId = triggerRecord?.created_by_id;
if (!userId) return Response.json({ status: 'skipped', reason: 'No user associated with progress record' });
const allProgress = await base44.asServiceRole.entities.VideoProgress.list();
const progressRecords = allProgress.filter((r) => r.created_by_id === userId);
const progressByVideo = {};
progressRecords.forEach((r) => { progressByVideo[r.video_id] = r; });
for (const videoId of MODULE_VIDEO_IDS) {
const rec = progressByVideo[videoId];
if (!rec || !rec.completed) return Response.json({ status: 'incomplete', reason: `Module ${videoId} not completed` });
if (rec.quiz_total && rec.quiz_score !== undefined && rec.quiz_score !== null) {
if (rec.quiz_score / rec.quiz_total < PASS_THRESHOLD)
return Response.json({ status: 'failed', reason: `Module ${videoId} quiz not passed` });
}
}
const allCerts = await base44.asServiceRole.entities.IssuedCertificate.list();
const existing = allCerts.filter((c) => c.created_by_id === userId);
if (existing.length > 0) return Response.json({ status: 'already_issued', certificate_id: existing[0].id });
const settings = await base44.asServiceRole.entities.CertificateSettings.list();
const template = settings[0] || {};
const user = await base44.asServiceRole.entities.User.get(userId);
const userName = user?.full_name || 'Participant';
const cert = await base44.asServiceRole.entities.IssuedCertificate.create({
user_name: userName,
certificate_title: template.certificate_title || 'Certificate of Completion',
workshop_name: template.workshop_name || 'Virtual Build & Program A Pool Workshop',
body_text: template.body_text || '',
signature_title: template.signature_title || 'Executive Director',
issued_at: new Date().toISOString(),
});
if (user?.email) {
await base44.asServiceRole.integrations.Core.SendEmail({
to: user.email,
subject: 'Your Certificate of Completion — TAETI',
body: `Congratulations ${userName}!\n\nYou have successfully completed all modules of the ${template.workshop_name} and passed every module quiz.\n\nYour Certificate of Completion has been issued.`,
});
}
return Response.json({ status: 'issued', certificate_id: cert.id });
} catch (error) {
console.error('issue-certificate error:', error);
return Response.json({ error: error.message }, { status: 500 });
}
});
```
**Important:** Uses `base44.asServiceRole` because the workflow runs server-side. `MODULE_VIDEO_IDS` and `PASS_THRESHOLD` are hardcoded — change them to match your course.
---
## 5. The workflow that triggers issuance
Path: `base44/workflows/Issue Certificate on Course Completion.jsonc`
```jsonc
{
"name": "Issue Certificate on Course Completion",
"description": "When a user marks a video module complete, checks if all modules are done and quizzes passed, then issues a certificate and sends a completion email.",
"trigger": {
"config": {
"trigger_type": "entity",
"entity_name": "VideoProgress",
"events": ["create", "update"]
},
"condition": "${ .trigger.data.completed == true }"
},
"definition": {
"document": { "dsl": "1.0.0", "name": "issue_certificate_on_completion", "version": "1.0", "namespace": "base44" },
"do": [
{
"issue_certificate": {
"call": "invoke_backend_function",
"with": { "function_name": "issue-certificate", "args": { "video_progress_id": "${ .trigger.entity_id }" } },
"then": "end",
"x-base44": { "title": "Check completion and issue certificate", "description": "Verifies all modules done and quizzes passed" }
}
}
]
}
}
```
Fires on every `VideoProgress` create/update where `completed == true`. The function checks *all* modules, so it returns `incomplete` until the last module is done. Idempotency inside the function makes it safe to fire on every module.
---
## 6. Setup checklist for the new app
1. **Create the entities:** `CertificateSettings`, `IssuedCertificate`, and `VideoProgress`.
2. **Create the backend function:** `issue-certificate` — copy the code, adjust `MODULE_VIDEO_IDS` and `PASS_THRESHOLD`.
3. **Create the workflow:** `Issue Certificate on Course Completion` — point it at your progress entity and the `issue-certificate` function.
4. **Add the components:** `CertificateView` (users) and `CertificateManager` (admins).
5. **Wire up the admin panel:** Add `CertificateManager` as a tab.
6. **Wire up the user view:** Render `<CertificateView userName={user.full_name} completedAt={cert.issued_at} />` wherever users see their certificate.
7. **Seed the template:** Have an admin save the initial template (or insert a `CertificateSettings` record via SDK).
8. **Install packages:** `html2canvas` and `jspdf` (both already in this app's package.json).
9. **Test end-to-end:** Complete all modules as a test user, verify the workflow fires, the `IssuedCertificate` record is created, and the email arrives. Then view the certificate, download the PDF, and print it.
---
## 7. Customization notes
- **Colors:** `#1a3a6b` (navy), `#1a7ab5` / `#00aacc` (blues). Change inline in `CertificateView` and `CertificateManager`.
- **Logo:** Replace `BAPP_LOGO`, or set `logo_url` in `CertificateSettings`.
- **Fonts:** `Playfair Display` + `Open Sans` from Google Fonts. Swap in `handlePrint`'s `<link>` and the `fontFamily` values.
- **A4 ratio:** `aspectRatio: '1.414 / 1'` = landscape A4. Print uses `@page { size: landscape; margin: 0; }`.
- **Certificate ID:** Generated client-side as `name-timestamp`. Move to the backend function if you need it stable/unique.
- **PDF download:** `html2canvas` at `scale: 2` for high resolution, `jsPDF` landscape A4. Adjust `scale` for quality vs file size.