1400 lines
61 KiB
TypeScript
1400 lines
61 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } 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 { AdminLayout } from '../components/AdminLayout';
|
|
import {
|
|
createQrInvite,
|
|
EventQrInvite,
|
|
getEvent,
|
|
getEventQrInvites,
|
|
revokeEventQrInvite,
|
|
TenantEvent,
|
|
updateEventQrInvite,
|
|
EventQrInviteLayout,
|
|
} from '../api';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import {
|
|
ADMIN_EVENTS_PATH,
|
|
ADMIN_EVENT_VIEW_PATH,
|
|
ADMIN_EVENT_TOOLKIT_PATH,
|
|
ADMIN_EVENT_PHOTOS_PATH,
|
|
} from '../constants';
|
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
|
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';
|
|
|
|
interface PageState {
|
|
event: TenantEvent | null;
|
|
invites: EventQrInvite[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
type TabKey = 'layout' | 'export' | 'links';
|
|
|
|
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 = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout';
|
|
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 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] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]);
|
|
setState({ event: eventData, invites: invitesData, loading: false, error: null });
|
|
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen 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 param = searchParams.get('tab');
|
|
const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout';
|
|
setActiveTab((current) => (current === nextTab ? current : nextTab));
|
|
}, [searchParams]);
|
|
|
|
const handleTabChange = React.useCallback(
|
|
(value: string) => {
|
|
const nextTab = value === 'export' || value === 'links' ? (value as TabKey) : 'layout';
|
|
setActiveTab(nextTab);
|
|
const nextParams = new URLSearchParams(searchParams);
|
|
if (nextTab === 'layout') {
|
|
nextParams.delete('tab');
|
|
} else {
|
|
nextParams.set('tab', nextTab);
|
|
}
|
|
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 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 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,
|
|
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((_id: string | null) => undefined, []);
|
|
const handlePreviewChange = React.useCallback((_id: string, _patch: Partial<LayoutElement>) => 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]);
|
|
|
|
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);
|
|
} catch {
|
|
// ignore clipboard failures
|
|
}
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
|
|
}
|
|
} finally {
|
|
setCreatingInvite(false);
|
|
}
|
|
}
|
|
|
|
async function handleCopy(invite: EventQrInvite) {
|
|
try {
|
|
await navigator.clipboard.writeText(invite.url);
|
|
setCopiedInviteId(invite.id);
|
|
} catch (error) {
|
|
console.warn('[Invites] Clipboard copy failed', error);
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState((prev) => ({ ...prev, error: 'QR-Einladung 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);
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState((prev) => ({ ...prev, error: 'Anpassung 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(
|
|
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment],
|
|
normalizedFormat,
|
|
'einladungslayout',
|
|
);
|
|
|
|
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,
|
|
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,
|
|
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')}
|
|
</Button>
|
|
{slug ? (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
|
|
className="hover:text-foreground"
|
|
>
|
|
{t('invites.actions.backToEvent', 'Event öffnen')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))}
|
|
className="hover:text-foreground"
|
|
>
|
|
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))}
|
|
className="hover:text-foreground"
|
|
>
|
|
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
|
|
const limitWarnings = React.useMemo(
|
|
() => buildLimitWarnings(state.event?.limits, tLimits),
|
|
[state.event?.limits, tLimits]
|
|
);
|
|
|
|
const limitScopeLabels = React.useMemo(
|
|
() => ({
|
|
photos: tLimits('photosTitle'),
|
|
guests: tLimits('guestsTitle'),
|
|
gallery: tLimits('galleryTitle'),
|
|
}),
|
|
[tLimits]
|
|
);
|
|
|
|
return (
|
|
<AdminLayout
|
|
title={eventName}
|
|
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
|
actions={actions}
|
|
>
|
|
{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}
|
|
>
|
|
<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>
|
|
</Alert>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<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="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>
|
|
<TabsTrigger value="links" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
|
{t('invites.tabs.links', 'QR-Codes verwalten')}
|
|
</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', 'Einladungslayout 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}
|
|
/>
|
|
)}
|
|
</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', 'Einladung 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}
|
|
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>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('invites.export.actions.hint', 'PDF enthält Beschnittmarken, PNG ist für schnelle digitale Freigaben geeignet.')}
|
|
</p>
|
|
</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 Einladung')}
|
|
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 Einladung 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 Einladung aus, um Downloads zu starten.')}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="links" 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">
|
|
<QrCode className="h-5 w-5 text-primary" />
|
|
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-muted-foreground">
|
|
{t('invites.cardDescription', 'Erzeuge Einladungen, 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 Einladungen')}: {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 Einladung 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>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
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() || `Einladung #${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 Einladungen')}</h3>
|
|
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine Einladung, 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 Einladung 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',
|
|
});
|
|
}
|