feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@cio/certificates",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc && tsc-alias",
"dev": "tsc --watch",
"format": "prettier --write ."
},
"dependencies": {
"@cio/tsconfig": "workspace:*"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.5.2"
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { CertificateDesign, CertificateTemplateId, CertificateTemplateMeta } from './types';
export const ACCENT_COLORS = ['#7a1f1f', '#1e3a8a', '#ff4500', '#d4af37', '#0a0a0a', '#065f46'] as const;
export type AccentColor = (typeof ACCENT_COLORS)[number];
export const DEFAULT_ACCENT_COLOR: string = ACCENT_COLORS[0];
export const CERTIFICATE_TEMPLATES: CertificateTemplateMeta[] = [
{
id: 'classique',
label: 'Classique',
description: 'Vintage engraved with double-rule border and seal.'
},
{
id: 'brutalist',
label: 'Brutalist',
description: 'Raw editorial grid with oversized typography and stamp.'
},
{
id: 'noir',
label: 'Noir',
description: 'Dark editorial layout with gilt accents and medal.'
},
{
id: 'poster',
label: 'Poster',
description: 'Maximalist editorial with colour blobs and rotated tag.'
},
{
id: 'minimal',
label: 'Minimal',
description: 'Refined Swiss layout with thin rules and pure typography.'
}
];
/**
* Maps the legacy 6 theme ids onto the new 5 templates.
* Existing courses keep rendering until they are re-saved.
*/
export const LEGACY_THEME_MAP: Record<string, CertificateTemplateId> = {
professional: 'classique',
plain: 'minimal',
purpleProfessionalBadge: 'noir',
blueProfessionalBadge: 'noir',
purpleBadgePattern: 'poster',
blueBadgePattern: 'poster'
};
export const DEFAULT_CERTIFICATE_DESIGN: CertificateDesign = {
templateId: 'classique',
accentColor: DEFAULT_ACCENT_COLOR,
subtitle: 'Awarded with Highest Distinction',
signatories: [
{ name: 'Course Facilitator', role: 'Facilitator' },
{ name: 'Organization Lead', role: 'Director' }
],
idFormat: 'N° {seq}'
};
+20
View File
@@ -0,0 +1,20 @@
export {
CERTIFICATE_TEMPLATE_IDS,
type CertificateDesign,
type CertificateRenderData,
type CertificateRenderResult,
type CertificateSignatory,
type CertificateTemplateId,
type CertificateTemplateMeta
} from './types';
export {
ACCENT_COLORS,
CERTIFICATE_TEMPLATES,
DEFAULT_ACCENT_COLOR,
DEFAULT_CERTIFICATE_DESIGN,
LEGACY_THEME_MAP,
type AccentColor
} from './constants';
export { renderCertificate, renderCertificateDocument, resolveTemplateId } from './render';
+65
View File
@@ -0,0 +1,65 @@
import { LEGACY_THEME_MAP } from './constants';
import type { CertificateDesign, CertificateRenderData, CertificateRenderResult, CertificateTemplateId } from './types';
import { CERTIFICATE_TEMPLATE_IDS } from './types';
import { renderBrutalist } from './templates/brutalist';
import { renderClassique } from './templates/classique';
import { renderMinimal } from './templates/minimal';
import { renderNoir } from './templates/noir';
import { renderPoster } from './templates/poster';
import { BASE_STYLES, FONTS_LINK_HREF, type TemplateRenderer } from './templates/shared';
const RENDERERS: Record<CertificateTemplateId, TemplateRenderer> = {
classique: renderClassique,
brutalist: renderBrutalist,
noir: renderNoir,
poster: renderPoster,
minimal: renderMinimal
};
export function resolveTemplateId(value: string | undefined | null): CertificateTemplateId {
if (!value) return 'classique';
if (CERTIFICATE_TEMPLATE_IDS.includes(value as CertificateTemplateId)) {
return value as CertificateTemplateId;
}
if (value in LEGACY_THEME_MAP) {
return LEGACY_THEME_MAP[value]!;
}
return 'classique';
}
export function renderCertificate(design: CertificateDesign, data: CertificateRenderData): CertificateRenderResult {
const templateId = resolveTemplateId(design.templateId);
const renderer = RENDERERS[templateId];
const { body, styles } = renderer({ design: { ...design, templateId }, data });
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1100,initial-scale=1.0">
<title>Certificate</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="${FONTS_LINK_HREF}">
</head>
<body>
${body}
</body>
</html>`;
const fullStyles = BASE_STYLES + '\n' + styles;
return { html, styles: fullStyles };
}
/**
* Returns a single-document HTML string that already includes the styles inline.
* Useful for iframe `srcdoc` and `<iframe>`-style previews where a separate
* `addStyleTag` is not available.
*/
export function renderCertificateDocument(design: CertificateDesign, data: CertificateRenderData): string {
const { html, styles } = renderCertificate(design, data);
return html.replace('</head>', `<style>${styles}</style></head>`);
}
@@ -0,0 +1,206 @@
import { escapeHtml, type TemplateRenderer } from './shared';
export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
const subtitle = design.subtitle ?? '';
const description = design.descriptionOverride || data.courseDescription;
const [signatoryOne, signatoryTwo] = design.signatories;
const idDigits = data.certificateId.match(/\d+/)?.[0] ?? '00';
const body = `
<div class="cert t-brutalist">
<div class="grid-bg"></div>
<div class="header">
<div>${escapeHtml(data.orgName)}</div>
<div class="blk">${escapeHtml(data.certificateId)}</div>
</div>
<div class="title-block">
<div class="num">&#8470;<span>${escapeHtml(idDigits)}</span></div>
</div>
<div class="meta-row">
<div>
<div class="k">Date</div>
<div class="v">${escapeHtml(data.date)}</div>
</div>
<div>
<div class="k">Award</div>
<div class="v">${escapeHtml(data.courseName)}</div>
</div>
<div>
<div class="k">Class</div>
<div class="v">${escapeHtml(subtitle)}</div>
</div>
</div>
<div class="recipient-block">
<div class="lbl">Awarded To</div>
<div class="recipient">${escapeHtml(data.recipientName)}</div>
<div class="description">${escapeHtml(description)}</div>
</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>
</div>
</div>
`;
const styles = `
.t-brutalist {
background: #f0ede4;
color: #000;
font-family: 'Archivo Black', sans-serif;
padding: 0;
}
.t-brutalist .grid-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(0,0,0,0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.06) 1px, transparent 1px);
background-size: 40px 40px;
}
.t-brutalist .header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 40px 50px 0;
position: relative;
z-index: 2;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.t-brutalist .header .blk {
background: ${accent};
color: #fff;
padding: 6px 10px;
}
.t-brutalist .title-block {
padding: 40px 50px 0;
position: relative;
z-index: 2;
}
.t-brutalist .num {
font-family: 'JetBrains Mono', monospace;
font-size: 120px;
font-weight: 700;
line-height: 0.85;
color: ${accent};
letter-spacing: -0.04em;
}
.t-brutalist .num span { color: #000; }
.t-brutalist .meta-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-top: 4px solid ${accent};
border-bottom: 2px solid #000;
margin: 30px 50px 0;
position: relative;
z-index: 2;
}
.t-brutalist .meta-row > div {
padding: 14px 18px;
border-right: 2px solid #000;
font-family: 'JetBrains Mono', monospace;
}
.t-brutalist .meta-row > div:last-child { border-right: none; }
.t-brutalist .meta-row .k {
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #666;
margin-bottom: 4px;
}
.t-brutalist .meta-row .v {
font-size: 18px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.t-brutalist .recipient-block {
padding: 40px 50px;
position: relative;
z-index: 2;
}
.t-brutalist .recipient-block .lbl {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.22em;
color: ${accent};
text-transform: uppercase;
margin-bottom: 8px;
}
.t-brutalist .recipient {
font-size: 88px;
line-height: 0.95;
letter-spacing: -0.03em;
text-transform: uppercase;
border-left: 6px solid ${accent};
padding-left: 18px;
margin-left: -24px;
}
.t-brutalist .description {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 400;
line-height: 1.5;
margin-top: 20px;
text-transform: none;
max-width: 780px;
color: #333;
letter-spacing: 0.02em;
}
.t-brutalist .footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 2px solid #000;
background: #fff;
}
.t-brutalist .footer > div {
padding: 18px 50px;
font-family: 'JetBrains Mono', monospace;
}
.t-brutalist .footer > div:first-child { border-right: 2px solid #000; }
.t-brutalist .footer .lbl {
font-size: 9px;
letter-spacing: 0.22em;
color: #666;
text-transform: uppercase;
margin-bottom: 4px;
}
.t-brutalist .footer .name {
font-family: 'Archivo Black', sans-serif;
font-size: 22px;
text-transform: uppercase;
letter-spacing: -0.01em;
}
.t-brutalist .stamp {
position: absolute;
top: 50%;
right: 50px;
transform: translateY(-50%) rotate(-12deg);
border: 3px solid ${accent};
color: ${accent};
padding: 10px 20px;
font-family: 'Archivo Black', sans-serif;
font-size: 18px;
text-transform: uppercase;
letter-spacing: 0.1em;
z-index: 3;
background: rgba(255, 255, 255, 0.6);
}
`;
return { body, styles };
};
@@ -0,0 +1,196 @@
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
export const renderClassique: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
const subtitle = design.subtitle ?? '';
const description = design.descriptionOverride || data.courseDescription;
const [signatoryOne, signatoryTwo] = design.signatories;
const body = `
<div class="cert t-classique">
<div class="corner tl"></div>
<div class="corner tr"></div>
<div class="corner bl"></div>
<div class="corner br"></div>
<div class="top-tag">${escapeHtml(data.orgName)}</div>
<div class="ornament">&#10086;</div>
<div class="title">${escapeHtml(data.courseName)}</div>
<div class="subtitle">${escapeHtml(subtitle)}</div>
<div class="presented">&mdash; this is to certify that &mdash;</div>
<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>
<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>
</div>
</div>
`;
const styles = `
.t-classique {
background: #faf6ec;
color: #2a1810;
padding: 55px;
font-family: 'Cormorant Garamond', serif;
}
.t-classique::before {
content: '';
position: absolute;
inset: 30px;
border: 2px double ${accent};
pointer-events: none;
}
.t-classique::after {
content: '';
position: absolute;
inset: 42px;
border: 1px solid ${accent};
pointer-events: none;
}
.t-classique .corner {
position: absolute;
width: 80px;
height: 80px;
border: 1px solid ${accent};
pointer-events: none;
}
.t-classique .corner.tl { top: 55px; left: 55px; border-right: none; border-bottom: none; }
.t-classique .corner.tr { top: 55px; right: 55px; border-left: none; border-bottom: none; }
.t-classique .corner.bl { bottom: 55px; left: 55px; border-right: none; border-top: none; }
.t-classique .corner.br { bottom: 55px; right: 55px; border-left: none; border-top: none; }
.t-classique .ornament {
font-family: 'Cinzel', serif;
text-align: center;
font-size: 24px;
color: ${accent};
margin: 8px 0;
letter-spacing: 0.5em;
}
.t-classique .top-tag {
text-align: center;
font-family: 'Cinzel', serif;
font-size: 13px;
letter-spacing: 0.6em;
color: ${accent};
margin-top: 60px;
text-transform: uppercase;
}
.t-classique .title {
text-align: center;
font-family: 'Bodoni Moda', serif;
font-size: 78px;
font-weight: 400;
font-style: italic;
margin: 8px 0 4px;
color: #2a1810;
}
.t-classique .subtitle {
text-align: center;
font-family: 'Cinzel', serif;
font-size: 14px;
letter-spacing: 0.45em;
color: ${accent};
margin-bottom: 36px;
text-transform: uppercase;
}
.t-classique .presented {
text-align: center;
font-style: italic;
font-size: 18px;
color: #5a3a25;
margin-bottom: 8px;
}
.t-classique .recipient {
text-align: center;
font-family: 'Bodoni Moda', serif;
font-size: 64px;
font-weight: 400;
margin: 6px 120px 12px;
border-bottom: 2px solid ${accent};
padding-bottom: 14px;
line-height: 1.05;
}
.t-classique .description {
text-align: center;
font-size: 18px;
font-style: italic;
color: #3a2515;
margin: 20px 110px 0;
line-height: 1.6;
}
.t-classique .footer {
position: absolute;
bottom: 90px;
left: 55px;
right: 55px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: end;
gap: 40px;
}
.t-classique .sig {
text-align: center;
border-top: 1px solid ${accent};
padding-top: 6px;
}
.t-classique .sig .name {
font-family: 'Bodoni Moda', serif;
font-size: 20px;
font-style: italic;
}
.t-classique .sig .label {
font-family: 'Cinzel', serif;
font-size: 10px;
letter-spacing: 0.3em;
color: ${accent};
text-transform: uppercase;
margin-top: 2px;
}
.t-classique .seal {
width: 120px;
height: 120px;
border: 2px solid ${accent};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: ${accent};
background: radial-gradient(circle, #faf6ec 60%, ${accent}1a);
position: relative;
}
.t-classique .seal::before {
content: '';
position: absolute;
inset: 6px;
border: 1px dashed ${accent};
border-radius: 50%;
}
.t-classique .seal .star { font-size: 20px; }
.t-classique .seal .yr {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 600;
margin-top: 2px;
}
.t-classique .seal .lbl {
font-family: 'Cinzel', serif;
font-size: 8px;
letter-spacing: 0.2em;
margin-top: 2px;
}
`;
return { body, styles };
};
@@ -0,0 +1,171 @@
import { escapeHtml, type TemplateRenderer } from './shared';
export const renderMinimal: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
const subtitle = design.subtitle ?? '';
const description = design.descriptionOverride || data.courseDescription;
const [signatoryOne, signatoryTwo] = design.signatories;
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>
</div>
<div class="body">
<div class="small">&mdash; ${escapeHtml(subtitle)} &mdash;</div>
<div class="title">${escapeHtml(data.courseName)}</div>
<div class="recipient-row">
<div class="num">${escapeHtml(data.certificateId)}</div>
<div class="recipient">${escapeHtml(data.recipientName)}</div>
</div>
<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>
</div>
</div>
`;
const styles = `
.t-minimal {
background: #fff;
color: #0a0a0a;
padding: 80px 100px;
font-family: 'Space Grotesk', sans-serif;
display: flex;
flex-direction: column;
position: relative;
}
.t-minimal .accent-bar {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 100%;
background: ${accent};
}
.t-minimal .top {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #999;
padding-bottom: 20px;
border-bottom: 1px solid #0a0a0a;
}
.t-minimal .body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 30px 0;
}
.t-minimal .small {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: ${accent};
margin-bottom: 22px;
display: inline-flex;
align-items: center;
gap: 10px;
align-self: flex-start;
}
.t-minimal .small::before {
content: '';
width: 28px;
height: 2px;
background: ${accent};
}
.t-minimal .small::after {
content: '';
width: 28px;
height: 2px;
background: ${accent};
}
.t-minimal .title {
font-family: 'Cormorant Garamond', serif;
font-size: 68px;
font-weight: 300;
font-style: italic;
line-height: 1;
margin-bottom: 50px;
letter-spacing: -0.01em;
}
.t-minimal .recipient-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 30px;
align-items: end;
border-bottom: 2px solid ${accent};
padding-bottom: 14px;
margin-bottom: 24px;
}
.t-minimal .recipient-row .num {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: ${accent};
padding-bottom: 14px;
letter-spacing: 0.1em;
}
.t-minimal .recipient {
font-family: 'Cormorant Garamond', serif;
font-size: 88px;
font-weight: 400;
line-height: 0.95;
letter-spacing: -0.02em;
}
.t-minimal .description {
font-size: 16px;
line-height: 1.6;
color: #333;
max-width: 700px;
font-weight: 400;
}
.t-minimal .footer {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 30px;
padding-top: 20px;
border-top: 1px solid #0a0a0a;
font-family: 'JetBrains Mono', monospace;
}
.t-minimal .footer .k {
font-size: 9px;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #999;
margin-bottom: 4px;
}
.t-minimal .footer .v {
font-family: 'Cormorant Garamond', serif;
font-size: 20px;
font-weight: 500;
line-height: 1.1;
}
.t-minimal .footer .ref .k { color: ${accent}; }
.t-minimal .footer .ref .v { color: ${accent}; }
`;
return { body, styles };
};
+212
View File
@@ -0,0 +1,212 @@
import { escapeHtml, getYear, shadeColor, type TemplateRenderer } from './shared';
export const renderNoir: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
const accentDeep = shadeColor(accent, -30);
const subtitle = design.subtitle ?? '';
const description = design.descriptionOverride || data.courseDescription;
const [signatoryOne, signatoryTwo] = design.signatories;
const body = `
<div class="cert t-noir">
<div class="top">
<span>${escapeHtml(data.certificateId)}</span>
<div class="line"></div>
<span>${escapeHtml(data.orgName)}</span>
<div class="line"></div>
<span>${escapeHtml(data.date)}</span>
</div>
<div class="crest">&#10022; &#10022; &#10022;</div>
<div class="title">${escapeHtml(data.courseName)}</div>
<div class="title-line">
<div class="l"></div>
<span>${escapeHtml(subtitle)}</span>
<div class="l"></div>
</div>
<div class="presented">presented to</div>
<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>
<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>
</div>
</div>
`;
const styles = `
.t-noir {
background: #0e0e0e;
color: #f5f1e8;
padding: 55px;
font-family: 'Cormorant Garamond', serif;
background-image:
radial-gradient(circle at 30% 20%, ${accent}14, transparent 50%),
radial-gradient(circle at 70% 80%, ${accent}0c, transparent 50%);
}
.t-noir::before {
content: '';
position: absolute;
inset: 30px;
border: 1px solid ${accent}66;
pointer-events: none;
}
.t-noir::after {
content: '';
position: absolute;
inset: 36px;
border: 1px solid ${accent}33;
pointer-events: none;
}
.t-noir .top {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
font-family: 'Cinzel', serif;
font-size: 11px;
letter-spacing: 0.4em;
color: ${accent};
text-transform: uppercase;
}
.t-noir .top .line {
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, ${accent}, transparent);
margin: 0 20px;
}
.t-noir .crest {
text-align: center;
margin-top: 30px;
font-family: 'Cinzel', serif;
color: ${accent};
font-size: 32px;
letter-spacing: 0.3em;
}
.t-noir .title {
text-align: center;
font-family: 'Playfair Display', serif;
font-size: 84px;
font-weight: 400;
font-style: italic;
margin: 20px 0 4px;
color: #f5f1e8;
line-height: 1;
}
.t-noir .title-line {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
margin-bottom: 36px;
}
.t-noir .title-line .l {
width: 80px;
height: 1px;
background: ${accent};
}
.t-noir .title-line span {
font-family: 'Cinzel', serif;
font-size: 13px;
letter-spacing: 0.5em;
color: ${accent};
text-transform: uppercase;
}
.t-noir .presented {
text-align: center;
font-style: italic;
font-size: 18px;
color: #c9b88c;
margin-bottom: 6px;
}
.t-noir .recipient {
text-align: center;
font-family: 'Playfair Display', serif;
font-size: 72px;
font-weight: 400;
color: ${accent};
margin: 0 80px;
padding-bottom: 16px;
border-bottom: 1px solid ${accent}66;
line-height: 1.05;
}
.t-noir .description {
text-align: center;
font-style: italic;
font-size: 19px;
margin: 24px 110px 0;
color: #c9b88c;
line-height: 1.6;
}
.t-noir .footer {
position: absolute;
bottom: 90px;
left: 80px;
right: 80px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: end;
gap: 40px;
}
.t-noir .sig {
text-align: center;
border-top: 1px solid ${accent};
padding-top: 6px;
}
.t-noir .sig .name {
font-family: 'Playfair Display', serif;
font-size: 20px;
font-style: italic;
color: #f5f1e8;
}
.t-noir .sig .label {
font-family: 'Cinzel', serif;
font-size: 10px;
letter-spacing: 0.3em;
color: ${accent};
text-transform: uppercase;
margin-top: 2px;
}
.t-noir .medal {
width: 110px;
height: 110px;
border-radius: 50%;
background: radial-gradient(circle, ${accent} 0%, ${accentDeep} 100%);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: #0e0e0e;
box-shadow: 0 0 30px ${accent}4d;
position: relative;
}
.t-noir .medal::before {
content: '';
position: absolute;
inset: 6px;
border: 1px solid #0e0e0e;
border-radius: 50%;
}
.t-noir .medal .yr {
font-family: 'Cinzel', serif;
font-size: 20px;
font-weight: 700;
}
.t-noir .medal .lbl {
font-family: 'Cinzel', serif;
font-size: 8px;
letter-spacing: 0.2em;
}
`;
return { body, styles };
};
@@ -0,0 +1,198 @@
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
export const renderPoster: TemplateRenderer = ({ design, data }) => {
const accent = design.accentColor;
const subtitle = design.subtitle ?? '';
const description = design.descriptionOverride || data.courseDescription;
const [signatoryOne, signatoryTwo] = design.signatories;
const [firstTitleWord, ...restTitleWords] = data.courseName.split(' ');
const titleEmphasis = restTitleWords.join(' ');
const body = `
<div class="cert t-poster">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
<div class="content">
<div class="top">
<span class="pill">${escapeHtml(data.orgName)}</span>
<span>${escapeHtml(data.certificateId)} / ${escapeHtml(data.date)}</span>
</div>
<div class="title">${escapeHtml(firstTitleWord || 'Award')} <em>${escapeHtml(titleEmphasis)}</em></div>
<div class="of">${escapeHtml(subtitle)}</div>
<div class="recipient-area">
<div class="lbl">Awarded To</div>
<div class="recipient">${escapeHtml(data.recipientName)}</div>
</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>
</div>
</div>
<div class="corner-num">${getYear(data.date)}</div>
</div>
`;
const styles = `
.t-poster {
background: #fef2dc;
color: #1a1a1a;
padding: 0;
font-family: 'Space Grotesk', sans-serif;
overflow: hidden;
}
.t-poster .blob {
position: absolute;
width: 550px;
height: 550px;
border-radius: 50%;
filter: blur(2px);
opacity: 0.95;
}
.t-poster .blob-1 {
background: ${accent};
top: -180px;
right: -120px;
}
.t-poster .blob-2 {
background: #1e3a8a;
bottom: -200px;
left: -150px;
width: 480px;
height: 480px;
}
.t-poster .blob-3 {
background: #fbbf24;
top: 40%;
left: 55%;
width: 200px;
height: 200px;
}
.t-poster .content {
position: relative;
z-index: 2;
height: 100%;
padding: 50px 55px;
display: flex;
flex-direction: column;
}
.t-poster .top {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
margin-bottom: 30px;
}
.t-poster .top .pill {
background: ${accent};
color: #fef2dc;
padding: 5px 12px;
border-radius: 100px;
}
.t-poster .title {
font-family: 'Playfair Display', serif;
font-size: 140px;
font-weight: 900;
line-height: 0.85;
letter-spacing: -0.04em;
color: #1a1a1a;
}
.t-poster .title em {
font-style: italic;
font-weight: 400;
color: ${accent};
}
.t-poster .of {
font-family: 'Playfair Display', serif;
font-size: 62px;
font-style: italic;
font-weight: 400;
line-height: 1;
margin-top: -4px;
color: #1a1a1a;
}
.t-poster .recipient-area {
margin-top: 40px;
background: ${accent};
color: #fef2dc;
padding: 24px 30px;
align-self: flex-start;
max-width: 75%;
transform: rotate(-1deg);
box-shadow: 8px 8px 0 #1a1a1a;
}
.t-poster .recipient-area .lbl {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.22em;
color: #fef2dc;
opacity: 0.8;
text-transform: uppercase;
margin-bottom: 6px;
}
.t-poster .recipient {
font-family: 'Playfair Display', serif;
font-size: 54px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
}
.t-poster .description {
font-size: 15px;
line-height: 1.55;
margin-top: 20px;
max-width: 580px;
color: #1a1a1a;
font-weight: 500;
}
.t-poster .bottom {
margin-top: auto;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 24px;
border-top: 2px solid #1a1a1a;
padding-top: 18px;
font-family: 'JetBrains Mono', monospace;
}
.t-poster .bottom .k {
font-size: 9px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #666;
margin-bottom: 3px;
}
.t-poster .bottom .v {
font-family: 'Playfair Display', serif;
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
}
.t-poster .corner-num {
position: absolute;
bottom: 36px;
right: 50px;
font-family: 'Playfair Display', serif;
font-size: 80px;
font-weight: 900;
font-style: italic;
color: ${accent};
z-index: 3;
line-height: 0.8;
}
`;
return { body, styles };
};
@@ -0,0 +1,64 @@
import type { CertificateDesign, CertificateRenderData } from '../types';
export function escapeHtml(input: unknown): string {
return String(input ?? '').replace(/[&<>"']/g, (char) => {
switch (char) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
default:
return '&#39;';
}
});
}
export function getYear(value: string | undefined | null): string {
const match = String(value ?? '').match(/\b(19|20|21)\d{2}\b/);
if (match) return match[0];
return String(new Date().getFullYear());
}
export function shadeColor(hex: string, percent: number): string {
const normalized = hex.startsWith('#') ? hex.slice(1) : hex;
if (normalized.length !== 6) return hex;
const numeric = parseInt(normalized, 16);
if (Number.isNaN(numeric)) return hex;
const offset = Math.round((percent / 100) * 255);
const clamp = (value: number) => Math.max(0, Math.min(255, value));
const red = clamp(((numeric >> 16) & 0xff) + offset);
const green = clamp(((numeric >> 8) & 0xff) + offset);
const blue = clamp((numeric & 0xff) + offset);
const next = (red << 16) | (green << 8) | blue;
return '#' + next.toString(16).padStart(6, '0');
}
export interface TemplateRenderArgs {
design: CertificateDesign;
data: CertificateRenderData;
}
export interface TemplateRenderOutput {
body: string;
styles: string;
}
export type TemplateRenderer = (args: TemplateRenderArgs) => TemplateRenderOutput;
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; }
`;
+37
View File
@@ -0,0 +1,37 @@
export const CERTIFICATE_TEMPLATE_IDS = ['classique', 'brutalist', 'noir', 'poster', 'minimal'] as const;
export type CertificateTemplateId = (typeof CERTIFICATE_TEMPLATE_IDS)[number];
export interface CertificateSignatory {
name: string;
role: string;
}
export interface CertificateDesign {
templateId: CertificateTemplateId;
accentColor: string;
subtitle?: string;
descriptionOverride?: string;
signatories: [CertificateSignatory, CertificateSignatory];
idFormat?: string;
}
export interface CertificateRenderData {
recipientName: string;
courseName: string;
courseDescription: string;
orgName: string;
orgLogoUrl?: string;
date: string;
certificateId: string;
}
export interface CertificateRenderResult {
html: string;
styles: string;
}
export interface CertificateTemplateMeta {
id: CertificateTemplateId;
label: string;
description: string;
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "@cio/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"module": "ES2022",
"target": "ES2020",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}