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 ? (
+
+ ) : (
+
+
+
+ )}
+
+ } onClick={() => handleQrDownload('png')}>
+ PNG
+
+ } onClick={() => handleQrDownload('svg')}>
+ SVG
+
+ } onClick={() => handleQrDownload('table-card')}>
+ {t('events.qrTableCard', 'Table card (A6)')}
+
+ } onClick={() => handleQrDownload('poster')}>
+ {t('events.qrPoster', 'Poster (A4)')}
+
+
+
+
+ )}
+
{!event.is_archived && (
would not carry the token. `origin` is passed so
+ // the backend can fall back to the admin browser's origin when the
+ // configured FRONTEND_URL is missing/localhost — the QR must encode the
+ // same URL the share-link card displays.
+ async getQrBlob(eventId: number, format: 'png' | 'svg', size?: number): Promise {
+ const { data } = await api.get(`/admin/events/${eventId}/qr`, {
+ params: { format, size, origin: window.location.origin },
+ responseType: 'blob',
+ });
+ return data;
+ },
+
+ async getQrPrintBlob(eventId: number, template: 'table-card' | 'poster', lang: string): Promise {
+ const { data } = await api.get(`/admin/events/${eventId}/qr-print`, {
+ params: { template, lang, origin: window.location.origin },
+ responseType: 'blob',
+ });
+ return data;
+ },
};