refactor: Extract certificate template to separate file with template selection
- Move HTML template to src/templates/certificates/AIcertifikat.html
- Add template rendering with {{variable}} and {{#if}} syntax
- Add CERTIFICATE_TEMPLATES registry for multiple templates
- Accept templateName parameter in generateCertificate
- Add hasCertificate check to prevent duplicate certificates
- Add hasCertificate field to getCombinedTableData query
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -269,9 +269,10 @@ export const getStats = async (req, res, next) => {
|
|||||||
export const generateCertificate = async (req, res, next) => {
|
export const generateCertificate = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { registraciaId } = req.params;
|
const { registraciaId } = req.params;
|
||||||
|
const { templateName } = req.body;
|
||||||
const { generateCertificate: generateCert } = await import('../services/ai-kurzy/certificate.service.js');
|
const { generateCertificate: generateCert } = await import('../services/ai-kurzy/certificate.service.js');
|
||||||
|
|
||||||
const result = await generateCert(parseInt(registraciaId));
|
const result = await generateCert(parseInt(registraciaId), templateName || 'AIcertifikat');
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
data: result,
|
data: result,
|
||||||
|
|||||||
@@ -4,12 +4,30 @@ import fs from 'fs/promises';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { db } from '../../config/database.js';
|
import { db } from '../../config/database.js';
|
||||||
import { registracie, ucastnici, kurzy, prilohy } from '../../db/schema.js';
|
import { registracie, ucastnici, kurzy, prilohy } from '../../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { NotFoundError } from '../../utils/errors.js';
|
import { NotFoundError } from '../../utils/errors.js';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'ai-kurzy', 'certificates');
|
const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'ai-kurzy', 'certificates');
|
||||||
const ASSETS_DIR = path.join(process.cwd(), 'src', 'assets', 'certificate');
|
const ASSETS_DIR = path.join(process.cwd(), 'src', 'assets', 'certificate');
|
||||||
|
const TEMPLATES_DIR = path.join(process.cwd(), 'src', 'templates', 'certificates');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available certificate templates
|
||||||
|
*/
|
||||||
|
export const CERTIFICATE_TEMPLATES = {
|
||||||
|
AIcertifikat: {
|
||||||
|
name: 'AI Certifikát',
|
||||||
|
file: 'AIcertifikat.html',
|
||||||
|
description: 'Osvedčenie o absolvovaní AI kurzu',
|
||||||
|
},
|
||||||
|
// Add more templates here in the future
|
||||||
|
// participation: {
|
||||||
|
// name: 'Potvrdenie o účasti',
|
||||||
|
// file: 'participation.html',
|
||||||
|
// description: 'Potvrdenie o účasti na školení',
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date to Slovak format (DD.MM.YYYY)
|
* Format date to Slovak format (DD.MM.YYYY)
|
||||||
@@ -47,246 +65,88 @@ const loadAssets = async () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
background: `data:image/jpeg;base64,${background.toString('base64')}`,
|
backgroundImage: `data:image/jpeg;base64,${background.toString('base64')}`,
|
||||||
signatureGablasova: `data:image/png;base64,${signatureGablasova.toString('base64')}`,
|
signatureGablasova: `data:image/png;base64,${signatureGablasova.toString('base64')}`,
|
||||||
signatureZdarilek: `data:image/png;base64,${signatureZdarilek.toString('base64')}`,
|
signatureZdarilek: `data:image/png;base64,${signatureZdarilek.toString('base64')}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate HTML template for certificate
|
* Load HTML template from file
|
||||||
*/
|
*/
|
||||||
const generateCertificateHtml = (data, assets) => {
|
const loadTemplate = async (templateName) => {
|
||||||
const { participantName, courseTitle, courseModules, dateFrom, dateTo, issueDate, certificateId } = data;
|
const template = CERTIFICATE_TEMPLATES[templateName];
|
||||||
|
if (!template) {
|
||||||
// Format date range
|
throw new NotFoundError(`Šablóna "${templateName}" neexistuje`);
|
||||||
let dateRangeText = '';
|
|
||||||
if (dateFrom && dateTo) {
|
|
||||||
dateRangeText = `${formatDate(dateFrom)} - ${formatDate(dateTo)}`;
|
|
||||||
} else if (dateTo) {
|
|
||||||
dateRangeText = formatDate(dateTo);
|
|
||||||
} else if (dateFrom) {
|
|
||||||
dateRangeText = formatDate(dateFrom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
const templatePath = path.join(TEMPLATES_DIR, template.file);
|
||||||
<!DOCTYPE html>
|
try {
|
||||||
<html lang="sk">
|
return await fs.readFile(templatePath, 'utf-8');
|
||||||
<head>
|
} catch (error) {
|
||||||
<meta charset="UTF-8">
|
throw new NotFoundError(`Súbor šablóny "${template.file}" nenájdený`);
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
}
|
||||||
<title>Osvedčenie - ${participantName}</title>
|
};
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Open+Sans:wght@300;400;500;600&display=swap');
|
|
||||||
|
|
||||||
* {
|
/**
|
||||||
margin: 0;
|
* Replace template placeholders with actual values
|
||||||
padding: 0;
|
* Supports {{variable}} and {{#if variable}}...{{/if}} syntax
|
||||||
box-sizing: border-box;
|
*/
|
||||||
}
|
const renderTemplate = (template, data) => {
|
||||||
|
let html = template;
|
||||||
|
|
||||||
@page {
|
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||||
size: A4 landscape;
|
html = html.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, variable, content) => {
|
||||||
margin: 0;
|
return data[variable] ? content : '';
|
||||||
}
|
});
|
||||||
|
|
||||||
html, body {
|
// Replace simple placeholders {{variable}}
|
||||||
width: 297mm;
|
html = html.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||||
height: 210mm;
|
return data[variable] !== undefined ? data[variable] : '';
|
||||||
overflow: hidden;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
return html;
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
};
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.certificate {
|
/**
|
||||||
width: 297mm;
|
* Get list of available templates
|
||||||
height: 210mm;
|
*/
|
||||||
position: relative;
|
export const getAvailableTemplates = () => {
|
||||||
background-image: url('${assets.background}');
|
return Object.entries(CERTIFICATE_TEMPLATES).map(([key, value]) => ({
|
||||||
background-size: cover;
|
id: key,
|
||||||
background-position: center;
|
name: value.name,
|
||||||
background-repeat: no-repeat;
|
description: value.description,
|
||||||
}
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
/* Main content area - positioned in the white space */
|
/**
|
||||||
.content {
|
* Check if certificate already exists for registration
|
||||||
position: absolute;
|
*/
|
||||||
top: 28mm;
|
export const hasCertificate = async (registraciaId) => {
|
||||||
left: 0;
|
const [existing] = await db
|
||||||
right: 0;
|
.select({ id: prilohy.id })
|
||||||
text-align: center;
|
.from(prilohy)
|
||||||
}
|
.where(and(
|
||||||
|
eq(prilohy.registraciaId, registraciaId),
|
||||||
|
eq(prilohy.typPrilohy, 'certifikat')
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
.main-title {
|
return !!existing;
|
||||||
font-family: 'Playfair Display', Georgia, serif;
|
|
||||||
font-size: 52pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 14px;
|
|
||||||
color: #1a1a1a;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 2mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #333;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 18mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-section {
|
|
||||||
margin-top: 5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-title {
|
|
||||||
font-family: 'Playfair Display', Georgia, serif;
|
|
||||||
font-size: 32pt;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 3mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-modules {
|
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
|
||||||
font-size: 13pt;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 2mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-dates {
|
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
|
||||||
font-size: 12pt;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-name {
|
|
||||||
font-family: 'Playfair Display', Georgia, serif;
|
|
||||||
font-size: 36pt;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-top: 14mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer section with signatures */
|
|
||||||
.footer-section {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 28mm;
|
|
||||||
left: 25mm;
|
|
||||||
right: 25mm;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-location {
|
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
|
||||||
font-size: 11pt;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signatures {
|
|
||||||
display: flex;
|
|
||||||
gap: 25mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature {
|
|
||||||
text-align: center;
|
|
||||||
min-width: 55mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-image {
|
|
||||||
height: 18mm;
|
|
||||||
margin-bottom: 1mm;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-line {
|
|
||||||
width: 55mm;
|
|
||||||
height: 0.4mm;
|
|
||||||
background: #333;
|
|
||||||
margin: 0 auto 2mm auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-name {
|
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-title {
|
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
|
||||||
font-size: 9pt;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.certificate-id {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20mm;
|
|
||||||
right: 25mm;
|
|
||||||
font-family: 'Open Sans', Arial, sans-serif;
|
|
||||||
font-size: 7pt;
|
|
||||||
color: #999;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="certificate">
|
|
||||||
<div class="content">
|
|
||||||
<h1 class="main-title">O S V E D Č E N I E</h1>
|
|
||||||
<p class="subtitle">o absolvovaní kurzu</p>
|
|
||||||
|
|
||||||
<div class="course-section">
|
|
||||||
<h2 class="course-title">${courseTitle}</h2>
|
|
||||||
${courseModules ? `<p class="course-modules">${courseModules}</p>` : ''}
|
|
||||||
${dateRangeText ? `<p class="course-dates">${dateRangeText}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="participant-name">${participantName}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-section">
|
|
||||||
<div class="date-location">
|
|
||||||
V Bratislave ${formatDate(issueDate)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="signatures">
|
|
||||||
<div class="signature">
|
|
||||||
<img src="${assets.signatureZdarilek}" alt="Podpis" class="signature-image" />
|
|
||||||
<div class="signature-line"></div>
|
|
||||||
<p class="signature-name">Mgr. Tomáš Zdarílek</p>
|
|
||||||
<p class="signature-title">lektor</p>
|
|
||||||
</div>
|
|
||||||
<div class="signature">
|
|
||||||
<img src="${assets.signatureGablasova}" alt="Podpis" class="signature-image" />
|
|
||||||
<div class="signature-line"></div>
|
|
||||||
<p class="signature-name">Ing. Jana Gablasová</p>
|
|
||||||
<p class="signature-title">konateľ</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="certificate-id">ID: ${certificateId}</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate certificate PDF for a registration
|
* Generate certificate PDF for a registration
|
||||||
|
* @param {number} registraciaId - Registration ID
|
||||||
|
* @param {string} templateName - Template name (default: 'AIcertifikat')
|
||||||
*/
|
*/
|
||||||
export const generateCertificate = async (registraciaId) => {
|
export const generateCertificate = async (registraciaId, templateName = 'AIcertifikat') => {
|
||||||
|
// Check if certificate already exists
|
||||||
|
const exists = await hasCertificate(registraciaId);
|
||||||
|
if (exists) {
|
||||||
|
throw new Error('Certifikát pre túto registráciu už existuje');
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch registration with participant and course data
|
// Fetch registration with participant and course data
|
||||||
const [registration] = await db
|
const [registration] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -333,8 +193,11 @@ export const generateCertificate = async (registraciaId) => {
|
|||||||
throw new NotFoundError('Kurz nenájdený');
|
throw new NotFoundError('Kurz nenájdený');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load assets
|
// Load template and assets
|
||||||
const assets = await loadAssets();
|
const [templateHtml, assets] = await Promise.all([
|
||||||
|
loadTemplate(templateName),
|
||||||
|
loadAssets(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Generate certificate data
|
// Generate certificate data
|
||||||
const certificateId = generateCertificateId(registraciaId);
|
const certificateId = generateCertificateId(registraciaId);
|
||||||
@@ -344,18 +207,29 @@ export const generateCertificate = async (registraciaId) => {
|
|||||||
|
|
||||||
const issueDate = registration.datumDo || registration.datumOd || new Date();
|
const issueDate = registration.datumDo || registration.datumOd || new Date();
|
||||||
|
|
||||||
const certificateData = {
|
// Format date range
|
||||||
|
let dateRange = '';
|
||||||
|
if (registration.datumOd && registration.datumDo) {
|
||||||
|
dateRange = `${formatDate(registration.datumOd)} - ${formatDate(registration.datumDo)}`;
|
||||||
|
} else if (registration.datumDo) {
|
||||||
|
dateRange = formatDate(registration.datumDo);
|
||||||
|
} else if (registration.datumOd) {
|
||||||
|
dateRange = formatDate(registration.datumOd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare template data
|
||||||
|
const templateData = {
|
||||||
participantName,
|
participantName,
|
||||||
courseTitle: course.nazov,
|
courseTitle: course.nazov,
|
||||||
courseModules: course.popis || null,
|
courseModules: course.popis || '',
|
||||||
dateFrom: registration.datumOd,
|
dateRange,
|
||||||
dateTo: registration.datumDo,
|
issueDate: formatDate(issueDate),
|
||||||
issueDate,
|
|
||||||
certificateId,
|
certificateId,
|
||||||
|
...assets,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate HTML
|
// Render template with data
|
||||||
const html = generateCertificateHtml(certificateData, assets);
|
const html = renderTemplate(templateHtml, templateData);
|
||||||
|
|
||||||
// Ensure upload directory exists
|
// Ensure upload directory exists
|
||||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
@@ -388,7 +262,7 @@ export const generateCertificate = async (registraciaId) => {
|
|||||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Certificate generated: ${fileName}`);
|
logger.info(`Certificate generated: ${fileName} (template: ${templateName})`);
|
||||||
} finally {
|
} finally {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
@@ -408,7 +282,7 @@ export const generateCertificate = async (registraciaId) => {
|
|||||||
cestaKSuboru: filePath,
|
cestaKSuboru: filePath,
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
velkostSuboru: stats.size,
|
velkostSuboru: stats.size,
|
||||||
popis: `Certifikát vygenerovaný ${formatDate(new Date())} - ${certificateId}`,
|
popis: `Certifikát vygenerovaný ${formatDate(new Date())} - ${certificateId} (šablóna: ${templateName})`,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -419,6 +293,7 @@ export const generateCertificate = async (registraciaId) => {
|
|||||||
certificateId,
|
certificateId,
|
||||||
participantName,
|
participantName,
|
||||||
courseTitle: course.nazov,
|
courseTitle: course.nazov,
|
||||||
|
templateUsed: templateName,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const getCombinedTableData = async () => {
|
|||||||
poznamka: registracie.poznamka,
|
poznamka: registracie.poznamka,
|
||||||
createdAt: registracie.createdAt,
|
createdAt: registracie.createdAt,
|
||||||
dokumentyCount: sql`(SELECT COUNT(*) FROM prilohy WHERE registracia_id = ${registracie.id})::int`,
|
dokumentyCount: sql`(SELECT COUNT(*) FROM prilohy WHERE registracia_id = ${registracie.id})::int`,
|
||||||
|
hasCertificate: sql`(SELECT COUNT(*) > 0 FROM prilohy WHERE registracia_id = ${registracie.id} AND typ_prilohy = 'certifikat')::boolean`,
|
||||||
})
|
})
|
||||||
.from(registracie)
|
.from(registracie)
|
||||||
.innerJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id))
|
.innerJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id))
|
||||||
|
|||||||
214
src/templates/certificates/AIcertifikat.html
Normal file
214
src/templates/certificates/AIcertifikat.html
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sk">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Osvedčenie - {{participantName}}</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Open+Sans:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 297mm;
|
||||||
|
height: 210mm;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate {
|
||||||
|
width: 297mm;
|
||||||
|
height: 210mm;
|
||||||
|
position: relative;
|
||||||
|
background-image: url('{{backgroundImage}}');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area - positioned in the white space */
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
top: 28mm;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
font-size: 52pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #333;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 18mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-section {
|
||||||
|
margin-top: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
font-size: 32pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-modules {
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-dates {
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
font-size: 12pt;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-name {
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
font-size: 36pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-top: 14mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer section with signatures */
|
||||||
|
.footer-section {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 28mm;
|
||||||
|
left: 25mm;
|
||||||
|
right: 25mm;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-location {
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signatures {
|
||||||
|
display: flex;
|
||||||
|
gap: 25mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 55mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-image {
|
||||||
|
height: 18mm;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-line {
|
||||||
|
width: 55mm;
|
||||||
|
height: 0.4mm;
|
||||||
|
background: #333;
|
||||||
|
margin: 0 auto 2mm auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-name {
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-title {
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-id {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20mm;
|
||||||
|
right: 25mm;
|
||||||
|
font-family: 'Open Sans', Arial, sans-serif;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #999;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate">
|
||||||
|
<div class="content">
|
||||||
|
<h1 class="main-title">O S V E D Č E N I E</h1>
|
||||||
|
<p class="subtitle">o absolvovaní kurzu</p>
|
||||||
|
|
||||||
|
<div class="course-section">
|
||||||
|
<h2 class="course-title">{{courseTitle}}</h2>
|
||||||
|
{{#if courseModules}}
|
||||||
|
<p class="course-modules">{{courseModules}}</p>
|
||||||
|
{{/if}}
|
||||||
|
{{#if dateRange}}
|
||||||
|
<p class="course-dates">{{dateRange}}</p>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="participant-name">{{participantName}}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-section">
|
||||||
|
<div class="date-location">
|
||||||
|
V Bratislave {{issueDate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="signature">
|
||||||
|
<img src="{{signatureZdarilek}}" alt="Podpis" class="signature-image" />
|
||||||
|
<div class="signature-line"></div>
|
||||||
|
<p class="signature-name">Mgr. Tomáš Zdarílek</p>
|
||||||
|
<p class="signature-title">lektor</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature">
|
||||||
|
<img src="{{signatureGablasova}}" alt="Podpis" class="signature-image" />
|
||||||
|
<div class="signature-line"></div>
|
||||||
|
<p class="signature-name">Ing. Jana Gablasová</p>
|
||||||
|
<p class="signature-title">konateľ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="certificate-id">ID: {{certificateId}}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user