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:
parent
a27782e4de
commit
516def235a
80
apps/api/src/lib/qr-pdf.ts
Normal file
80
apps/api/src/lib/qr-pdf.ts
Normal 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();
|
||||
}
|
||||
@ -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<Bindings>();
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user