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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Что-то пошло не так",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user