feat(events): gallery QR code + printable table-card/poster PDFs (#847)
* feat(events): gallery QR code + printable table-card/poster PDFs (#836) - GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or SVG, inline or attachment; adminAuth + events.view + ownership. - GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card / A4 poster with event name, QR, localized caption (8 locales; Cyrillic falls back to English — built-in Helvetica has no Cyrillic glyphs) and the share URL as footer. - Event detail: QR section in ShareLinkCard with live preview (blob fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print language follows the admin UI language. i18n keys in all 8 locales. - qrcode + pdfkit were already dependencies (MFA / CRM PDFs). * fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847) - QR URLs: prefer the configured public base, but fall back to the admin browser's origin (passed as ?origin=, validated) when the base is missing or localhost — mirrors buildShareLinkUrl so the QR encodes the same URL the card displays instead of an unusable localhost target. - PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek) instead of WinAnsi-only Helvetica: Cyrillic event names no longer silently disappear, and the caption's English-fallback hack is gone. - Fixed vertical layout: title gets a bounded two-line ellipsis region and all positions derive from constants, so long event names can't push the QR/caption over the footer; URL footer bounded too. - ShareLinkCard preview: stale-response guard — a late blob response after unmount/event-switch is revoked instead of leaking and overwriting the newer event's QR. * fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2) Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix Cyrillic titles and regressed the ru caption. Now bundling the complete IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap verified via fontkit, rendering verified on a generated PDF) under assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside. ~400 KB total; source: IBM/plex release zip @ibm/[email protected]. Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like the frontend's own URL logic does. Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only subsets and share the Cyrillic gap. * fix(events): responsive QR card that survives preview failures (codex review of #847, round 3) - The QR section keys off share-link availability instead of a loaded preview: a transient failure of the preview request no longer hides every download button until reload; a placeholder tile renders in place of the image. - Preview + actions stack on phone widths and the button grid drops to one column below sm, so 'Tischkarte (A6)'-length labels don't overflow. * fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round) - The QR target is now the STORED share_link — exactly what the card displays and the admin copies. Rebuilding from current slug/token/ short-URL setting could diverge for legacy absolute links or events created under a different short-URL setting; a printed QR encoding a different URL than the card is a permanent mistake. Rebuild remains only as fallback when no share_link is stored. - QR margin back to the library's 4-module default for all generated assets — the spec's quiet zone; margin 2 risks scan failures when the printout sits against colored surroundings. * fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round) Quote-/contract-converted events persist share_link as the raw token — the frontend's buildShareLinkUrl prefixes those with /gallery/, but the QR path normalization only added a leading slash, encoding <origin>/<token> into every image/PDF for such events. Now mirrors the frontend exactly. * test(events): 30s timeout for the print-PDF cases (CI fix) The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each); font parsing + subsetting exceeds jest's 5s default on slower CI runners — the suite went red on exactly that test after the font commit.
This commit is contained in:
@@ -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: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
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('<svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets attachment disposition with download=1', async () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
+93
@@ -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.
|
||||||
@@ -13,5 +13,6 @@ require('./slideshow')(router);
|
|||||||
require('./resets')(router);
|
require('./resets')(router);
|
||||||
require('./archiveBulk')(router);
|
require('./archiveBulk')(router);
|
||||||
require('./logo')(router);
|
require('./logo')(router);
|
||||||
|
require('./qr')(router);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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/<token> — 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -996,6 +996,9 @@
|
|||||||
"archived": "Archiviert",
|
"archived": "Archiviert",
|
||||||
"totalSize": "Gesamtgröße",
|
"totalSize": "Gesamtgröße",
|
||||||
"shareLink": "Freigabelink",
|
"shareLink": "Freigabelink",
|
||||||
|
"qrCode": "QR-Code",
|
||||||
|
"qrTableCard": "Tischkarte (A6)",
|
||||||
|
"qrPoster": "Poster (A4)",
|
||||||
"copyLink": "Link kopieren",
|
"copyLink": "Link kopieren",
|
||||||
"linkCopied": "Link kopiert!",
|
"linkCopied": "Link kopiert!",
|
||||||
"viewGallery": "Galerie ansehen",
|
"viewGallery": "Galerie ansehen",
|
||||||
@@ -2928,6 +2931,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Download fehlgeschlagen",
|
||||||
"galleryNotFound": "Galerie nicht gefunden",
|
"galleryNotFound": "Galerie nicht gefunden",
|
||||||
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
|
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
|
||||||
"somethingWentWrong": "Etwas ist schiefgelaufen",
|
"somethingWentWrong": "Etwas ist schiefgelaufen",
|
||||||
|
|||||||
@@ -543,6 +543,9 @@
|
|||||||
"archived": "Archived",
|
"archived": "Archived",
|
||||||
"totalSize": "Total Size",
|
"totalSize": "Total Size",
|
||||||
"shareLink": "Share Link",
|
"shareLink": "Share Link",
|
||||||
|
"qrCode": "QR code",
|
||||||
|
"qrTableCard": "Table card (A6)",
|
||||||
|
"qrPoster": "Poster (A4)",
|
||||||
"copyLink": "Copy Link",
|
"copyLink": "Copy Link",
|
||||||
"linkCopied": "Link copied!",
|
"linkCopied": "Link copied!",
|
||||||
"viewGallery": "View Gallery",
|
"viewGallery": "View Gallery",
|
||||||
@@ -2506,6 +2509,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Download failed",
|
||||||
"galleryNotFound": "Gallery Not Found",
|
"galleryNotFound": "Gallery Not Found",
|
||||||
"galleryNotFoundMessage": "This gallery does not exist or has been removed.",
|
"galleryNotFoundMessage": "This gallery does not exist or has been removed.",
|
||||||
"somethingWentWrong": "Something went wrong",
|
"somethingWentWrong": "Something went wrong",
|
||||||
|
|||||||
@@ -393,6 +393,9 @@
|
|||||||
"photoCount": "{{count}} fotos",
|
"photoCount": "{{count}} fotos",
|
||||||
"totalSize": "Tamaño total",
|
"totalSize": "Tamaño total",
|
||||||
"shareLink": "Enlace para compartir",
|
"shareLink": "Enlace para compartir",
|
||||||
|
"qrCode": "Código QR",
|
||||||
|
"qrTableCard": "Tarjeta de mesa (A6)",
|
||||||
|
"qrPoster": "Póster (A4)",
|
||||||
"copyLink": "Copiar enlace",
|
"copyLink": "Copiar enlace",
|
||||||
"linkCopied": "Enlace copiado!",
|
"linkCopied": "Enlace copiado!",
|
||||||
"viewGallery": "Ver galería",
|
"viewGallery": "Ver galería",
|
||||||
@@ -1608,6 +1611,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Error al descargar",
|
||||||
"notFound": "No encontrado",
|
"notFound": "No encontrado",
|
||||||
"galleryNotFound": "Galería no encontrada",
|
"galleryNotFound": "Galería no encontrada",
|
||||||
"galleryNotFoundMessage": "Esta galería no existe o ha sido eliminada.",
|
"galleryNotFoundMessage": "Esta galería no existe o ha sido eliminada.",
|
||||||
|
|||||||
@@ -387,6 +387,9 @@
|
|||||||
"archived": "Archivé",
|
"archived": "Archivé",
|
||||||
"totalSize": "Taille totale",
|
"totalSize": "Taille totale",
|
||||||
"shareLink": "Partager le lien",
|
"shareLink": "Partager le lien",
|
||||||
|
"qrCode": "Code QR",
|
||||||
|
"qrTableCard": "Carte de table (A6)",
|
||||||
|
"qrPoster": "Affiche (A4)",
|
||||||
"copyLink": "Copier le lien",
|
"copyLink": "Copier le lien",
|
||||||
"linkCopied": "Lien copié !",
|
"linkCopied": "Lien copié !",
|
||||||
"viewGallery": "Voir la galerie",
|
"viewGallery": "Voir la galerie",
|
||||||
@@ -1860,6 +1863,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Échec du téléchargement",
|
||||||
"galleryNotFound": "Galerie introuvable",
|
"galleryNotFound": "Galerie introuvable",
|
||||||
"galleryNotFoundMessage": "Cette galerie n'existe pas ou a été supprimée.",
|
"galleryNotFoundMessage": "Cette galerie n'existe pas ou a été supprimée.",
|
||||||
"somethingWentWrong": "Quelque chose s'est mal passé",
|
"somethingWentWrong": "Quelque chose s'est mal passé",
|
||||||
|
|||||||
@@ -387,6 +387,9 @@
|
|||||||
"archived": "Gearchiveerd",
|
"archived": "Gearchiveerd",
|
||||||
"totalSize": "Totale grootte",
|
"totalSize": "Totale grootte",
|
||||||
"shareLink": "Deellink",
|
"shareLink": "Deellink",
|
||||||
|
"qrCode": "QR-code",
|
||||||
|
"qrTableCard": "Tafelkaart (A6)",
|
||||||
|
"qrPoster": "Poster (A4)",
|
||||||
"copyLink": "Link kopieren",
|
"copyLink": "Link kopieren",
|
||||||
"linkCopied": "Link gekopieerd!",
|
"linkCopied": "Link gekopieerd!",
|
||||||
"viewGallery": "Galerij bekijken",
|
"viewGallery": "Galerij bekijken",
|
||||||
@@ -1849,6 +1852,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Downloaden mislukt",
|
||||||
"galleryNotFound": "Galerij niet gevonden",
|
"galleryNotFound": "Galerij niet gevonden",
|
||||||
"galleryNotFoundMessage": "Deze galerij bestaat niet of is verwijderd.",
|
"galleryNotFoundMessage": "Deze galerij bestaat niet of is verwijderd.",
|
||||||
"somethingWentWrong": "Er is iets misgegaan",
|
"somethingWentWrong": "Er is iets misgegaan",
|
||||||
|
|||||||
@@ -395,6 +395,9 @@
|
|||||||
"archived": "Arquivado",
|
"archived": "Arquivado",
|
||||||
"totalSize": "Tamanho Total",
|
"totalSize": "Tamanho Total",
|
||||||
"shareLink": "Link de Compartilhamento",
|
"shareLink": "Link de Compartilhamento",
|
||||||
|
"qrCode": "Código QR",
|
||||||
|
"qrTableCard": "Cartão de mesa (A6)",
|
||||||
|
"qrPoster": "Pôster (A4)",
|
||||||
"copyLink": "Copiar Link",
|
"copyLink": "Copiar Link",
|
||||||
"linkCopied": "Link copiado!",
|
"linkCopied": "Link copiado!",
|
||||||
"viewGallery": "Ver Galeria",
|
"viewGallery": "Ver Galeria",
|
||||||
@@ -1874,6 +1877,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Falha no download",
|
||||||
"galleryNotFound": "Galeria Não Encontrada",
|
"galleryNotFound": "Galeria Não Encontrada",
|
||||||
"galleryNotFoundMessage": "Esta galeria não existe ou foi removida.",
|
"galleryNotFoundMessage": "Esta galeria não existe ou foi removida.",
|
||||||
"somethingWentWrong": "Algo deu errado",
|
"somethingWentWrong": "Algo deu errado",
|
||||||
|
|||||||
@@ -403,6 +403,9 @@
|
|||||||
"archived": "В архиве",
|
"archived": "В архиве",
|
||||||
"totalSize": "Общий размер",
|
"totalSize": "Общий размер",
|
||||||
"shareLink": "Ссылка для доступа",
|
"shareLink": "Ссылка для доступа",
|
||||||
|
"qrCode": "QR-код",
|
||||||
|
"qrTableCard": "Карточка на стол (A6)",
|
||||||
|
"qrPoster": "Постер (A4)",
|
||||||
"copyLink": "Копировать ссылку",
|
"copyLink": "Копировать ссылку",
|
||||||
"linkCopied": "Ссылка скопирована!",
|
"linkCopied": "Ссылка скопирована!",
|
||||||
"viewGallery": "Просмотреть галерею",
|
"viewGallery": "Просмотреть галерею",
|
||||||
@@ -1899,6 +1902,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Ошибка загрузки",
|
||||||
"galleryNotFound": "Галерея не найдена",
|
"galleryNotFound": "Галерея не найдена",
|
||||||
"galleryNotFoundMessage": "Эта галерея не существует или была удалена.",
|
"galleryNotFoundMessage": "Эта галерея не существует или была удалена.",
|
||||||
"somethingWentWrong": "Что-то пошло не так",
|
"somethingWentWrong": "Что-то пошло не так",
|
||||||
|
|||||||
@@ -387,6 +387,9 @@
|
|||||||
"archived": "Arhiviran",
|
"archived": "Arhiviran",
|
||||||
"totalSize": "Skupna velikost",
|
"totalSize": "Skupna velikost",
|
||||||
"shareLink": "Povezava za deljenje",
|
"shareLink": "Povezava za deljenje",
|
||||||
|
"qrCode": "QR-koda",
|
||||||
|
"qrTableCard": "Namizna kartica (A6)",
|
||||||
|
"qrPoster": "Plakat (A4)",
|
||||||
"copyLink": "Kopiraj povezavo",
|
"copyLink": "Kopiraj povezavo",
|
||||||
"linkCopied": "Povezava kopirana!",
|
"linkCopied": "Povezava kopirana!",
|
||||||
"viewGallery": "Ogled galerije",
|
"viewGallery": "Ogled galerije",
|
||||||
@@ -1849,6 +1852,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"downloadFailed": "Prenos ni uspel",
|
||||||
"galleryNotFound": "Galerija ni najdena",
|
"galleryNotFound": "Galerija ni najdena",
|
||||||
"galleryNotFoundMessage": "Ta galerija ne obstaja ali je bila odstranjena.",
|
"galleryNotFoundMessage": "Ta galerija ne obstaja ali je bila odstranjena.",
|
||||||
"somethingWentWrong": "Nekaj je šlo narobe",
|
"somethingWentWrong": "Nekaj je šlo narobe",
|
||||||
|
|||||||
@@ -1,21 +1,74 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
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 type { Event } from '../../../types';
|
||||||
import { Button, Card } from '../../../components/common';
|
import { Button, Card } from '../../../components/common';
|
||||||
import { eventsService } from '../../../services/events.service';
|
import { eventsService } from '../../../services/events.service';
|
||||||
import { buildShareLinkUrl } from '../../../utils/url';
|
import { buildShareLinkUrl } from '../../../utils/url';
|
||||||
import { isGalleryPublic } from '../../../utils/accessControl';
|
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 {
|
interface ShareLinkCardProps {
|
||||||
event: Event;
|
event: Event;
|
||||||
setShowPasswordReset: (show: boolean) => void;
|
setShowPasswordReset: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
|
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
const [qrPreviewUrl, setQrPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// QR preview (#836) — fetched as a blob because the admin API needs the
|
||||||
|
// Bearer token; a plain <img src> 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 () => {
|
const handleCopyLink = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +136,45 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
|
|||||||
: t('events.shareWithGuests')}
|
: t('events.shareWithGuests')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{event.share_link && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||||
|
<QrCode className="w-4 h-4" />
|
||||||
|
{t('events.qrCode', 'QR code')}
|
||||||
|
</h3>
|
||||||
|
{/* 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). */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start gap-4">
|
||||||
|
{qrPreviewUrl ? (
|
||||||
|
<img
|
||||||
|
src={qrPreviewUrl}
|
||||||
|
alt={t('events.qrCode', 'QR code')}
|
||||||
|
className="w-28 h-28 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white p-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-28 h-28 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-700 flex items-center justify-center">
|
||||||
|
<QrCode className="w-8 h-8 text-neutral-300 dark:text-neutral-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('png')}>
|
||||||
|
PNG
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('svg')}>
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('table-card')}>
|
||||||
|
{t('events.qrTableCard', 'Table card (A6)')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('poster')}>
|
||||||
|
{t('events.qrPoster', 'Poster (A4)')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!event.is_archived && (
|
{!event.is_archived && (
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -302,4 +302,25 @@ export const eventsService = {
|
|||||||
const response = await api.post(`/admin/events/${eventId}/rename`, { newEventName, resendEmail });
|
const response = await api.post(`/admin/events/${eventId}/rename`, { newEventName, resendEmail });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Gallery QR code (#836). Admin API uses Bearer auth, so images are fetched
|
||||||
|
// as blobs — an <img src> 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<Blob> {
|
||||||
|
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<Blob> {
|
||||||
|
const { data } = await api.get(`/admin/events/${eventId}/qr-print`, {
|
||||||
|
params: { template, lang, origin: window.location.origin },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user