feat: add QR code endpoint with PNG/SVG/PDF formats

GET /api/qrcode returns the QR for the upload URL of the event.
?format=png (default, 800px) for screen, ?format=svg for vector, and
?format=pdf for an A6 printable card with the couple's names, the
event date (if set), and a "Aponte a câmera" instruction. The
encoded URL defaults to the request's origin + /enviar so it works
across environments without extra config; ?url= overrides for tests.

https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
This commit is contained in:
Claude 2026-05-04 02:42:29 +00:00
parent a27782e4de
commit 516def235a
No known key found for this signature in database
2 changed files with 129 additions and 0 deletions

View File

@ -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<Uint8Array> {
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();
}

View File

@ -4,6 +4,8 @@ import type { GalleryResponse } from '@leetete/shared';
import { getDb } from '../db/client.js'; import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js'; import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.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'; import { createStorage } from '../lib/storage-factory.js';
export const publicRoutes = new Hono<Bindings>(); export const publicRoutes = new Hono<Bindings>();
@ -70,6 +72,53 @@ publicRoutes.get('/gallery', async (c) => {
return c.json({ items, nextCursor } satisfies GalleryResponse); 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) => { publicRoutes.get('/stats', async (c) => {
const db = getDb(c.env.DB); const db = getDb(c.env.DB);
const all = await db const all = await db