Skip to content
This repository has been archived by the owner on Dec 10, 2024. It is now read-only.

Commit

Permalink
feat(certs): cert signatures and labels
Browse files Browse the repository at this point in the history
  • Loading branch information
jhdcruz committed Nov 24, 2024
1 parent 191b68b commit 779966a
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 37 deletions.
133 changes: 107 additions & 26 deletions src/app/portal/certs/_components/CertsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
IconClock,
IconFileZip,
IconMailFast,
IconSignature,
IconUpload,
} from '@tabler/icons-react';
import { createWorkerFactory, useWorker } from '@shopify/react-web-worker';
Expand All @@ -41,10 +42,10 @@ const createWorker = createWorkerFactory(

export interface CertsProps {
activity?: string;
local: boolean;
local: boolean | null;
type: string[];
template?: string;
qrPos: 'left' | 'right';
qrPos?: 'left' | 'right';
}

export interface TemplateProps {
Expand All @@ -59,18 +60,21 @@ function CertsShellComponent() {
const worker = useWorker(createWorker);

const [templates, setTemplates] = useState<TemplateProps[]>([]);
const [file, setFile] = useState<File | null>(null);
const [customTemplate, setCustomTemplate] = useState<File | null>(null);

// Signatures
const [coordinator, setCoordinator] = useState<File | null>(null);
const [vpsas, setVpsas] = useState<File | null>(null);

const form = useForm<CertsProps>({
mode: 'uncontrolled',
validateInputOnBlur: true,

initialValues: {
activity: '',
local: true,
local: null,
type: [],
template: 'coa_1.png',
qrPos: 'right',
template: '',
},
});

Expand All @@ -90,20 +94,31 @@ function CertsShellComponent() {
const supabase = createBrowserClient();
const { data: activity } = await supabase
.from('activities')
.select('id')
.select('id, date_ending')
.eq('title', values.activity!)
.limit(1)
.single();

const selectedTemplate = file
? new Blob([file], { type: file.type })
const selectedTemplate = customTemplate
? new Blob([customTemplate], { type: customTemplate.type })
: templates.find((tmpl) => tmpl.name === values.template)?.data!;

// Convert blob to data URL properly
const templateDataUrl = await blobToDataURL(selectedTemplate);

// Use web worker for local generation
if (!values.local) {
if (coordinator === null || vpsas === null) {
notifications.show({
title: 'Missing signatures',
message: 'Please upload the coordinator and VPSAS signatures',
color: 'red',
withBorder: true,
autoClose: 6000,
});
return;
}

notifications.show({
title: 'Queued certificate generation',
message: `${values.type.toLocaleString()} certificates is queued for ${values.activity}.`,
Expand All @@ -113,14 +128,30 @@ function CertsShellComponent() {
autoClose: 4000,
});

const coordinatorUrl = await blobToDataURL(new Blob([coordinator]));
const vpsasUrl = await blobToDataURL(new Blob([vpsas]));

// trigger the generate-certs trigger
await triggerGenerateCerts(
values.activity!,
templateDataUrl,
coordinatorUrl,
vpsasUrl,
values.type,
values.qrPos,
values.qrPos ?? 'right',
);
} else {
if (coordinator === null || vpsas === null) {
notifications.show({
title: 'Missing signatures',
message: 'Please upload the coordinator and VPSAS signatures',
color: 'red',
withBorder: true,
autoClose: 6000,
});
return;
}

notifications.show({
id: 'certs',
loading: true,
Expand All @@ -131,7 +162,7 @@ function CertsShellComponent() {
});

// get respondents
const { data } = await supabase
const { data: respondents } = await supabase
.from('activity_eval_view')
.select('name, email')
.eq('title', values.activity!)
Expand All @@ -141,9 +172,13 @@ function CertsShellComponent() {
.not('name', 'is', null);

const response = (await worker.generateCertificate({
data: data!,
respondents: respondents!,
activityId: activity?.id!,
activityTitle: values.activity!,
activityEnd: activity?.date_ending!,
template: templateDataUrl,
coordinator,
vpsas,
qrPos: values.qrPos,
})) as CertReturnProps;

Expand Down Expand Up @@ -224,7 +259,10 @@ function CertsShellComponent() {
setTemplates(newTemplates);
};

form.setValues({ local: true, template: 'coa_1.png', qrPos: 'right' });
void fetchTemplates();

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
Expand Down Expand Up @@ -284,30 +322,69 @@ function CertsShellComponent() {
<Group grow justify="space-between" preventGrowOverflow={false}>
<div>
<Text fw={500} size="sm">
Filled/Signed certificate
Custom Certificate/Signatures
</Text>
<Text c="dimmed" size="xs">
For automated sending of certificates to recipients.
For automated sending of certificates to recipients. or custom
certificates template.
</Text>
</div>

<FileButton accept="image/png,image/jpeg" onChange={setFile}>
<FileButton
accept="image/png,image/jpeg"
onChange={setCustomTemplate}
>
{(props) => (
<Group gap="xs">
<Group gap="xs" justify="flex-end">
{/* Custom Template Upload */}
<Button
fullWidth
leftSection={<IconUpload size={16} stroke={1.5} />}
mt="xs"
variant="default"
{...props}
>
Upload certificate
{customTemplate
? `${customTemplate.name?.slice(0, 8)}...`
: 'Custom Template'}
</Button>
{file && (
<Text mt="xs" size="sm">
Selected file: {file.name}
</Text>
)}

<Divider orientation="vertical" />

{/* Signatures Upload */}
<FileButton accept="image/png" onChange={setCoordinator}>
{(props) => (
<Button
leftSection={
coordinator ? (
<IconCheck size={18} />
) : (
<IconSignature size={18} />
)
}
variant="filled"
{...props}
>
{coordinator ? 'Uploaded' : 'Coordinator'}
</Button>
)}
</FileButton>

<FileButton accept="image/png" onChange={setVpsas}>
{(props) => (
<Button
leftSection={
vpsas ? (
<IconCheck size={18} />
) : (
<IconSignature size={18} />
)
}
variant="filled"
{...props}
>
{vpsas ? 'Uploaded' : 'VPSAS'}
</Button>
)}
</FileButton>
</Group>
)}
</FileButton>
Expand Down Expand Up @@ -352,7 +429,7 @@ function CertsShellComponent() {

<Divider my="md" />

<Group gap="xs" ml="auto" mr={0}>
<Group gap="xs" justify="flex-end">
<Tooltip
label="Only save generated certificates"
multiline
Expand All @@ -369,7 +446,11 @@ function CertsShellComponent() {
</Tooltip>

<Button
disabled={file === null}
disabled={
form.values.activity === '' ||
coordinator === null ||
vpsas === null
}
onClick={() => form.setValues({ local: false })}
rightSection={<IconMailFast size={20} stroke={1.5} />}
type="submit"
Expand Down
4 changes: 4 additions & 0 deletions src/app/portal/certs/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { generateCerts } from '@/trigger/generate-certificate';
export async function triggerGenerateCerts(
activity: string,
templateDataUrl: string,
coordinator: string,
vpsas: string,
type: string[],
qrPos: 'left' | 'right',
send: boolean = true,
Expand All @@ -16,6 +18,8 @@ export async function triggerGenerateCerts(
{
activity,
template: templateDataUrl,
coordinator,
vpsas,
type,
qrPos,
send,
Expand Down
49 changes: 47 additions & 2 deletions src/libs/pdflib/certificate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import QRCode from 'qrcode';

Expand Down Expand Up @@ -37,12 +38,16 @@ const cursiveConfig = {
export async function generateCert(
name: string,
template: string,
id: string,
hash: string,
activityTitle: string,
activityEnd: string,
coordinator: string,
vpsas: string,
qrLoc: 'left' | 'right' = 'right',
): Promise<Blob | undefined> {
try {
// Create QR code
const qrDataUrl = await QRCode.toDataURL(`https://deuz.tech/certs/${id}`);
const qrDataUrl = await QRCode.toDataURL(`https://deuz.tech/certs/${hash}`);
const qrImageBytes = Buffer.from(qrDataUrl.split(',')[1], 'base64');

// Create PDF document
Expand Down Expand Up @@ -77,6 +82,46 @@ export async function generateCert(
height: 240,
});

// Add activity title
page.drawText(activityTitle, {
x: CERTIFICATE_DIMENSIONS.width / 2 + 182,
y: 564,
font: await doc.embedFont(StandardFonts.HelveticaBold),
size: 36,
});

// Add activity end date
const dateFormat = dayjs(activityEnd).format('MMMM D, YYYY');
page.drawText(dateFormat, {
x: CERTIFICATE_DIMENSIONS.width / 2 - 42,
y: 424,
font: await doc.embedFont(StandardFonts.HelveticaBold),
size: 36,
});

// Embed coordinator and VPSAs signatures
const coordinatorBytes = Buffer.from(coordinator.split(',')[1], 'base64');
const vpsasBytes = Buffer.from(vpsas.split(',')[1], 'base64');

const coordinatorImage = await doc.embedPng(coordinatorBytes);
const vpsasImage = await doc.embedPng(vpsasBytes);

// Draw coordinator signature
page.drawImage(coordinatorImage, {
x: CERTIFICATE_DIMENSIONS.width / 2 - 540,
y: 170,
width: 210,
height: 210,
});

// Draw VPSAs signature
page.drawImage(vpsasImage, {
x: CERTIFICATE_DIMENSIONS.width / 2 + 340,
y: 170,
width: 210,
height: 210,
});

// Add name text
const fontSize = Math.max(
standardConfig.minSize,
Expand Down
Loading

0 comments on commit 779966a

Please sign in to comment.