feat: add sequential progression and video watch lesson completion

Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
This commit is contained in:
Rotimi Best
2026-06-11 14:48:34 +01:00
parent c8154409f5
commit 52d9bf18c3
94 changed files with 3499 additions and 564 deletions
+2 -2
View File
@@ -52,8 +52,8 @@ export const DEFAULT_CERTIFICATE_DESIGN: CertificateDesign = {
accentColor: DEFAULT_ACCENT_COLOR,
subtitle: 'Awarded with Highest Distinction',
signatories: [
{ name: 'Course Facilitator', role: 'Facilitator' },
{ name: 'Organization Lead', role: 'Director' }
{ name: 'Course Facilitator', role: 'Facilitator', enabled: true },
{ name: 'Organization Lead', role: 'Director', enabled: true }
],
idFormat: 'N° {seq}'
};
@@ -1,4 +1,4 @@
import { escapeHtml, type TemplateRenderer } from './shared';
import { escapeHtml, renderSignatoryBlock, SIGNATURE_IMAGE_STYLES, type TemplateRenderer } from './shared';
export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
@@ -38,14 +38,8 @@ export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
</div>
<div class="stamp">Verified</div>
<div class="footer">
<div>
<div class="lbl">${escapeHtml(signatoryOne.role)}</div>
<div class="name">${escapeHtml(signatoryOne.name)}</div>
</div>
<div>
<div class="lbl">${escapeHtml(signatoryTwo.role)}</div>
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
</div>
${renderSignatoryBlock(signatoryOne, { nameClass: 'name', roleClass: 'lbl', roleFirst: true })}
${renderSignatoryBlock(signatoryTwo, { nameClass: 'name', roleClass: 'lbl', roleFirst: true })}
</div>
</div>
`;
@@ -200,6 +194,7 @@ export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
z-index: 3;
background: rgba(255, 255, 255, 0.6);
}
${SIGNATURE_IMAGE_STYLES}
`;
return { body, styles };
@@ -1,4 +1,4 @@
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
import { escapeHtml, getYear, renderSignatoryBlock, SIGNATURE_IMAGE_STYLES, type TemplateRenderer } from './shared';
export const renderClassique: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
@@ -20,19 +20,13 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
<div class="recipient">${escapeHtml(data.recipientName)}</div>
<div class="description">${escapeHtml(description)}</div>
<div class="footer">
<div class="sig">
<div class="name">${escapeHtml(signatoryOne.name)}</div>
<div class="label">${escapeHtml(signatoryOne.role)}</div>
</div>
${renderSignatoryBlock(signatoryOne, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
<div class="seal">
<div class="star">&#9733;</div>
<div class="yr">${getYear(data.date)}</div>
<div class="lbl">${escapeHtml(data.certificateId)}</div>
</div>
<div class="sig">
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
<div class="label">${escapeHtml(signatoryTwo.role)}</div>
</div>
${renderSignatoryBlock(signatoryTwo, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
</div>
</div>
`;
@@ -140,6 +134,7 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
gap: 40px;
}
.t-classique .sig {
width: 100%;
text-align: center;
border-top: 1px solid ${accent};
padding-top: 6px;
@@ -190,6 +185,7 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
letter-spacing: 0.2em;
margin-top: 2px;
}
${SIGNATURE_IMAGE_STYLES}
`;
return { body, styles };
+22 -26
View File
@@ -1,4 +1,10 @@
import { escapeHtml, type TemplateRenderer } from './shared';
import {
escapeHtml,
renderFooterMetaBlock,
renderSignatoryBlock,
SIGNATURE_IMAGE_STYLES,
type TemplateRenderer
} from './shared';
export const renderMinimal: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
@@ -8,7 +14,6 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
const body = `
<div class="cert t-minimal">
<span class="accent-bar" aria-hidden="true"></span>
<div class="top">
<span>${escapeHtml(data.orgName)}</span>
<span>${escapeHtml(data.certificateId)} &middot; ${escapeHtml(data.date)}</span>
@@ -23,22 +28,10 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
<div class="description">${escapeHtml(description)}</div>
</div>
<div class="footer">
<div>
<div class="k">${escapeHtml(signatoryOne.role)}</div>
<div class="v">${escapeHtml(signatoryOne.name)}</div>
</div>
<div>
<div class="k">${escapeHtml(signatoryTwo.role)}</div>
<div class="v">${escapeHtml(signatoryTwo.name)}</div>
</div>
<div>
<div class="k">Issued</div>
<div class="v">${escapeHtml(data.date)}</div>
</div>
<div class="ref">
<div class="k">Reference</div>
<div class="v">${escapeHtml(data.certificateId)}</div>
</div>
${renderSignatoryBlock(signatoryOne, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
${renderSignatoryBlock(signatoryTwo, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
${renderFooterMetaBlock('Issued', data.date, { labelClass: 'k', valueClass: 'v' })}
${renderFooterMetaBlock('Reference', data.certificateId, { wrapperClass: 'ref', labelClass: 'k', valueClass: 'v' })}
</div>
</div>
`;
@@ -52,14 +45,7 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
display: flex;
flex-direction: column;
position: relative;
}
.t-minimal .accent-bar {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 100%;
background: ${accent};
border-left: 12px solid ${accent};
}
.t-minimal .top {
display: flex;
@@ -145,11 +131,20 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
.t-minimal .footer {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
align-items: end;
gap: 30px;
padding-top: 20px;
border-top: 1px solid #0a0a0a;
font-family: 'JetBrains Mono', monospace;
}
.t-minimal .footer .sig-content,
.t-minimal .footer .sig-text {
align-items: flex-start;
text-align: left;
}
.t-minimal .footer .sig-img-slot {
justify-content: flex-start;
}
.t-minimal .footer .k {
font-size: 9px;
letter-spacing: 0.25em;
@@ -165,6 +160,7 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
}
.t-minimal .footer .ref .k { color: ${accent}; }
.t-minimal .footer .ref .v { color: ${accent}; }
${SIGNATURE_IMAGE_STYLES}
`;
return { body, styles };
+12 -9
View File
@@ -1,4 +1,11 @@
import { escapeHtml, getYear, shadeColor, type TemplateRenderer } from './shared';
import {
escapeHtml,
getYear,
renderSignatoryBlock,
shadeColor,
SIGNATURE_IMAGE_STYLES,
type TemplateRenderer
} from './shared';
export const renderNoir: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
@@ -27,18 +34,12 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
<div class="recipient">${escapeHtml(data.recipientName)}</div>
<div class="description">${escapeHtml(description)}</div>
<div class="footer">
<div class="sig">
<div class="name">${escapeHtml(signatoryOne.name)}</div>
<div class="label">${escapeHtml(signatoryOne.role)}</div>
</div>
${renderSignatoryBlock(signatoryOne, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
<div class="medal">
<div class="yr">${getYear(data.date)}</div>
<div class="lbl">&#9733; AWARD &#9733;</div>
</div>
<div class="sig">
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
<div class="label">${escapeHtml(signatoryTwo.role)}</div>
</div>
${renderSignatoryBlock(signatoryTwo, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
</div>
</div>
`;
@@ -158,6 +159,7 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
gap: 40px;
}
.t-noir .sig {
width: 100%;
text-align: center;
border-top: 1px solid ${accent};
padding-top: 6px;
@@ -206,6 +208,7 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
font-size: 8px;
letter-spacing: 0.2em;
}
${SIGNATURE_IMAGE_STYLES}
`;
return { body, styles };
+21 -13
View File
@@ -1,4 +1,11 @@
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
import {
escapeHtml,
getYear,
renderFooterMetaBlock,
renderSignatoryBlock,
SIGNATURE_IMAGE_STYLES,
type TemplateRenderer
} from './shared';
export const renderPoster: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
@@ -26,18 +33,9 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
</div>
<div class="description">${escapeHtml(description)}</div>
<div class="bottom">
<div>
<div class="k">${escapeHtml(signatoryOne.role)}</div>
<div class="v">${escapeHtml(signatoryOne.name)}</div>
</div>
<div>
<div class="k">${escapeHtml(signatoryTwo.role)}</div>
<div class="v">${escapeHtml(signatoryTwo.name)}</div>
</div>
<div>
<div class="k">Issued</div>
<div class="v">${escapeHtml(data.date)}</div>
</div>
${renderSignatoryBlock(signatoryOne, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
${renderSignatoryBlock(signatoryTwo, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
${renderFooterMetaBlock('Issued', data.date, { labelClass: 'k', valueClass: 'v' })}
</div>
</div>
<div class="corner-num">${getYear(data.date)}</div>
@@ -162,11 +160,20 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
margin-top: auto;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: end;
gap: 24px;
border-top: 2px solid #1a1a1a;
padding-top: 18px;
font-family: 'JetBrains Mono', monospace;
}
.t-poster .bottom .sig-content,
.t-poster .bottom .sig-text {
align-items: flex-start;
text-align: left;
}
.t-poster .bottom .sig-img-slot {
justify-content: flex-start;
}
.t-poster .bottom .k {
font-size: 9px;
letter-spacing: 0.2em;
@@ -192,6 +199,7 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
z-index: 3;
line-height: 0.8;
}
${SIGNATURE_IMAGE_STYLES}
`;
return { body, styles };
+91 -3
View File
@@ -1,4 +1,4 @@
import type { CertificateDesign, CertificateRenderData } from '../types';
import type { CertificateDesign, CertificateRenderData, CertificateSignatory } from '../types';
export function escapeHtml(input: unknown): string {
return String(input ?? '').replace(/[&<>"']/g, (char) => {
@@ -53,12 +53,100 @@ export interface TemplateRenderOutput {
export type TemplateRenderer = (args: TemplateRenderArgs) => TemplateRenderOutput;
export interface SignatoryBlockOptions {
wrapperClass?: string;
nameClass: string;
roleClass: string;
roleFirst?: boolean;
}
export function renderSignatoryBlock(sig: CertificateSignatory, options: SignatoryBlockOptions): string {
if (sig.enabled === false) return '';
const sigImg = sig.signatureUrl ? `<img class="sig-img" src="${escapeHtml(sig.signatureUrl)}" alt="" />` : '';
const nameEl = `<div class="${options.nameClass}">${escapeHtml(sig.name)}</div>`;
const roleEl = `<div class="${options.roleClass}">${escapeHtml(sig.role)}</div>`;
const content = options.roleFirst ? `${roleEl}${nameEl}` : `${nameEl}${roleEl}`;
const wrapperClass = options.wrapperClass ? ` class="${options.wrapperClass}"` : '';
return `
<div${wrapperClass}>
<div class="sig-content">
<div class="sig-img-slot">${sigImg}</div>
<div class="sig-text">${content}</div>
</div>
</div>
`;
}
export interface FooterMetaBlockOptions {
wrapperClass?: string;
labelClass: string;
valueClass: string;
}
export function renderFooterMetaBlock(label: string, value: string, options: FooterMetaBlockOptions): string {
const wrapperClass = options.wrapperClass ? ` class="${options.wrapperClass}"` : '';
return `
<div${wrapperClass}>
<div class="sig-content">
<div class="sig-img-slot"></div>
<div class="sig-text">
<div class="${options.labelClass}">${escapeHtml(label)}</div>
<div class="${options.valueClass}">${escapeHtml(value)}</div>
</div>
</div>
</div>
`;
}
export const SIGNATURE_IMAGE_STYLES = `
.sig-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.sig-img-slot {
display: flex;
align-items: flex-end;
justify-content: center;
width: 100%;
height: 48px;
min-height: 48px;
margin-bottom: 6px;
flex-shrink: 0;
}
.sig-text {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
text-align: center;
}
.sig-img {
display: block;
max-height: 48px;
max-width: 100%;
width: auto;
height: auto;
object-fit: contain;
background: transparent;
}
`;
export const FONTS_LINK_HREF =
'https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Cinzel:wght@400;500;600;700&family=DM+Mono:wght@400;500&family=Bodoni+Moda:ital,wght@0,400;0,700;1,400&family=Archivo+Black&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=Space+Grotesk:wght@400;500;700&display=swap';
export const BASE_STYLES = `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1100px; height: 780px; background: transparent; }
body { -webkit-font-smoothing: antialiased; }
.cert { width: 1100px; height: 780px; position: relative; overflow: hidden; }
body {
-webkit-font-smoothing: antialiased;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cert { width: 1100px; height: 780px; position: relative; overflow: hidden; box-shadow: inset 0 0 0 3px rgba(0,0,0,0.12); }
`;
+2
View File
@@ -4,6 +4,8 @@ export type CertificateTemplateId = (typeof CERTIFICATE_TEMPLATE_IDS)[number];
export interface CertificateSignatory {
name: string;
role: string;
enabled: boolean;
signatureUrl?: string;
}
export interface CertificateDesign {