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:
Paul Nothaft
2026-07-19 22:35:44 +02:00
committed by GitHub
parent 613133c29c
commit 60cdd07085
16 changed files with 547 additions and 3 deletions
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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.",
+4
View File
@@ -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é",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "Что-то пошло не так",
+4
View File
@@ -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",
@@ -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<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
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 () => {
try {
@@ -83,6 +136,45 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
: t('events.shareWithGuests')}
</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 && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
<Button
+21
View File
@@ -302,4 +302,25 @@ export const eventsService = {
const response = await api.post(`/admin/events/${eventId}/rename`, { newEventName, resendEmail });
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;
},
};