Skip to content

Commit

Permalink
feat(frontend): 🎸 add certificate generator function
Browse files Browse the repository at this point in the history
  • Loading branch information
markogracin committed Jan 20, 2025
1 parent 9a5cbfd commit c24417f
Show file tree
Hide file tree
Showing 24 changed files with 707 additions and 6 deletions.
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tikz/hedera-mirror-node-ts": "^3.0.0",
"@types/qrcode": "^1.5.5",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
Expand All @@ -40,6 +41,7 @@
"typescript-eslint": "^8.20.0",
"vite": "^6.0.7",
"vite-plugin-node-polyfills": "^0.22.0",
"qrcode": "^1.5.4",
"web3": "^4.16.0"
}
}
267 changes: 267 additions & 0 deletions frontend/src/lib/certificate-generator/generate-certificate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import QRCode from 'qrcode'

interface TextElement {
mission: string
operationsManager: string
dateOfWork: string
bidiEarned: string
}

const generateQRCode = async (text: string): Promise<string> => {
const qrOptions: QRCode.QRCodeToStringOptions = {
type: 'svg',
margin: 5,
}

try {
const qrSvg = await new Promise<string>((resolve, reject) => {
QRCode.toString(text, qrOptions, (err, string) => {
if (err) reject(err)
else resolve(string)
})
})

return qrSvg
.replace(/<\/?svg[^>]*>/g, '')
.replace(/width="[^"]*"/, '')
.replace(/height="[^"]*"/, '')
} catch (error) {
console.error('Error generating QR code:', error)
return ''
}
}

const getRandomItem = <T>(array: T[]): T => array[Math.floor(Math.random() * array.length)]

const getRandomColor = (): string => {
const colors = [
'#CE84B7',
'#DBE38B',
'#E6595F',
'#96CEB4',
'#E6C83C',
'#F3923E',
'#9CCDA0',
'#BCA671',
'#F1C40F',
'#CBE1B6',
'#AD7595',
'#ADBA42',
'#EA4B8B',
'#9684BD',
]
return getRandomItem(colors)
}

const createTextElement = (textData: TextElement): string => `
<g font-family="Andale Mono, Arial, sans-serif" text-anchor="start" style="text-transform: uppercase">
<text x="180" y="730" font-size="8px" font-weight="400">
MISSION: ${textData.mission}
</text>
<text x="180" y="740" font-size="8px" font-weight="400" style="text-transform: uppercase;;">
OPERATIONS MANAGER: ${textData.operationsManager}
</text>
<text x="180" y="750" font-size="8px" font-weight="400" style="text-transform: uppercase;;">
DATE OF WORK: ${textData.dateOfWork}
</text>
<text x="180" y="760" font-size="8px" font-weight="400" style="text-transform: uppercase;;">
NUMBER OF BIDI EARNED: ${textData.bidiEarned}
</text>
<text x="180" y="770" font-size="8px" style="text-transform: uppercase;;">
HEDERA BLOCKCHAIN
</text>
<text x="180" y="780" font-size="8px" style="text-transform: uppercase;;">
BIDIGUT.CH
</text>
</g>
`

