For Builders

Certificate Replication Guide

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.