Files
fotospiel-app/resources/js/admin/pages/EventInvitesPage.tsx

1736 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart, Save, Plus } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import toast from 'react-hot-toast';
import { AdminLayout } from '../components/AdminLayout';
import {
createQrInvite,
EventQrInvite,
getEvent,
getEventQrInvites,
revokeEventQrInvite,
TenantEvent,
updateEventQrInvite,
EventQrInviteLayout,
createEventAddonCheckout,
getAddonCatalog,
type EventAddonCatalogItem,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { buildEventTabs } from '../lib/eventTabs';
import { getApiErrorMessage } from '../lib/apiError';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
buildDefaultElements,
clamp,
normalizeElements,
payloadToElements,
LayoutElement,
} from './components/invite-layout/schema';
import {
generatePdfBytes,
generatePngDataUrl,
openPdfInNewTab,
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
import { preloadedBackgrounds } from './components/invite-layout/backgrounds';
import { useOnboardingProgress } from '../onboarding';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
interface PageState {
event: TenantEvent | null;
invites: EventQrInvite[];
loading: boolean;
error: string | null;
}
type TabKey = 'layout' | 'share' | 'export';
function resolveTabKey(value: string | null): TabKey {
if (value === 'export') {
return 'export';
}
if (value === 'share' || value === 'links') {
return 'share';
}
return 'layout';
}
const HEX_COLOR_FULL = /^#([0-9A-Fa-f]{6})$/;
const HEX_COLOR_SHORT = /^#([0-9A-Fa-f]{3})$/;
function normalizeHexColor(value?: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (HEX_COLOR_FULL.test(trimmed)) {
return trimmed.toUpperCase();
}
if (HEX_COLOR_SHORT.test(trimmed)) {
const [, shorthand] = HEX_COLOR_SHORT.exec(trimmed)!;
const expanded = shorthand
.split('')
.map((char) => char + char)
.join('');
return `#${expanded}`.toUpperCase();
}
return null;
}
function normalizeGradient(value: unknown): { angle: number; stops: string[] } | null {
if (!value || typeof value !== 'object') {
return null;
}
const gradient = value as { angle?: unknown; stops?: unknown };
const angle = typeof gradient.angle === 'number' ? gradient.angle : 180;
const stops = Array.isArray(gradient.stops)
? gradient.stops
.map((stop) => normalizeHexColor(typeof stop === 'string' ? stop : null))
.filter((stop): stop is string => Boolean(stop))
: [];
return stops.length ? { angle, stops } : null;
}
function buildBackgroundStyle(background: string | null, gradient: { angle: number; stops: string[] } | null): React.CSSProperties {
if (gradient) {
return { backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(', ')})` };
}
return { backgroundColor: background ?? '#F8FAFC' };
}
function toStringList(value: unknown): string[] {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>)
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
return [];
}
function ensureInstructionList(value: unknown, fallback: string[]): string[] {
const source = toStringList(value);
const base = source.length ? source : fallback;
return base
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0)
.slice(0, 5);
}
function formatPaperLabel(paper?: string | null): string {
if (!paper) {
return 'A4';
}
return paper.toUpperCase();
}
function formatQrSizeLabel(sizePx: number | null, fallback: string): string {
if (!sizePx || Number.isNaN(sizePx)) {
return fallback;
}
return `${sizePx}px`;
}
export default function EventInvitesPage(): React.ReactElement {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
const [state, setState] = React.useState<PageState>({ event: null, invites: [], loading: true, error: null });
const [creatingInvite, setCreatingInvite] = React.useState(false);
const [revokingId, setRevokingId] = React.useState<number | null>(null);
const [selectedInviteId, setSelectedInviteId] = React.useState<number | null>(null);
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
const [customizerSaving, setCustomizerSaving] = React.useState(false);
const [customizerResetting, setCustomizerResetting] = React.useState(false);
const [customizerDraft, setCustomizerDraft] = React.useState<QrLayoutCustomization | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const initialTab = resolveTabKey(tabParam);
const [activeTab, setActiveTab] = React.useState<TabKey>(initialTab);
const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null);
const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null);
const [exportError, setExportError] = React.useState<string | null>(null);
const exportPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const [exportScale, setExportScale] = React.useState(0.34);
const { markStep } = useOnboardingProgress();
const load = React.useCallback(async () => {
if (!slug) {
setState({ event: null, invites: [], loading: false, error: 'Kein Event-Slug angegeben.' });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, invitesData, catalog] = await Promise.all([
getEvent(slug),
getEventQrInvites(slug),
getAddonCatalog(),
]);
setState({ event: eventData, invites: invitesData, loading: false, error: null });
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
setAddonsCatalog(catalog);
} catch (error) {
if (!isAuthError(error)) {
setState({ event: null, invites: [], loading: false, error: 'QR-QR-Code konnten nicht geladen werden.' });
}
}
}, [slug]);
const recomputeExportScale = React.useCallback(() => {
const container = exportPreviewContainerRef.current;
if (!container) {
return;
}
const widthRatio = container.clientWidth / CANVAS_WIDTH;
const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY;
const portraitRatio = 1754 / 1240; // A4 height/width for portrait priority
const adjustedHeightRatio = heightRatio * portraitRatio;
const base = Math.min(widthRatio, adjustedHeightRatio);
const safeBase = Number.isFinite(base) && base > 0 ? base : 1;
const clampedScale = clamp(safeBase, 0.1, 1);
setExportScale((prev) => (Math.abs(prev - clampedScale) < 0.001 ? prev : clampedScale));
}, []);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
recomputeExportScale();
}, [recomputeExportScale]);
React.useEffect(() => {
const handleResize = () => recomputeExportScale();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [recomputeExportScale]);
React.useEffect(() => {
const nextTab = resolveTabKey(searchParams.get('tab'));
setActiveTab((current) => (current === nextTab ? current : nextTab));
}, [searchParams]);
const handleTabChange = React.useCallback(
(value: string) => {
const nextTab = resolveTabKey(value);
setActiveTab(nextTab);
const nextParams = new URLSearchParams(searchParams);
if (nextTab === 'layout') {
nextParams.delete('tab');
} else {
nextParams.set('tab', nextTab === 'share' ? 'share' : 'export');
}
setSearchParams(nextParams, { replace: true });
},
[searchParams, setSearchParams]
);
const event = state.event;
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
const eventDate = event?.event_date ?? null;
const eventTabs = React.useMemo(() => {
if (!event || !slug) {
return [];
}
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, {
photos: event.photo_count ?? event.pending_photo_count ?? undefined,
tasks: event.tasks_count ?? undefined,
});
}, [event, slug, state.invites.length, t]);
const selectedInvite = React.useMemo(
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
[state.invites, selectedInviteId]
);
React.useEffect(() => {
setExportError(null);
setExportDownloadBusy(null);
setExportPrintBusy(null);
}, [selectedInvite?.id]);
React.useEffect(() => {
if (state.invites.length === 0) {
setSelectedInviteId(null);
return;
}
setSelectedInviteId((current) => {
if (current && state.invites.some((invite) => invite.id === current)) {
return current;
}
return state.invites[0]?.id ?? null;
});
}, [state.invites]);
React.useEffect(() => {
setCustomizerDraft(null);
}, [selectedInviteId]);
const currentCustomization = React.useMemo(() => {
if (!selectedInvite) {
return null;
}
const metadata = selectedInvite.metadata as Record<string, unknown> | undefined | null;
const raw = metadata?.layout_customization;
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
}, [selectedInvite]);
const effectiveCustomization = customizerDraft ?? currentCustomization;
const exportLayout = React.useMemo(() => {
if (!selectedInvite || selectedInvite.layouts.length === 0) {
return null;
}
const targetId = effectiveCustomization?.layout_id;
if (targetId) {
const match = selectedInvite.layouts.find((layout) => layout.id === targetId);
if (match) {
return match;
}
}
return selectedInvite.layouts[0];
}, [selectedInvite, effectiveCustomization?.layout_id]);
const exportPreview = React.useMemo(() => {
if (!exportLayout || !selectedInvite) {
return null;
}
const customization = effectiveCustomization ?? null;
const layoutPreview = exportLayout.preview ?? {};
const backgroundColor = normalizeHexColor(customization?.background_color ?? (layoutPreview.background as string | undefined)) ?? '#F8FAFC';
const accentColor = normalizeHexColor(customization?.accent_color ?? (layoutPreview.accent as string | undefined)) ?? '#6366F1';
const textColor = normalizeHexColor(customization?.text_color ?? (layoutPreview.text as string | undefined)) ?? '#111827';
const secondaryColor = '#1F2937';
const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor;
const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null);
const backgroundImage = customization?.background_image ?? null;
const instructions = ensureInstructionList(customization?.instructions, exportLayout.instructions ?? []);
const workflowSteps = toStringList(t('invites.export.workflow.steps', { returnObjects: true }));
const tips = toStringList(t('invites.export.tips.items', { returnObjects: true }));
const formatKeys = exportLayout.formats ?? [];
const formatBadges = formatKeys.map((format) => String(format).toUpperCase());
const formatLabel = formatBadges.length ? formatBadges.join(' · ') : t('invites.export.meta.formatsNone', 'Keine Formate hinterlegt');
const qrSizePx = (layoutPreview.qr_size_px as number | undefined) ?? 480;
return {
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
backgroundColor,
backgroundGradient: gradient,
backgroundImage,
badgeLabel: customization?.badge_label?.trim() || t('tasks.customizer.defaults.badgeLabel'),
badgeColor,
badgeTextColor: '#FFFFFF',
accentColor,
textColor,
secondaryColor,
headline: customization?.headline?.trim() || eventName,
subtitle: customization?.subtitle?.trim() || exportLayout.subtitle || '',
description: customization?.description?.trim() || exportLayout.description || '',
instructionsHeading: customization?.instructions_heading?.trim() || t('tasks.customizer.defaults.instructionsHeading'),
instructions: instructions.slice(0, 4),
linkHeading: customization?.link_heading?.trim() || t('tasks.customizer.defaults.linkHeading'),
linkLabel: (customization?.link_label?.trim() || selectedInvite.url || ''),
ctaLabel: customization?.cta_label?.trim() || t('tasks.customizer.defaults.ctaLabel'),
layoutLabel: exportLayout.name || t('invites.customizer.layoutFallback', 'Layout'),
layoutSubtitle: exportLayout.subtitle || '',
formatLabel,
formatBadges,
formats: formatKeys,
paperLabel: formatPaperLabel('a4'),
orientationLabel: t('invites.export.meta.orientationPortrait', 'Hochformat'),
qrSizeLabel: formatQrSizeLabel(qrSizePx, t('invites.export.meta.qrSizeFallback', 'Automatisch')),
lastUpdated: selectedInvite.created_at ? formatDateTime(selectedInvite.created_at) : null,
mode: customization?.mode === 'advanced' ? 'advanced' : 'standard',
workflowSteps: workflowSteps.length
? workflowSteps
: [
t('invites.export.workflow.default1', 'Testdruck ausführen und Farben prüfen.'),
t('invites.export.workflow.default2', 'Ausdrucke laminieren oder in Schutzfolien stecken.'),
t('invites.export.workflow.default3', 'Mehrere QR-Codes im Eingangsbereich und an Hotspots platzieren.'),
],
tips: tips.length
? tips
: [
t('invites.export.tips.default1', 'Nutze Papier mit mindestens 160 g/m² für langlebige Ausdrucke.'),
t('invites.export.tips.default2', 'Drucke einen QR-Code zur Sicherheit in Reserve aus.'),
t('invites.export.tips.default3', 'Fotografiere den gedruckten QR-Code testweise, um die Lesbarkeit zu prüfen.'),
],
};
}, [exportLayout, effectiveCustomization, selectedInvite, eventName, t]);
const exportElements = React.useMemo<LayoutElement[]>(() => {
if (!exportLayout) {
return [];
}
if (effectiveCustomization?.mode === 'advanced' && Array.isArray(effectiveCustomization.elements) && effectiveCustomization.elements.length) {
return normalizeElements(payloadToElements(effectiveCustomization.elements));
}
const baseForm: QrLayoutCustomization = {
...effectiveCustomization,
layout_id: exportLayout.id,
link_label: effectiveCustomization?.link_label ?? selectedInvite?.url ?? '',
badge_label: effectiveCustomization?.badge_label ?? exportLayout.badge_label ?? undefined,
instructions: ensureInstructionList(effectiveCustomization?.instructions, exportLayout.instructions ?? []),
instructions_heading: effectiveCustomization?.instructions_heading ?? exportLayout.instructions_heading ?? undefined,
logo_data_url: effectiveCustomization?.logo_data_url ?? undefined,
logo_url: effectiveCustomization?.logo_url ?? undefined,
};
return buildDefaultElements(
exportLayout,
baseForm,
eventName,
exportLayout.preview?.qr_size_px ?? 480
);
}, [exportLayout, effectiveCustomization, selectedInvite?.url, eventName]);
React.useEffect(() => {
if (activeTab !== 'export') {
return;
}
recomputeExportScale();
}, [activeTab, recomputeExportScale, exportElements.length, exportLayout?.id, selectedInvite?.id]);
React.useEffect(() => {
if (typeof ResizeObserver !== 'function') {
return undefined;
}
const target = exportPreviewContainerRef.current;
if (!target) {
return undefined;
}
const observer = new ResizeObserver(() => recomputeExportScale());
observer.observe(target);
return () => observer.disconnect();
}, [recomputeExportScale, activeTab]);
const exportCanvasKey = React.useMemo(
() => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`,
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
);
const exportLogo = effectiveCustomization?.logo_data_url ?? effectiveCustomization?.logo_url ?? null;
const exportQr = selectedInvite?.qr_code_data_url ?? null;
const handlePreviewSelect = React.useCallback(() => undefined, []);
const handlePreviewChange = React.useCallback(() => undefined, []);
const handleCustomizerDraftChange = React.useCallback((draft: QrLayoutCustomization | null) => {
setCustomizerDraft((previous) => {
const prevSignature = previous ? JSON.stringify(previous) : null;
const nextSignature = draft ? JSON.stringify(draft) : null;
if (prevSignature === nextSignature) {
return previous;
}
return draft;
});
}, []);
const inviteCountSummary = React.useMemo(() => {
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
const total = state.invites.length;
return { active, total };
}, [state.invites]);
const primaryInvite = React.useMemo(() => selectedInvite ?? state.invites[0] ?? null, [selectedInvite, state.invites]);
const workflowSteps = React.useMemo<InviteWorkflowStep[]>(() => {
const layoutReady = Boolean(effectiveCustomization);
const shareReady = state.invites.length > 0;
const exportReady = Boolean(exportPreview && exportElements.length);
const mapStatus = (tab: TabKey, done: boolean) => {
if (done) return 'done';
if (activeTab === tab) return 'active';
return 'pending';
};
return [
{
key: 'layout',
title: t('invites.workflow.steps.layout.title', 'Vorlage wählen'),
description: t('invites.workflow.steps.layout.description', 'Wähle ein Layout und passe Texte, Farben und QR-Elemente an.'),
status: mapStatus('layout', layoutReady),
},
{
key: 'share',
title: t('invites.workflow.steps.share.title', 'Links & QR teilen'),
description: t('invites.workflow.steps.share.description', 'Aktiviere Gästelinks, kopiere QR-Codes und verteile sie im Team.'),
status: mapStatus('share', shareReady),
},
{
key: 'export',
title: t('invites.workflow.steps.export.title', 'Drucken & Export'),
description: t('invites.workflow.steps.export.description', 'Erzeuge PDFs oder PNGs für den Druck deiner Karten.'),
status: mapStatus('export', exportReady),
},
];
}, [activeTab, effectiveCustomization, exportElements.length, exportPreview, state.invites.length, t]);
async function handleCreateInvite() {
if (!slug || creatingInvite) {
return;
}
setCreatingInvite(true);
setState((prev) => ({ ...prev, error: null }));
try {
const invite = await createQrInvite(slug);
setState((prev) => ({
...prev,
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
}));
setSelectedInviteId(invite.id);
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch {
// ignore clipboard failures
}
toast.success(t('invites.actions.created', 'QR-Code erstellt'));
markStep({
lastStep: 'invite',
serverStep: 'invite_created',
meta: { invite_id: invite.id },
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht erstellt werden.' }));
toast.error(t('invites.actions.createFailed', 'QR-Code konnte nicht erstellt werden.'));
}
} finally {
setCreatingInvite(false);
}
}
async function handleCopy(invite: EventQrInvite) {
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch (error) {
console.warn('[Invites] Clipboard copy failed', error);
toast.error(t('invites.actions.copyFailed', 'Link konnte nicht kopiert werden.'));
}
}
React.useEffect(() => {
if (!copiedInviteId) return;
const timeout = setTimeout(() => setCopiedInviteId(null), 3000);
return () => clearTimeout(timeout);
}, [copiedInviteId]);
async function handleRevoke(invite: EventQrInvite) {
if (!slug || invite.revoked_at) {
return;
}
setRevokingId(invite.id);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await revokeEventQrInvite(slug, invite.id);
setState((prev) => ({
...prev,
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
}));
if (selectedInviteId === invite.id && !updated.is_active) {
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
}
toast.success(t('invites.actions.revoked', 'QR-Code deaktiviert'));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht deaktiviert werden.' }));
toast.error(t('invites.actions.revokeFailed', 'QR-Code konnte nicht deaktiviert werden.'));
}
} finally {
setRevokingId(null);
}
}
async function handleSaveCustomization(customization: QrLayoutCustomization) {
if (!slug || !selectedInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
metadata: { layout_customization: customization },
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
toast.success(t('invites.customizer.toastSaved', 'Layout gespeichert'));
markStep({
lastStep: 'branding',
serverStep: 'branding_configured',
meta: {
invite_id: selectedInvite.id,
has_custom_branding: true,
},
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
toast.error(t('invites.customizer.toastSaveFailed', 'Layout konnte nicht gespeichert werden.'));
}
} finally {
setCustomizerSaving(false);
}
}
async function handleResetCustomization() {
if (!slug || !selectedInvite) {
return;
}
setCustomizerResetting(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
metadata: { layout_customization: null },
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
}
} finally {
setCustomizerResetting(false);
}
}
const handleQrDownload = React.useCallback(async () => {
if (!selectedInvite?.qr_code_data_url) {
return;
}
try {
const response = await fetch(selectedInvite.qr_code_data_url);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
const eventDateSegment = normalizeEventDateSegment(eventDate);
const downloadName = buildDownloadFilename(
['QR Code fuer', eventName, eventDateSegment],
'png',
'qr-code',
);
link.download = downloadName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (error) {
console.error('[Invites] QR download failed', error);
setExportError(t('invites.export.qr.error', 'QR-Code konnte nicht gespeichert werden.'));
}
}, [selectedInvite, eventName, eventDate, t]);
const handleExportDownload = React.useCallback(
async (format: string) => {
if (!selectedInvite || !exportLayout || !exportPreview) {
return;
}
const normalizedFormat = format.toLowerCase();
const busyKey = `${exportLayout.id}-${normalizedFormat}`;
setExportDownloadBusy(busyKey);
setExportError(null);
const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['QR-Codeslayout', eventName, exportLayout.name ?? null, eventDateSegment],
normalizedFormat,
'QR-Codeslayout',
);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
textColor: exportPreview.textColor,
secondaryColor: exportPreview.secondaryColor ?? '#1F2937',
badgeColor: exportPreview.badgeColor,
qrCodeDataUrl: exportQr,
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
backgroundImageUrl: exportPreview.backgroundImage ?? null,
readOnly: true,
selectedId: null,
} as const;
try {
if (normalizedFormat === 'png') {
const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, filename);
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
'a4',
'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
} else {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
}
} catch (error) {
console.error('[Invites] Export download failed', error);
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
} finally {
setExportDownloadBusy(null);
}
},
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, eventName, eventDate, t]
);
const handleExportPrint = React.useCallback(
async () => {
if (!selectedInvite || !exportLayout || !exportPreview) {
return;
}
setExportPrintBusy(exportLayout.id);
setExportError(null);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
textColor: exportPreview.textColor,
secondaryColor: exportPreview.secondaryColor ?? '#1F2937',
badgeColor: exportPreview.badgeColor,
qrCodeDataUrl: exportQr,
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
backgroundImageUrl: exportPreview.backgroundImage ?? null,
readOnly: true,
selectedId: null,
} as const;
try {
const pdfBytes = await generatePdfBytes(
exportOptions,
'a4',
'portrait',
);
await openPdfInNewTab(pdfBytes);
} catch (error) {
console.error('[Invites] Export print failed', error);
setExportError(t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'));
} finally {
setExportPrintBusy(null);
}
},
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
);
const actions = (
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="hover:text-foreground"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
</Button>
</div>
);
const limitWarnings = React.useMemo(
() => buildLimitWarnings(state.event?.limits, tLimits),
[state.event?.limits, tLimits]
);
const [addonBusy, setAddonBusy] = React.useState<string | null>(null);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
//const [searchParams] = useSearchParams();
const handleAddonPurchase = React.useCallback(
async (addonKey?: string) => {
if (!slug) return;
setAddonBusy('guests');
const key = addonKey ?? 'extra_guests_100';
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: key,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
}
} catch (err) {
toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.'));
} finally {
setAddonBusy(null);
}
},
[slug],
);
const fabActions = React.useMemo<FloatingAction[]>(() => {
const items: FloatingAction[] = [
{
key: 'create-invite',
label: creatingInvite ? t('invites.actions.creating', 'Erstellen...') : t('invites.actions.create', 'Neue QR-Code erstellen'),
icon: Plus,
onClick: () => { void handleCreateInvite(); },
loading: creatingInvite,
disabled: creatingInvite || state.event?.limits?.can_add_guests === false,
tone: 'primary',
},
{
key: 'refresh',
label: state.loading ? t('invites.actions.refreshing', 'Aktualisieren...') : t('invites.actions.refresh', 'Aktualisieren'),
icon: RefreshCw,
onClick: () => { void load(); },
loading: state.loading,
disabled: state.loading,
tone: 'secondary',
},
];
if (activeTab === 'layout' && selectedInvite && effectiveCustomization) {
items.unshift({
key: 'save-layout',
label: customizerSaving ? t('invites.customizer.actions.saving', 'Speichert...') : t('invites.customizer.actions.save', 'Layout speichern'),
icon: Save,
onClick: () => { void handleSaveCustomization(effectiveCustomization); },
loading: customizerSaving,
disabled: customizerSaving || customizerResetting,
tone: 'primary',
});
}
return items;
}, [activeTab, selectedInvite, effectiveCustomization, customizerSaving, customizerResetting, creatingInvite, state.event?.limits?.can_add_guests, state.loading, t, handleSaveCustomization, load]);
const limitScopeLabels = React.useMemo(
() => ({
photos: tLimits('photosTitle'),
guests: tLimits('guestsTitle'),
gallery: tLimits('galleryTitle'),
}),
[tLimits]
);
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void load();
searchParams.delete('addon_success');
navigate(window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, t]);
return (
<AdminLayout
title={eventName}
subtitle={t('invites.subtitle', 'Manage QR-Codes, Drucklayouts und Branding für deine Gäste.')}
actions={actions}
tabs={eventTabs}
currentTabKey="invites"
>
<div className="pb-28">
{limitWarnings.length > 0 && (
<div className="mb-6 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]}
</AlertTitle>
<AlertDescription className="text-sm">
{warning.message}
</AlertDescription>
</div>
{warning.scope === 'guests' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(); }}
disabled={addonBusy === 'guests'}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
</Button>
<AddonsPicker
addons={addonsCatalog}
scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'}
t={(key, fallback) => t(key, fallback)}
/>
</div>
) : null}
</div>
</Alert>
))}
</div>
)}
{state.event?.addons?.length ? (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader>
<CardContent>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
</CardContent>
</Card>
) : null}
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.share', 'Links & QR teilen')}
</TabsTrigger>
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.layout', 'Layout anpassen')}
</TabsTrigger>
<TabsTrigger value="export" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.export', 'Drucken & Export')}
</TabsTrigger>
</TabsList>
{state.error ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
) : null}
<TabsContent value="layout" className="space-y-6 focus-visible:outline-hidden">
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'QR-Codeslayout anpassen')}</h2>
<p className="text-sm text-muted-foreground">
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
</p>
</div>
</div>
{state.loading ? (
<InviteCustomizerSkeleton />
) : (
<InviteLayoutCustomizerPanel
invite={selectedInvite ?? null}
eventName={eventName}
eventDate={eventDate}
saving={customizerSaving}
resetting={customizerResetting}
onSave={handleSaveCustomization}
onReset={handleResetCustomization}
initialCustomization={currentCustomization}
draftCustomization={customizerDraft}
onDraftChange={handleCustomizerDraftChange}
backgroundImages={preloadedBackgrounds}
/>
)}
</section>
</TabsContent>
<TabsContent value="export" className="space-y-6 focus-visible:outline-hidden">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
<Printer className="h-5 w-5 text-primary" />
{t('invites.export.title', 'Drucken & Export')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.export.description', 'Überprüfe das Layout, starte Testdrucke und exportiere alle Formate.')}
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<Select
value={selectedInvite ? String(selectedInvite.id) : ''}
onValueChange={(value) => setSelectedInviteId(Number(value))}
disabled={state.invites.length === 0}
>
<SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60">
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'QR-Code auswählen')} />
</SelectTrigger>
<SelectContent>
{state.invites.map((invite) => (
<SelectItem key={invite.id} value={String(invite.id)}>
{invite.label || invite.token}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={() => void load()}
disabled={state.loading}
>
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('invites.actions.refresh', 'Aktualisieren')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{exportError ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.export.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{exportError}</AlertDescription>
</Alert>
) : null}
{selectedInvite ? (
exportPreview && exportLayout ? (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<div className="space-y-6">
<div className="rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/80 p-6 shadow-inner transition-colors">
<div className="flex flex-col gap-3 border-b border-[var(--tenant-border-strong)] pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">{exportPreview.layoutLabel}</h3>
{exportPreview.layoutSubtitle ? (
<p className="text-xs text-muted-foreground">{exportPreview.layoutSubtitle}</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="border-primary/40 bg-primary/10 text-primary">
{exportPreview.mode === 'advanced'
? t('invites.export.mode.advanced', 'Freier Editor')
: t('invites.export.mode.standard', 'Standardlayout')}
</Badge>
<span>{exportPreview.paperLabel}</span>
<span></span>
<span>{exportPreview.orientationLabel}</span>
</div>
</div>
<p className="text-xs text-muted-foreground sm:max-w-[220px]">
{t('invites.export.previewHint', 'Speichere nach Änderungen, um neue Exportdateien zu erzeugen.')}
</p>
</div>
<div className="mt-6 flex justify-center">
{exportElements.length ? (
<div
ref={exportPreviewContainerRef}
className="pointer-events-none w-full max-w-full"
>
<div className="aspect-[1240/1754] mx-auto max-w-full">
<DesignerCanvas
elements={exportElements}
selectedId={null}
onSelect={handlePreviewSelect}
onChange={handlePreviewChange}
background={exportPreview.backgroundColor}
gradient={exportPreview.backgroundGradient}
backgroundImageUrl={exportPreview.backgroundImage ?? null}
accent={exportPreview.accentColor}
text={exportPreview.textColor}
secondary={exportPreview.secondaryColor}
badge={exportPreview.badgeColor}
qrCodeDataUrl={exportQr}
logoDataUrl={exportLogo}
scale={exportScale}
layoutKey={exportCanvasKey}
readOnly
/>
</div>
</div>
) : (
<div className="rounded-3xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)]/80 px-10 py-20 text-center text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noLayoutPreview', 'Für diese Kombination liegt noch keine Vorschau vor. Speichere das Layout zuerst.')}
</div>
)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.meta.title', 'Layout-Details')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.meta.description', 'Wichtige Kennzahlen für den Druck.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.paper', 'Papierformat')}</span>
<span className="font-medium text-foreground">{exportPreview.paperLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.orientation', 'Ausrichtung')}</span>
<span className="font-medium text-foreground">{exportPreview.orientationLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.qrSize', 'QR-Code-Größe')}</span>
<span className="font-medium text-foreground">{exportPreview.qrSizeLabel}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('invites.export.meta.formats', 'Verfügbare Formate')}</span>
<div className="flex flex-wrap gap-1">
{exportPreview.formatBadges.map((item) => (
<Badge key={item} className="bg-primary/10 text-primary">
{item}
</Badge>
))}
</div>
</div>
{exportPreview.lastUpdated ? (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t('invites.export.meta.updated', 'Zuletzt aktualisiert')}</span>
<span>{exportPreview.lastUpdated}</span>
</div>
) : null}
</CardContent>
</Card>
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.workflow.title', 'Ablauf vor dem Event')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.workflow.description', 'So stellst du sicher, dass Gäste den QR-Code finden.')}</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3 text-sm text-muted-foreground">
{exportPreview.workflowSteps.map((step, index) => (
<li key={`workflow-step-${index}`} className="flex items-start gap-3">
<span className="mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{index + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</CardContent>
</Card>
</div>
</div>
<div className="space-y-6">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.actions.title', 'Aktionen')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.actions.description', 'Starte deinen Testdruck oder lade die Layouts herunter.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
size="lg"
className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/30 hover:from-amber-400 hover:via-orange-500 hover:to-rose-500"
onClick={() => void handleExportPrint()}
disabled={exportPrintBusy === exportLayout.id || Boolean(exportDownloadBusy)}
>
{exportPrintBusy === exportLayout.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Printer className="mr-2 h-4 w-4" />
)}
{t('invites.export.actions.printNow', 'Direkt drucken')}
</Button>
<div className="grid gap-2 sm:grid-cols-2">
{exportPreview.formats.map((format) => {
const key = format.toLowerCase();
const busyKey = `${exportLayout.id}-${key}`;
const isBusy = exportDownloadBusy === busyKey;
return (
<Button
key={busyKey}
variant="outline"
onClick={() => void handleExportDownload(key)}
disabled={(!!exportDownloadBusy && !isBusy) || exportPrintBusy === exportLayout.id}
className="justify-between"
>
<span>{format.toUpperCase()}</span>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</Button>
);
})}
</div>
</CardContent>
</Card>
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.qr.title', 'QR-Code & Link')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.qr.description', 'Verteile den Link digital oder erstelle weitere Auszüge.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-center">
{selectedInvite.qr_code_data_url ? (
<img
src={selectedInvite.qr_code_data_url}
alt={t('invites.export.qr.alt', 'QR-Code der QR-Code')}
className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md"
/>
) : (
<div className="flex h-40 w-40 items-center justify-center rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] text-xs text-muted-foreground">
{t('invites.export.qr.placeholder', 'QR-Code wird nach dem Speichern generiert.')}
</div>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="w-full" onClick={() => void handleQrDownload()} disabled={!selectedInvite.qr_code_data_url}>
<Download className="mr-2 h-4 w-4" />
{t('invites.export.qr.download', 'QR-Code speichern')}
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleCopy(selectedInvite)}
>
<Copy className="mr-2 h-4 w-4" />
{t('invites.export.qr.copyLink', 'Link kopieren')}
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] px-3 py-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{selectedInvite.url}</span>
</div>
</CardContent>
</Card>
<Alert className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)]/80">
<AlertTitle>{t('invites.export.tips.title', 'Tipps für perfekte Ausdrucke')}</AlertTitle>
<AlertDescription>
<ul className="mt-2 space-y-1 text-sm text-muted-foreground">
{exportPreview.tips.map((tip, index) => (
<li key={`export-tip-${index}`} className="flex gap-2">
<span></span>
<span>{tip}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noLayouts', 'Für diese QR-Code sind aktuell keine Layouts verfügbar.')}
</div>
)
) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noInviteSelected', 'Wähle zunächst eine QR-Code aus, um Downloads zu starten.')}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="share" className="space-y-6 focus-visible:outline-hidden">
{primaryInvite ? (
<InviteShareSummaryCard
invite={primaryInvite}
onCopy={() => handleCopy(primaryInvite)}
onCreate={handleCreateInvite}
onOpenLayout={() => handleTabChange('layout')}
onOpenExport={() => handleTabChange('export')}
stats={inviteCountSummary}
/>
) : null}
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
<QrCode className="h-5 w-5 text-primary" />
{t('invites.cardTitle', 'QR-QR-Code & Layouts')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.cardDescription', 'Erzeuge QR-Code, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
</CardDescription>
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]">
<span>{t('invites.summary.active', 'Aktive QR-Code')}: {inviteCountSummary.active}</span>
<span className="text-primary"></span>
<span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void load()}
disabled={state.loading}
>
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('invites.actions.refresh', 'Aktualisieren')}
</Button>
<Button
size="sm"
onClick={handleCreateInvite}
disabled={creatingInvite || state.event?.limits?.can_add_guests === false}
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
>
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
{t('invites.actions.create', 'Neue QR-Code erstellen')}
</Button>
{!state.loading && state.event?.limits?.can_add_guests === false && (
<p className="w-full text-xs text-amber-600">
{tLimits('guestsBlocked')}
</p>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{state.loading ? (
<InviteSkeleton />
) : state.invites.length === 0 ? (
<EmptyState onCreate={handleCreateInvite} />
) : (
<div className="grid gap-3">
{state.invites.map((invite) => (
<InviteListCard
key={invite.id}
invite={invite}
onSelect={() => setSelectedInviteId(invite.id)}
onCopy={() => handleCopy(invite)}
onRevoke={() => handleRevoke(invite)}
selected={invite.id === selectedInvite?.id}
revoking={revokingId === invite.id}
copied={copiedInviteId === invite.id}
/>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}
type InviteWorkflowStep = {
key: TabKey;
title: string;
description: string;
status: 'done' | 'active' | 'pending';
};
function InviteWorkflowSteps({ steps, onSelectStep }: { steps: InviteWorkflowStep[]; onSelectStep: (tab: TabKey) => void }) {
const { t } = useTranslation('management');
return (
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)]/80 shadow-sm shadow-primary/10">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base font-semibold text-foreground">
{t('invites.workflow.title', 'QR-Codes-Workflow')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge Layout gestalten, Links teilen, Export starten.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-primary/30 text-xs uppercase tracking-[0.2em] text-primary">
{t('invites.workflow.badge', 'Setup')}
</Badge>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-3">
{steps.map((step) => {
const isDone = step.status === 'done';
const isActive = step.status === 'active';
return (
<button
key={step.key}
type="button"
className={`flex flex-col gap-2 rounded-2xl border px-4 py-3 text-left transition ${
isActive
? 'border-primary bg-primary/5 text-primary'
: isDone
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] text-muted-foreground'
}`}
onClick={() => onSelectStep(step.key)}
>
<div className="flex items-center gap-2">
{isDone ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Circle className="h-4 w-4" />
)}
<span className="text-sm font-semibold">{step.title}</span>
</div>
<p className="text-xs leading-snug text-current/80">{step.description}</p>
</button>
);
})}
</CardContent>
</Card>
);
}
type InviteShareSummaryProps = {
invite: EventQrInvite;
onCopy: () => void;
onCreate: () => void;
onOpenLayout: () => void;
onOpenExport: () => void;
stats: { active: number; total: number };
};
function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpenExport, stats }: InviteShareSummaryProps) {
const { t } = useTranslation('management');
return (
<Card className="border border-[var(--tenant-border-strong)] bg-gradient-to-r from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] shadow-lg shadow-primary/10">
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
<Link2 className="h-5 w-5 text-primary" />
{t('invites.share.title', 'Schnellzugriff auf Gästelink')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.share.description', 'Nutze den Standardlink, um QR-Codes zu teilen oder weitere Karten zu erzeugen.')}
</CardDescription>
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary">
{t('invites.share.stats.active', { defaultValue: '{{count}} aktiv', count: stats.active })}
</Badge>
<Badge variant="outline" className="border-[var(--tenant-border-strong)] text-muted-foreground">
{t('invites.share.stats.total', { defaultValue: '{{count}} gesamt', count: stats.total })}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2 rounded-2xl border border-[var(--tenant-border-strong)] bg-white/90 p-4 text-sm text-muted-foreground">
<span className="text-xs uppercase tracking-[0.3em] text-muted-foreground">{t('invites.share.primaryLabel', 'Hauptlink')}</span>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<span className="break-all font-mono text-xs text-foreground">{invite.url}</span>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onCopy}>
<Copy className="mr-1 h-3.5 w-3.5" />
{t('invites.share.actions.copy', 'Link kopieren')}
</Button>
{invite.url ? (
<Button
size="sm"
variant="ghost"
onClick={() => {
window.open(invite.url ?? '#', '_blank', 'noopener');
}}
className="text-primary"
>
<ExternalLink className="mr-1 h-3.5 w-3.5" />
{t('invites.share.actions.open', 'Öffnen')}
</Button>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Button variant="secondary" onClick={onOpenLayout} className="justify-between text-left">
<div>
<p className="text-sm font-semibold text-foreground">{t('invites.share.actions.editLayout', 'Layout bearbeiten')}</p>
<p className="text-xs text-muted-foreground">{t('invites.share.actions.editHint', 'Farben & Texte direkt im Editor anpassen.')}</p>
</div>
<ArrowLeft className="h-4 w-4 rotate-180" />
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={onOpenExport} className="flex-1">
<Printer className="mr-2 h-4 w-4" />
{t('invites.share.actions.export', 'Drucken/Export')}
</Button>
<Button variant="outline" onClick={onCreate} className="flex-1">
<Share2 className="mr-2 h-4 w-4" />
{t('invites.share.actions.create', 'Weitere QR-Code')}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function InviteCustomizerSkeleton(): React.ReactElement {
return (
<div className="space-y-6">
<div className="h-8 w-56 animate-pulse rounded-full bg-white/70" />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`customizer-skeleton-${index}`} className="h-40 animate-pulse rounded-2xl bg-white/70" />
))}
</div>
<div className="h-[420px] animate-pulse rounded-3xl bg-white/70" />
</div>
</div>
);
}
function InviteListCard({
invite,
selected,
onSelect,
onCopy,
onRevoke,
revoking,
copied,
}: {
invite: EventQrInvite;
selected: boolean;
onSelect: () => void;
onCopy: () => void;
onRevoke: () => void;
revoking: boolean;
copied: boolean;
}) {
const { t } = useTranslation('management');
const status = getInviteStatus(invite);
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
const preferredLayoutId = customization?.layout_id ?? invite.layouts[0]?.id ?? null;
const isAutoGenerated = Boolean(metadata.auto_generated);
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
const layoutsById = React.useMemo(() => {
const map = new Map<string, EventQrInviteLayout>();
invite.layouts.forEach((layout) => map.set(layout.id, layout));
return map;
}, [invite.layouts]);
const layoutName = preferredLayoutId ? layoutsById.get(preferredLayoutId)?.name ?? invite.layouts[0]?.name ?? '' : '';
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect();
}
}}
className={`flex flex-col gap-3 rounded-2xl border p-4 transition-shadow ${selected ? 'border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-lg shadow-primary/20' : 'border-border bg-[var(--tenant-surface)] hover:border-[var(--tenant-border-strong)]'}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `QR-Code #${invite.id}`}</span>
<Badge variant="outline" className={statusBadgeClass(status)}>
{status}
</Badge>
{isAutoGenerated ? (
<Badge variant="secondary" className="bg-muted text-muted-foreground">{t('invites.labels.standard', 'Standard')}</Badge>
) : null}
{customization ? (
<Badge className="bg-emerald-500/15 text-emerald-700">{t('tasks.customizer.badge', 'Angepasst')}</Badge>
) : null}
</div>
{invite.qr_code_data_url ? (
<img
src={invite.qr_code_data_url}
alt={t('invites.labels.qrAlt', 'QR-Code Vorschau')}
className="h-16 w-16 rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-2 shadow-sm"
/>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="break-all rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] px-2 py-1 font-mono text-xs text-muted-foreground">
{invite.url}
</span>
<Button
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
onCopy();
}}
className={copied ? 'border-emerald-300 text-emerald-700 hover:bg-emerald-50' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
>
<Copy className="mr-1 h-3 w-3" />
{copied ? t('invites.actions.copied', 'Kopiert!') : t('invites.actions.copy', 'Link kopieren')}
</Button>
</div>
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-2">
<span>
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
</span>
<span>
{t('invites.labels.layout', 'Layout')}: {layoutName || t('invites.labels.layoutFallback', 'Standard')}
</span>
{invite.expires_at ? <span>{t('invites.labels.validUntil', 'Gültig bis')} {formatDateTime(invite.expires_at)}</span> : null}
{invite.created_at ? <span>{t('invites.labels.createdAt', 'Erstellt am')} {formatDateTime(invite.created_at)}</span> : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{selected ? (
<Badge variant="outline" className="border-amber-300 bg-amber-100/70 text-amber-700">
{t('invites.labels.selected', 'Aktuell ausgewählt')}
</Badge>
) : (
<div className="text-xs text-muted-foreground">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
)}
<Button
variant="ghost"
size="sm"
onClick={(event) => {
event.stopPropagation();
onRevoke();
}}
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
className="text-muted-foreground hover:text-destructive disabled:opacity-50"
>
{revoking ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <X className="mr-1 h-4 w-4" />}
{t('invites.actions.deactivate', 'Deaktivieren')}
</Button>
</div>
</div>
);
}
function InviteSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`invite-skeleton-${index}`} className="h-32 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors">
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine QR-Code')}</h3>
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten.')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
<Share2 className="mr-1 h-4 w-4" />
{t('invites.actions.create', 'Neue QR-Code erstellen')}
</Button>
</div>
);
}
function renderEventName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
if (invite.revoked_at) return 'Deaktiviert';
if (invite.expires_at) {
const expiry = new Date(invite.expires_at);
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
return 'Abgelaufen';
}
}
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
}
function statusBadgeClass(status: string): string {
if (status === 'Aktiv') {
return 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-200 dark:border-emerald-500/40';
}
if (status === 'Abgelaufen') {
return 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-200 dark:border-orange-500/40';
}
return 'bg-slate-200 text-slate-700 border-slate-300 dark:bg-slate-600/40 dark:text-slate-200 dark:border-slate-500/40';
}
function formatDateTime(iso: string | null): string {
if (!iso) return 'unbekannt';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'unbekannt';
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}