const combineSVGElements = (
svgArray: string[],
filenames: string[],
qrCode: string,
textElement: string,
): string => {
const insectAColor = getRandomColor()

const processedSVGs = svgArray.map((svg, index) => {
const isWaves = filenames[index].includes('waves')
const isDynamic = filenames[index].includes('title-dynamic')
const isInsectA = filenames[index].includes('insect') && filenames[index].includes('-a')
const isInsectB = filenames[index].includes('insect') && filenames[index].includes('-b')

if (isWaves) {
return svg
.replace(/<\/?svg[^>]*>/g, '')
.replace(/<g/, '<g style="mix-blend-mode: multiply"')
.trim()
}

let colorToUse: string = ''
if (isInsectA || isDynamic) {
colorToUse = `fill="${insectAColor}" opacity="1"`
} else if (isInsectB) {
colorToUse = `fill="${getRandomColor()}" opacity="1"`
}

return svg
.replace(/<\/?svg[^>]*>/g, '')
.replace(/class="[^"]*"/g, colorToUse)
.trim()
})

const wavesIndex = filenames.findIndex((name) => name.includes('waves'))
const wavesContent = processedSVGs[wavesIndex]
const otherContent = processedSVGs.filter((_, index) => index !== wavesIndex)

const qrElement = `
<g transform="translate(85, 710) scale(2.6)">
${qrCode}
</g>
`

return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 595.3 841.9">
<rect width="100%" height="100%" fill="white"/>
${otherContent.join('\n')}
${textElement}
${qrElement}
${wavesContent}
</svg>
`.trim()
}

const prepareCertificate = async (qrUrl: string, textData: TextElement): Promise<string> => {
const insectAOptions = [
'/certificate/insect-1-a.svg',
'/certificate/insect-2-a.svg',
'/certificate/insect-3-a.svg',
'/certificate/insect-4-a.svg',
'/certificate/insect-5-a.svg',
'/certificate/insect-6-a.svg',
]

const insectBOptions = [
'/certificate/insect-1-b.svg',
'/certificate/insect-2-b.svg',
'/certificate/insect-3-b.svg',
'/certificate/insect-4-b.svg',
'/certificate/insect-5-b.svg',
'/certificate/insect-6-b.svg',
]

const wavesPath = '/certificate/waves.svg'
const titleFixedPath = '/certificate/title-fixed.svg'
const titleDynamicPath = '/certificate/title-dynamic.svg'

try {
const qrCode = await generateQRCode(qrUrl)
const selectedInsectAPath = getRandomItem(insectAOptions)
const selectedInsectBPath = getRandomItem(insectBOptions)
const selectedPaths = [
selectedInsectAPath,
selectedInsectBPath,
wavesPath,
titleFixedPath,
titleDynamicPath,
]

const [selectedInsectA, selectedInsectB, waves, titleFixed, titleDynamic] = await Promise.all([
fetch(selectedInsectAPath).then((res) => res.text()),
fetch(selectedInsectBPath).then((res) => res.text()),
fetch(wavesPath).then((res) => res.text()),
fetch(titleFixedPath).then((res) => res.text()),
fetch(titleDynamicPath).then((res) => res.text()),
])

const textElement = createTextElement(textData)

return combineSVGElements(
[selectedInsectA, selectedInsectB, waves, titleFixed, titleDynamic],
selectedPaths,
qrCode,
textElement,
)
} catch (error) {
console.error('Error generating certificate:', error)
throw error
}
}

const convertSvgToPng = async (svgString: string) => {
const A4_WIDTH = 2480
const A4_HEIGHT = 3508

const canvas = document.createElement('canvas')
canvas.width = A4_WIDTH
canvas.height = A4_HEIGHT
const ctx = canvas.getContext('2d')

const img = new Image()
const blob = new Blob([svgString], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)

return new Promise((resolve, reject) => {
if (!ctx) throw new Error('Error getting canvas context')

img.onload = () => {
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)

ctx.drawImage(img, 0, 0, canvas.width, canvas.height)

canvas.toBlob(
(blob) => {
URL.revokeObjectURL(url)
resolve(blob)
},
'image/png',
1.0,
)
}

img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Error loading SVG'))
}

img.src = url
})
}

export const generateNftCertificate = async (
qrUrl: string,
{
mission,
operationsManager,
dateOfWork,
bidiEarned,
}: {
mission: string
operationsManager: string
dateOfWork: string
bidiEarned: string
},
): Promise<File> => {
try {
const svgString = await prepareCertificate(qrUrl, {
mission: mission,
operationsManager: operationsManager,
dateOfWork: dateOfWork,
bidiEarned: bidiEarned,
})

const pngBlob = (await convertSvgToPng(svgString)) as Blob

const blobParts: BlobPart[] = [pngBlob]
const filename = `bidi-certificate-${new Date().getTime()}.png`

return new File(blobParts, filename, {
type: 'image/png',
lastModified: new Date().getTime(),
})
} catch (error) {
console.error('Error generating certificate:', error)
throw error
}
}
9 changes: 9 additions & 0 deletions frontend/src/lib/css/fonts.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@font-face {
font-family: 'Andale Mono';
src: url('/static/fonts/andale-mono.woff2') format('woff2'),
url('/static/fonts/andale-mono.woff2') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}

14 changes: 8 additions & 6 deletions frontend/src/lib/pinata/pinata.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { PINATA_JWT } from '$env/static/private'
import { PINATA_GROUP_ID, PINATA_JWT } from '$env/static/private'
import { PUBLIC_GATEWAY_URL } from '$env/static/public'
import type { BidiCertificate } from '$lib/certificate'
import { getNftDescription } from '$lib/getNftDescription'
import type { StandardNftMetadata } from '$lib/hedera/StandardNftMetadata'
import { PinataSDK } from 'pinata-web3'
import { type FileObject, PinataSDK } from 'pinata-web3'

export const pinata = new PinataSDK({
pinataJwt: PINATA_JWT,
Expand All @@ -14,16 +14,18 @@ export const pinata = new PinataSDK({
export const uploadNftMetadata = async (options: {
missionTitle: string
certificate: BidiCertificate
certificateImage: FileObject
}) => {
// Upload static image todo generate image later on, adding this pre-uploaded url for now
// const imageUpload = await pinata.upload.file(staticFile).group(PINATA_GROUP_ID)
const imageIpfsUrl = `ipfs://bafkreifpz6c7i5bcxklf45qgbz3yo4zmic6imue7ryaa62vg3s7m3sa5qa`
const certificateImageUploadResult = await pinata.upload
.file(options.certificateImage)
.group(PINATA_GROUP_ID)

const description = getNftDescription(options.certificate)
const fullMetadata: StandardNftMetadata<BidiCertificate> = {
name: options.missionTitle,
creator: 'BIDI-Organization',
description,
image: imageIpfsUrl,
image: certificateImageUploadResult?.IpfsHash,
type: 'image/jpg',
properties: options.certificate,
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/routes/api/pinata/upload/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import type { BidiCertificate } from '$lib/certificate'
import { uploadNftMetadata } from '$lib/pinata/pinata.server'
import { error, json } from '@sveltejs/kit'
import type { FileObject } from 'pinata-web3'
import type { RequestHandler } from './$types'

export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData()

const certificateImage = formData.get('certificateImage') as FileObject
const missionTitleEntry = formData.get('missionTitle')
if (!missionTitleEntry) {
error(400, { message: `missionTitle is required in the form data!` })
Expand All @@ -18,6 +21,7 @@ export const POST: RequestHandler = async ({ request }) => {
const uploadedNftMetadata = await uploadNftMetadata({
missionTitle,
certificate,
certificateImage,
})

return json(uploadedNftMetadata)
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/routes/dashboard/mint/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import CertificateCreationForm from './CertificateCreationForm.svelte'
import containerStyles from '$lib/css/container.module.css'
import type { Nft } from '$lib/nft'
import {generateNftCertificate} from "$lib/certificate-generator/generate-certificate";
const uploadMetadata = async (options: {
missionTitle: string
Expand All @@ -20,6 +21,17 @@
formData.append('metadata', JSON.stringify(options.certificate))
formData.append('missionTitle', options.missionTitle)
// todo custom qr code url? defaulting to bidigut.ch for now
// had to generate this on the frontend cause of canvas stuff
const certificateImage = await generateNftCertificate('https://bidigut.ch', {
mission: options.missionTitle,
dateOfWork: options.certificate.dateOfWork,
operationsManager: 'todo',
bidiEarned: options.certificate.numberOfBidi.toString(),
})
formData.append('certificateImage', certificateImage)
const response = await fetch('/api/pinata/upload', {
method: 'POST',
body: formData,
Expand Down
136 changes: 136 additions & 0 deletions frontend/static/certificate/insect-1-a.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit c24417f

Please sign in to comment.