diff --git a/apps/api/src/lib/qr-pdf.ts b/apps/api/src/lib/qr-pdf.ts new file mode 100644 index 0000000..ffeb456 --- /dev/null +++ b/apps/api/src/lib/qr-pdf.ts @@ -0,0 +1,80 @@ +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import { generateQrPng } from './qrcode.js'; + +export interface QrPdfOptions { + url: string; + coupleNames: string; + eventDate?: string; + callToAction?: string; +} + +const PT_PER_MM = 2.834645669; +const A6_WIDTH = 105 * PT_PER_MM; +const A6_HEIGHT = 148 * PT_PER_MM; + +function centerX(text: string, fontSize: number, font: import('pdf-lib').PDFFont): number { + const w = font.widthOfTextAtSize(text, fontSize); + return (A6_WIDTH - w) / 2; +} + +export async function generateQrPdf(opts: QrPdfOptions): Promise { + const pdf = await PDFDocument.create(); + const page = pdf.addPage([A6_WIDTH, A6_HEIGHT]); + + const titleFont = await pdf.embedFont(StandardFonts.TimesRomanItalic); + const bodyFont = await pdf.embedFont(StandardFonts.Helvetica); + const boldFont = await pdf.embedFont(StandardFonts.HelveticaBold); + + const ink = rgb(0.18, 0.16, 0.14); + const muted = rgb(0.45, 0.42, 0.38); + + const titleSize = 28; + const titleY = A6_HEIGHT - 28 * PT_PER_MM; + page.drawText(opts.coupleNames, { + x: centerX(opts.coupleNames, titleSize, titleFont), + y: titleY, + size: titleSize, + font: titleFont, + color: ink, + }); + + if (opts.eventDate) { + const dateSize = 11; + page.drawText(opts.eventDate, { + x: centerX(opts.eventDate, dateSize, bodyFont), + y: titleY - 7 * PT_PER_MM, + size: dateSize, + font: bodyFont, + color: muted, + }); + } + + const qrPng = await generateQrPng(opts.url, 600); + const qrImage = await pdf.embedPng(qrPng); + const qrSize = 70 * PT_PER_MM; + const qrX = (A6_WIDTH - qrSize) / 2; + const qrY = (A6_HEIGHT - qrSize) / 2 - 8 * PT_PER_MM; + page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); + + const cta = opts.callToAction ?? 'Aponte a câmera do celular'; + const ctaSize = 12; + page.drawText(cta, { + x: centerX(cta, ctaSize, boldFont), + y: qrY - 9 * PT_PER_MM, + size: ctaSize, + font: boldFont, + color: ink, + }); + + const sub = 'para enviar fotos e mensagem'; + const subSize = 10; + page.drawText(sub, { + x: centerX(sub, subSize, bodyFont), + y: qrY - 14 * PT_PER_MM, + size: subSize, + font: bodyFont, + color: muted, + }); + + return await pdf.save(); +} diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index f34d0c1..72404ae 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -4,6 +4,8 @@ import type { GalleryResponse } from '@leetete/shared'; import { getDb } from '../db/client.js'; import { eventConfig, uploads } from '../db/schema.js'; import type { Bindings } from '../env.js'; +import { generateQrPng, generateQrSvg } from '../lib/qrcode.js'; +import { generateQrPdf } from '../lib/qr-pdf.js'; import { createStorage } from '../lib/storage-factory.js'; export const publicRoutes = new Hono(); @@ -70,6 +72,53 @@ publicRoutes.get('/gallery', async (c) => { return c.json({ items, nextCursor } satisfies GalleryResponse); }); +publicRoutes.get('/qrcode', async (c) => { + const format = (c.req.query('format') ?? 'png').toLowerCase(); + const overrideUrl = c.req.query('url'); + + const reqUrl = new URL(c.req.url); + const base = + overrideUrl ?? + c.env.PUBLIC_BASE_URL ?? + `${reqUrl.protocol}//${reqUrl.host}`; + const target = base.replace(/\/$/, '') + '/enviar'; + + if (format === 'svg') { + const svg = await generateQrSvg(target); + return new Response(svg, { + headers: { + 'content-type': 'image/svg+xml; charset=utf-8', + 'cache-control': 'public, max-age=300', + }, + }); + } + + if (format === 'pdf') { + const db = getDb(c.env.DB); + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get(); + const pdf = await generateQrPdf({ + url: target, + coupleNames: cfg?.coupleNames ?? c.env.COUPLE_NAMES, + eventDate: cfg?.eventDate ?? c.env.EVENT_DATE, + }); + return new Response(pdf as BodyInit, { + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'inline; filename="qr-mesa.pdf"', + 'cache-control': 'no-store', + }, + }); + } + + const png = await generateQrPng(target, 800); + return new Response(png as BodyInit, { + headers: { + 'content-type': 'image/png', + 'cache-control': 'public, max-age=300', + }, + }); +}); + publicRoutes.get('/stats', async (c) => { const db = getDb(c.env.DB); const all = await db