Des weiteren: neue Blogartikel und howto-Artikel von ChatGPT. Das QR-Code-Canvas funktioniert nun noch besser. die Layouts sehen besser aus. Der PaketSeeder enthält nun die Paddle Sandbox ProductIDs
1386 lines
60 KiB
TypeScript
1386 lines
60 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 { 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 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;
|
|
link.download = `${selectedInvite.token || 'invite'}-qr.png`;
|
|
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, 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 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;
|
|
|
|
const filenameStem = `${selectedInvite.token || 'invite'}-${exportLayout.id}`;
|
|
|
|
try {
|
|
if (normalizedFormat === 'png') {
|
|
const dataUrl = await generatePngDataUrl(exportOptions);
|
|
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
|
|
} else if (normalizedFormat === 'pdf') {
|
|
const pdfBytes = await generatePdfBytes(
|
|
exportOptions,
|
|
'a4',
|
|
'portrait',
|
|
);
|
|
triggerDownloadFromBlob(new Blob([pdfBytes as any], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
|
} 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, 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}
|
|
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',
|
|
});
|
|
}
|