diff --git a/backend/__tests__/routes/adminEventQr.test.js b/backend/__tests__/routes/adminEventQr.test.js new file mode 100644 index 00000000..cfec5359 --- /dev/null +++ b/backend/__tests__/routes/adminEventQr.test.js @@ -0,0 +1,122 @@ +/** + * HTTP tests for the gallery QR endpoints (#836): + * GET /api/admin/events/:id/qr (PNG / SVG) + * GET /api/admin/events/:id/qr-print (table-card / poster PDF) + * Same real-SQLite harness as adminEvents.smoke.test.js. + */ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-qr-')), 'db.sqlite' +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-qr-test-secret'; + +const express = require('express'); +const cookieParser = require('cookie-parser'); +const request = require('supertest'); +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb'); + +async function insertEvent(db, adminId, over = {}) { + const base = { + slug: `ev-${Math.random().toString(16).slice(2)}`, + event_type: 'wedding', + event_name: 'QR Test Wedding', + event_date: '2026-05-29', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: 'x', + share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`, + share_token: `st-${Math.random().toString(16).slice(2)}`, + expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), + is_active: 1, is_archived: 0, is_draft: 0, + created_by: adminId, + created_at: new Date().toISOString(), + ...over, + }; + const r = await db('events').insert(base).returning('id'); + return r[0]?.id ?? r[0]; +} + +describe('admin event QR endpoints', () => { + let db; let cleanup; let app; let adminId; let token; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId } = await seedMinimal(db)); + await assignAdminRole(db, adminId, 'super_admin'); + token = mintAdminToken(adminId); + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/admin/events', require('../../src/routes/adminEvents')); + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, next) => { + res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code }); + }); + }, 120000); + + afterAll(async () => { await cleanup(); }); + + beforeEach(async () => { await db('events').del(); }); + + const auth = (req) => req.set('Authorization', `Bearer ${token}`); + + it('401s without an admin token', async () => { + const eventId = await insertEvent(db, adminId); + const res = await request(app).get(`/api/admin/events/${eventId}/qr`); + expect(res.status).toBe(401); + }); + + it('returns a PNG QR by default', async () => { + const eventId = await insertEvent(db, adminId); + const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`)).buffer(); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + // PNG magic bytes + expect(res.body.slice(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + }); + + it('returns an SVG QR when requested', async () => { + const eventId = await insertEvent(db, adminId); + // supertest doesn't text-parse image/svg+xml — buffer and decode manually. + const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?format=svg`)).buffer(); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/image\/svg\+xml/); + expect(Buffer.from(res.body).toString('utf8')).toContain(' { + const eventId = await insertEvent(db, adminId); + const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?download=1`)).buffer(); + expect(res.headers['content-disposition']).toMatch(/^attachment/); + }); + + // 30s: the print PDFs embed the full IBM Plex Sans TTFs (~200 KB each) — + // font parsing + subsetting exceeds jest's 5s default on slower CI runners. + it.each(['table-card', 'poster'])('renders the %s print PDF', async (template) => { + const eventId = await insertEvent(db, adminId); + const res = await auth( + request(app).get(`/api/admin/events/${eventId}/qr-print?template=${template}&lang=de`) + ).buffer(); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('application/pdf'); + expect(res.body.slice(0, 4).toString()).toBe('%PDF'); + }, 30000); + + it('409s when the event has no share link', async () => { + // events.share_link is NOT NULL — an empty string is the closest real-world + // "no share link" shape (no token extractable from it either). + const eventId = await insertEvent(db, adminId, { share_link: '', share_token: null }); + const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`)); + expect(res.status).toBe(409); + }); + + it('404s for a non-existent event', async () => { + const res = await auth(request(app).get('/api/admin/events/999999/qr')); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/assets/fonts/IBM-Plex-Sans-Full/400.ttf b/backend/assets/fonts/IBM-Plex-Sans-Full/400.ttf new file mode 100644 index 00000000..bd6817d5 Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans-Full/400.ttf differ diff --git a/backend/assets/fonts/IBM-Plex-Sans-Full/700.ttf b/backend/assets/fonts/IBM-Plex-Sans-Full/700.ttf new file mode 100644 index 00000000..1d66b1a2 Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans-Full/700.ttf differ diff --git a/backend/assets/fonts/IBM-Plex-Sans-Full/OFL-LICENSE.txt b/backend/assets/fonts/IBM-Plex-Sans-Full/OFL-LICENSE.txt new file mode 100755 index 00000000..c35c4c61 --- /dev/null +++ b/backend/assets/fonts/IBM-Plex-Sans-Full/OFL-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/backend/src/routes/adminEvents/index.js b/backend/src/routes/adminEvents/index.js index 5c393f5e..7677a2cc 100644 --- a/backend/src/routes/adminEvents/index.js +++ b/backend/src/routes/adminEvents/index.js @@ -13,5 +13,6 @@ require('./slideshow')(router); require('./resets')(router); require('./archiveBulk')(router); require('./logo')(router); +require('./qr')(router); module.exports = router; diff --git a/backend/src/routes/adminEvents/qr.js b/backend/src/routes/adminEvents/qr.js new file mode 100644 index 00000000..66898da7 --- /dev/null +++ b/backend/src/routes/adminEvents/qr.js @@ -0,0 +1,183 @@ +// Gallery QR codes (#836): QR image (PNG/SVG) for the event's share link and +// print-ready PDF templates (table card A6, poster A4) built with pdfkit. +// Registered after ./logo in ./index.js — all routes are '/:id/...' literals, +// so registration order relative to the other sub-modules is not sensitive. + +const path = require('path'); +const QRCode = require('qrcode'); +const PDFDocument = require('pdfkit'); +const { db } = require('../../database/db'); +const { adminAuth } = require('../../middleware/auth'); +const { requirePermission } = require('../../middleware/permissions'); +const { requireEventOwnership } = require('../../middleware/ownership'); +const { buildShareLinkVariants, getEventShareToken } = require('../../services/shareLinkService'); +const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); +const logger = require('../../utils/logger'); +const { errorResponse } = require('../../utils/routeHelpers'); + +// Caption under the QR on the print templates, in the admin's UI language +// (passed as ?lang= by the frontend). Mirrors the 8 gallery locales. +const PRINT_CAPTIONS = { + en: 'Scan to view & share your photos', + de: 'Scannen, um Fotos anzusehen & zu teilen', + es: 'Escanea para ver y compartir tus fotos', + fr: 'Scannez pour voir et partager vos photos', + nl: 'Scan om foto’s te bekijken & te delen', + pt: 'Escaneie para ver e compartilhar suas fotos', + ru: 'Отсканируйте, чтобы посмотреть и поделиться фото', + sl: 'Skenirajte za ogled in deljenje fotografij', +}; + +// A6 = 298 x 420 pt, A4 = 595 x 842 pt (pdfkit default unit). +const TEMPLATES = { + 'table-card': { size: [298, 420], qrSize: 180, titleSize: 16, captionSize: 10, urlSize: 7 }, + poster: { size: 'A4', qrSize: 360, titleSize: 28, captionSize: 16, urlSize: 10 }, +}; + +// Bundled COMPLETE IBM Plex Sans (1019 glyphs: Latin + Cyrillic + Greek, +// verified via fontkit cmap) — pdfkit's built-in Helvetica is WinAnsi-only +// and silently drops e.g. Cyrillic event names, and the pre-existing +// assets/fonts/IBM-Plex-Sans/ files are 270-glyph Latin subsets with the +// same gap (codex review of #847). OFL license alongside the files. +const FONT_BOLD = path.join(__dirname, '../../../assets/fonts/IBM-Plex-Sans-Full/700.ttf'); +const FONT_REGULAR = path.join(__dirname, '../../../assets/fonts/IBM-Plex-Sans-Full/400.ttf'); + +// A same-origin admin GET carries no Origin header, so the frontend passes +// window.location.origin explicitly. Only accept a plain http(s) origin. +const ORIGIN_RE = /^https?:\/\/[^\s/]+$/i; +// Configured FRONTEND_URL defaults to localhost on unconfigured installs — +// a QR pointing there is unusable on any other device (codex review of #847). +const LOCAL_BASE_RE = /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/i; + +async function loadShareUrl(eventId, requestOrigin) { + const event = await db('events').where({ id: eventId }).first(); + if (!event) return { event: null, shareUrl: null }; + + // Single source of truth is the STORED share_link — exactly what the + // ShareLinkCard displays and the admin copies. Rebuilding from the + // current slug/token/short-URL setting can diverge for legacy absolute + // links or events created under a different short-URL setting, and a + // printed QR encoding a different URL than the card is a permanent + // mistake (codex review of #847, confirmation round). Only when no + // share_link is stored do we fall back to rebuilding it. + let link = event.share_link; + if (!link) { + const shareToken = getEventShareToken(event); + if (!shareToken) return { event, shareUrl: null }; + ({ shareLinkToStore: link } = await buildShareLinkVariants({ slug: event.slug, shareToken })); + } + + const origin = typeof requestOrigin === 'string' && ORIGIN_RE.test(requestOrigin) + ? requestOrigin.replace(/\/$/, '') + : null; + + // Absolute + reachable → use as-is; loopback-absolute → re-anchor its + // path; relative → absolutize. Mirrors the frontend's buildShareLinkUrl. + if (/^https?:\/\//i.test(link) && !LOCAL_BASE_RE.test(link)) { + return { event, shareUrl: link }; + } + let sharePath = link; + if (/^https?:\/\//i.test(link)) { + try { const u = new URL(link); sharePath = `${u.pathname}${u.search}`; } catch { /* keep as-is */ } + } + // Bare stored values (quote-/contract-converted events persist the raw + // token) resolve as /gallery/ — mirroring the frontend's + // buildShareLinkUrl exactly (codex review of #847, final round). + if (!sharePath.startsWith('/')) sharePath = `/gallery/${sharePath}`; + if (origin) return { event, shareUrl: `${origin}${sharePath}` }; + const frontendBase = await getFrontendBaseUrl(); + return { event, shareUrl: frontendBase ? `${frontendBase}${sharePath}` : sharePath }; +} + +module.exports = (router) => { + // QR image for the gallery share link. + router.get('/:id/qr', adminAuth, requirePermission('events.view'), requireEventOwnership, async (req, res) => { + try { + const { event, shareUrl } = await loadShareUrl(req.params.id, req.query.origin); + if (!event) return errorResponse(res, 'Event not found', 404); + if (!shareUrl) return errorResponse(res, 'Event has no share link', 409); + + const format = req.query.format === 'svg' ? 'svg' : 'png'; + const download = req.query.download === '1'; + const disposition = `${download ? 'attachment' : 'inline'}; filename="qr-${event.slug}.${format}"`; + + if (format === 'svg') { + const svg = await QRCode.toString(shareUrl, { type: 'svg', margin: 4 }); + res.set('Content-Type', 'image/svg+xml'); + res.set('Content-Disposition', disposition); + return res.send(svg); + } + + const width = Math.min(Math.max(parseInt(req.query.size, 10) || 600, 128), 2048); + const png = await QRCode.toBuffer(shareUrl, { type: 'png', width, margin: 4 }); + res.set('Content-Type', 'image/png'); + res.set('Content-Disposition', disposition); + return res.send(png); + } catch (error) { + logger.error('Failed to generate gallery QR code:', error); + return errorResponse(res, error, 500, 'Failed to generate QR code'); + } + }); + + // Print-ready PDF (table card / poster) with QR + event name + caption. + router.get('/:id/qr-print', adminAuth, requirePermission('events.view'), requireEventOwnership, async (req, res) => { + try { + const { event, shareUrl } = await loadShareUrl(req.params.id, req.query.origin); + if (!event) return errorResponse(res, 'Event not found', 404); + if (!shareUrl) return errorResponse(res, 'Event has no share link', 409); + + const templateKey = TEMPLATES[req.query.template] ? req.query.template : 'table-card'; + const tpl = TEMPLATES[templateKey]; + const caption = PRINT_CAPTIONS[req.query.lang] || PRINT_CAPTIONS.en; + + const qrPng = await QRCode.toBuffer(shareUrl, { type: 'png', width: tpl.qrSize * 3, margin: 4 }); + + const doc = new PDFDocument({ + size: tpl.size, + margins: { top: 0, bottom: 0, left: 0, right: 0 }, + info: { Title: `PicPeak QR — ${event.event_name}` }, + }); + res.set('Content-Type', 'application/pdf'); + res.set('Content-Disposition', `attachment; filename="qr-${templateKey}-${event.slug}.pdf"`); + doc.pipe(res); + + const pageWidth = doc.page.width; + const pageHeight = doc.page.height; + const contentTop = pageHeight * 0.12; + + // Fixed vertical layout: the title gets a bounded two-line region with + // ellipsis so an arbitrarily long event name can't push the QR/caption + // over the footer or off the page (codex review of #847). All positions + // below derive from constants, never from doc.y. + const titleBlockHeight = tpl.titleSize * 2.6; + doc.font(FONT_BOLD).fontSize(tpl.titleSize).fillColor('#1a1a1a') + .text(event.event_name, pageWidth * 0.1, contentTop, { + width: pageWidth * 0.8, + align: 'center', + height: titleBlockHeight, + ellipsis: true, + }); + + const qrX = (pageWidth - tpl.qrSize) / 2; + const qrY = contentTop + titleBlockHeight + tpl.titleSize * 0.5; + doc.image(qrPng, qrX, qrY, { width: tpl.qrSize, height: tpl.qrSize }); + + doc.font(FONT_REGULAR).fontSize(tpl.captionSize).fillColor('#333333') + .text(caption, pageWidth * 0.1, qrY + tpl.qrSize + tpl.captionSize, { width: pageWidth * 0.8, align: 'center' }); + + doc.font(FONT_REGULAR).fontSize(tpl.urlSize).fillColor('#888888') + .text(shareUrl, pageWidth * 0.05, pageHeight - pageHeight * 0.07, { + width: pageWidth * 0.9, + align: 'center', + height: pageHeight * 0.06, + ellipsis: true, + }); + + doc.end(); + } catch (error) { + logger.error('Failed to generate QR print PDF:', error); + if (!res.headersSent) return errorResponse(res, error, 500, 'Failed to generate QR print PDF'); + return res.end(); + } + }); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bb1c41df..46de4f2d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -996,6 +996,9 @@ "archived": "Archiviert", "totalSize": "Gesamtgröße", "shareLink": "Freigabelink", + "qrCode": "QR-Code", + "qrTableCard": "Tischkarte (A6)", + "qrPoster": "Poster (A4)", "copyLink": "Link kopieren", "linkCopied": "Link kopiert!", "viewGallery": "Galerie ansehen", @@ -2928,6 +2931,7 @@ } }, "errors": { + "downloadFailed": "Download fehlgeschlagen", "galleryNotFound": "Galerie nicht gefunden", "galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.", "somethingWentWrong": "Etwas ist schiefgelaufen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0d7a87a4..5dbb4568 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -543,6 +543,9 @@ "archived": "Archived", "totalSize": "Total Size", "shareLink": "Share Link", + "qrCode": "QR code", + "qrTableCard": "Table card (A6)", + "qrPoster": "Poster (A4)", "copyLink": "Copy Link", "linkCopied": "Link copied!", "viewGallery": "View Gallery", @@ -2506,6 +2509,7 @@ } }, "errors": { + "downloadFailed": "Download failed", "galleryNotFound": "Gallery Not Found", "galleryNotFoundMessage": "This gallery does not exist or has been removed.", "somethingWentWrong": "Something went wrong", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 249b1e50..49e4e38b 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -393,6 +393,9 @@ "photoCount": "{{count}} fotos", "totalSize": "Tamaño total", "shareLink": "Enlace para compartir", + "qrCode": "Código QR", + "qrTableCard": "Tarjeta de mesa (A6)", + "qrPoster": "Póster (A4)", "copyLink": "Copiar enlace", "linkCopied": "Enlace copiado!", "viewGallery": "Ver galería", @@ -1608,6 +1611,7 @@ } }, "errors": { + "downloadFailed": "Error al descargar", "notFound": "No encontrado", "galleryNotFound": "Galería no encontrada", "galleryNotFoundMessage": "Esta galería no existe o ha sido eliminada.", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index dd9c0539..504559de 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -387,6 +387,9 @@ "archived": "Archivé", "totalSize": "Taille totale", "shareLink": "Partager le lien", + "qrCode": "Code QR", + "qrTableCard": "Carte de table (A6)", + "qrPoster": "Affiche (A4)", "copyLink": "Copier le lien", "linkCopied": "Lien copié !", "viewGallery": "Voir la galerie", @@ -1860,6 +1863,7 @@ } }, "errors": { + "downloadFailed": "Échec du téléchargement", "galleryNotFound": "Galerie introuvable", "galleryNotFoundMessage": "Cette galerie n'existe pas ou a été supprimée.", "somethingWentWrong": "Quelque chose s'est mal passé", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 0a624fbe..fc375d58 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -387,6 +387,9 @@ "archived": "Gearchiveerd", "totalSize": "Totale grootte", "shareLink": "Deellink", + "qrCode": "QR-code", + "qrTableCard": "Tafelkaart (A6)", + "qrPoster": "Poster (A4)", "copyLink": "Link kopieren", "linkCopied": "Link gekopieerd!", "viewGallery": "Galerij bekijken", @@ -1849,6 +1852,7 @@ } }, "errors": { + "downloadFailed": "Downloaden mislukt", "galleryNotFound": "Galerij niet gevonden", "galleryNotFoundMessage": "Deze galerij bestaat niet of is verwijderd.", "somethingWentWrong": "Er is iets misgegaan", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 144d0cc4..e92bdb51 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -395,6 +395,9 @@ "archived": "Arquivado", "totalSize": "Tamanho Total", "shareLink": "Link de Compartilhamento", + "qrCode": "Código QR", + "qrTableCard": "Cartão de mesa (A6)", + "qrPoster": "Pôster (A4)", "copyLink": "Copiar Link", "linkCopied": "Link copiado!", "viewGallery": "Ver Galeria", @@ -1874,6 +1877,7 @@ } }, "errors": { + "downloadFailed": "Falha no download", "galleryNotFound": "Galeria Não Encontrada", "galleryNotFoundMessage": "Esta galeria não existe ou foi removida.", "somethingWentWrong": "Algo deu errado", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index cc4ae618..88a215f3 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -403,6 +403,9 @@ "archived": "В архиве", "totalSize": "Общий размер", "shareLink": "Ссылка для доступа", + "qrCode": "QR-код", + "qrTableCard": "Карточка на стол (A6)", + "qrPoster": "Постер (A4)", "copyLink": "Копировать ссылку", "linkCopied": "Ссылка скопирована!", "viewGallery": "Просмотреть галерею", @@ -1899,6 +1902,7 @@ } }, "errors": { + "downloadFailed": "Ошибка загрузки", "galleryNotFound": "Галерея не найдена", "galleryNotFoundMessage": "Эта галерея не существует или была удалена.", "somethingWentWrong": "Что-то пошло не так", diff --git a/frontend/src/i18n/locales/sl.json b/frontend/src/i18n/locales/sl.json index aa528b63..af025f9f 100644 --- a/frontend/src/i18n/locales/sl.json +++ b/frontend/src/i18n/locales/sl.json @@ -387,6 +387,9 @@ "archived": "Arhiviran", "totalSize": "Skupna velikost", "shareLink": "Povezava za deljenje", + "qrCode": "QR-koda", + "qrTableCard": "Namizna kartica (A6)", + "qrPoster": "Plakat (A4)", "copyLink": "Kopiraj povezavo", "linkCopied": "Povezava kopirana!", "viewGallery": "Ogled galerije", @@ -1849,6 +1852,7 @@ } }, "errors": { + "downloadFailed": "Prenos ni uspel", "galleryNotFound": "Galerija ni najdena", "galleryNotFoundMessage": "Ta galerija ne obstaja ali je bila odstranjena.", "somethingWentWrong": "Nekaj je šlo narobe", diff --git a/frontend/src/pages/admin/event-details/ShareLinkCard.tsx b/frontend/src/pages/admin/event-details/ShareLinkCard.tsx index 91102955..8b7f4c7d 100644 --- a/frontend/src/pages/admin/event-details/ShareLinkCard.tsx +++ b/frontend/src/pages/admin/event-details/ShareLinkCard.tsx @@ -1,21 +1,74 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Copy, CheckCircle, Key, Mail } from 'lucide-react'; +import { Copy, CheckCircle, Key, Mail, QrCode, Download } from 'lucide-react'; import type { Event } from '../../../types'; import { Button, Card } from '../../../components/common'; import { eventsService } from '../../../services/events.service'; import { buildShareLinkUrl } from '../../../utils/url'; import { isGalleryPublic } from '../../../utils/accessControl'; +const saveBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; + interface ShareLinkCardProps { event: Event; setShowPasswordReset: (show: boolean) => void; } export const ShareLinkCard: React.FC = ({ event, setShowPasswordReset }) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [copiedLink, setCopiedLink] = useState(false); + const [qrPreviewUrl, setQrPreviewUrl] = useState(null); + + // QR preview (#836) — fetched as a blob because the admin API needs the + // Bearer token; a plain would come back 401. The `stale` flag + // guards the async gap: without it, a response landing after unmount or + // an event switch would leak its object URL and could overwrite a newer + // event's preview with the previous gallery's QR (codex review of #847). + useEffect(() => { + if (!event.share_link) return; + let stale = false; + let objectUrl: string | null = null; + eventsService.getQrBlob(event.id, 'png', 300) + .then((blob) => { + const url = URL.createObjectURL(blob); + if (stale) { + URL.revokeObjectURL(url); + return; + } + objectUrl = url; + setQrPreviewUrl(url); + }) + .catch(() => { + if (!stale) setQrPreviewUrl(null); + }); + return () => { + stale = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [event.id, event.share_link]); + + const handleQrDownload = async (kind: 'png' | 'svg' | 'table-card' | 'poster') => { + try { + if (kind === 'png' || kind === 'svg') { + saveBlob(await eventsService.getQrBlob(event.id, kind, 1024), `qr-${event.slug}.${kind}`); + } else { + const lang = (i18n.language || 'en').split('-')[0]; + saveBlob(await eventsService.getQrPrintBlob(event.id, kind, lang), `qr-${kind}-${event.slug}.pdf`); + } + } catch { + toast.error(t('errors.downloadFailed', 'Download failed')); + } + }; const handleCopyLink = async () => { try { @@ -83,6 +136,45 @@ export const ShareLinkCard: React.FC = ({ event, setShowPass : t('events.shareWithGuests')}

+ {event.share_link && ( +
+

+ + {t('events.qrCode', 'QR code')} +

+ {/* Stacks on phones; downloads stay available even when the preview + request failed — the section keys off share-link availability, + not off a successfully loaded preview (codex review of #847). */} +
+ {qrPreviewUrl ? ( + {t('events.qrCode', + ) : ( +
+ +
+ )} +
+ + + + +
+
+
+ )} + {!event.is_archived && (