zu fabricjs gewechselt, noch nicht funktionsfähig
This commit is contained in:
@@ -1,162 +1,195 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Sparkles, QrCode } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Download,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Printer,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Smile,
|
||||
Sparkles,
|
||||
Users,
|
||||
} 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 { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
EventStats as TenantEventStats,
|
||||
EventToolkit,
|
||||
EventToolkitTask,
|
||||
TenantEvent,
|
||||
TenantPhoto,
|
||||
TenantEventStats,
|
||||
getEvent,
|
||||
getEventStats,
|
||||
TenantEvent,
|
||||
getEventToolkit,
|
||||
toggleEvent,
|
||||
submitTenantFeedback,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_EDIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
} from '../constants';
|
||||
|
||||
interface State {
|
||||
type EventDetailPageProps = {
|
||||
mode?: 'detail' | 'toolkit';
|
||||
};
|
||||
|
||||
type ToolkitState = {
|
||||
data: EventToolkit | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type WorkspaceState = {
|
||||
event: TenantEvent | null;
|
||||
stats: TenantEventStats | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
}
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps): JSX.Element {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [state, setState] = React.useState<State>({
|
||||
const slug = slugParam ?? null;
|
||||
|
||||
const [state, setState] = React.useState<WorkspaceState>({
|
||||
event: null,
|
||||
stats: null,
|
||||
error: null,
|
||||
loading: true,
|
||||
busy: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setState((prev) => ({ ...prev, loading: false, error: 'Kein Event-Slug angegeben.' }));
|
||||
setState({ event: null, stats: null, loading: false, busy: false, error: t('events.errors.missingSlug', 'Kein Event ausgewählt.') });
|
||||
setToolkit({ data: null, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [eventData, statsData] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventStats(slug),
|
||||
]);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
event: eventData,
|
||||
stats: statsData,
|
||||
loading: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (isAuthError(err)) return;
|
||||
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false }));
|
||||
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
|
||||
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, error: t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'), loading: false }));
|
||||
}
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
try {
|
||||
const toolkitData = await getEventToolkit(slug);
|
||||
setToolkit({ data: toolkitData, loading: false, error: null });
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setToolkit({ data: null, loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.') });
|
||||
}
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function handleToggle() {
|
||||
if (!slug) return;
|
||||
async function handleToggle(): Promise<void> {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, busy: true, error: null }));
|
||||
|
||||
try {
|
||||
const updated = await toggleEvent(slug);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
event: updated,
|
||||
stats: prev.stats ? { ...prev.stats, status: updated.status, is_active: Boolean(updated.is_active) } : prev.stats,
|
||||
busy: false,
|
||||
event: updated,
|
||||
stats: prev.stats
|
||||
? {
|
||||
...prev.stats,
|
||||
status: updated.status,
|
||||
is_active: Boolean(updated.is_active),
|
||||
}
|
||||
: prev.stats,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'Status konnte nicht angepasst werden.', busy: false }));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, busy: false, error: t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.') }));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, busy: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { event, stats, error, loading, busy } = state;
|
||||
const eventDisplayName = event ? renderName(event.name) : '';
|
||||
const activeInvitesCount = event?.active_invites_count ?? 0;
|
||||
const totalInvitesCount = event?.total_invites_count ?? activeInvitesCount;
|
||||
const { event, stats, loading, busy, error } = state;
|
||||
const toolkitData = toolkit.data;
|
||||
|
||||
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||
const subtitle = mode === 'toolkit'
|
||||
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
|
||||
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
|
||||
</Button>
|
||||
{event && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
|
||||
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
|
||||
>
|
||||
Bearbeiten
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
||||
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))}
|
||||
className="border-sky-200 text-sky-700 hover:bg-sky-50"
|
||||
>
|
||||
Mitglieder
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
||||
<Users className="h-4 w-4" /> {t('events.actions.members', 'Team & Rollen')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||
className="border-amber-200 text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
Tasks
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||||
<Sparkles className="h-4 w-4" /> {t('events.actions.tasks', 'Aufgaben verwalten')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
|
||||
className="border-amber-200 text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
QR & Einladungen
|
||||
<Button variant="outline" onClick={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||||
<QrCode className="h-4 w-4" /> {t('events.actions.invites', 'Einladungen & Layouts')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
|
||||
className="border-emerald-200 text-emerald-600 hover:bg-emerald-50"
|
||||
>
|
||||
Event-Day Toolkit
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Camera className="h-4 w-4" /> {t('events.actions.photos', 'Fotos moderieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('events.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout title="Event nicht gefunden" subtitle="Bitte wähle ein Event aus der Übersicht." actions={actions}>
|
||||
<AdminLayout title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')} actions={actions}>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardContent className="p-6 text-sm text-slate-600">
|
||||
Ohne gültigen Slug können wir keine Daten laden. Kehre zur Event-Liste zurück und wähle dort ein Event aus.
|
||||
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
@@ -164,159 +197,591 @@ export default function EventDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={event ? renderName(event.name) : 'Event wird geladen'}
|
||||
subtitle="Verwalte Status, Einladungen und Statistiken deines Events."
|
||||
actions={actions}
|
||||
>
|
||||
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
|
||||
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{toolkit.error && (
|
||||
<Alert variant="default">
|
||||
<AlertTitle>{toolkit.error}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<DetailSkeleton />
|
||||
<WorkspaceSkeleton />
|
||||
) : event ? (
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Grundlegende Informationen für Gäste und Moderation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<InfoRow label="Slug" value={event.slug} />
|
||||
<InfoRow label="Status" value={event.status === 'published' ? 'Veröffentlicht' : event.status} />
|
||||
<InfoRow label="Datum" value={formatDate(event.event_date)} />
|
||||
<InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} />
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Button
|
||||
onClick={handleToggle}
|
||||
disabled={busy}
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{event.is_active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||||
className="border-sky-200 text-sky-700 hover:bg-sky-50"
|
||||
>
|
||||
<Camera className="h-4 w-4" /> Fotos moderieren
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
|
||||
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<QrCode className="h-5 w-5 text-amber-500" /> Einladungen & QR
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Steuere QR-Einladungen, Layouts und Branding gesammelt auf einer eigenen Seite.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
|
||||
<p>
|
||||
Aktive QR-Einladungen: {activeInvitesCount} · Gesamt erstellt: {totalInvitesCount}
|
||||
</p>
|
||||
<p>
|
||||
Bereite deine Drucklayouts vor, personalisiere Texte und Logos und drucke sie direkt aus.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
|
||||
<QuickActionsCard
|
||||
slug={event.slug}
|
||||
busy={busy}
|
||||
onToggle={handleToggle}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full bg-amber-500 text-white shadow-lg shadow-amber-500/20 hover:bg-amber-500/90"
|
||||
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
|
||||
>
|
||||
Einladungen & Layouts verwalten
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500">
|
||||
Du kannst bestehende Layouts duplizieren, Farben anpassen und neue PDFs generieren.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||||
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-sky-100/60 lg:col-span-2">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Heart className="h-5 w-5 text-sky-500" /> Performance
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Kennzahlen zu Uploads, Highlights und Interaktion.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 md:grid-cols-4">
|
||||
<StatChip label="Fotos" value={stats?.total ?? 0} />
|
||||
<StatChip label="Featured" value={stats?.featured ?? 0} />
|
||||
<StatChip label="Likes" value={stats?.likes ?? 0} />
|
||||
<StatChip label="Uploads (7 Tage)" value={stats?.recent_uploads ?? 0} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
||||
<InviteSummary
|
||||
invites={toolkitData?.invites}
|
||||
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<PendingPhotosCard
|
||||
photos={toolkitData?.photos.pending ?? []}
|
||||
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||||
/>
|
||||
<RecentUploadsCard photos={toolkitData?.photos.recent ?? []} />
|
||||
</div>
|
||||
|
||||
<FeedbackCard slug={event.slug} />
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Event nicht gefunden</AlertTitle>
|
||||
<AlertDescription>Bitte prüfe den Slug und versuche es erneut.</AlertDescription>
|
||||
</Alert>
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
|
||||
<CardContent className="p-6 text-sm text-slate-600">
|
||||
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
function resolveName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string' && name.trim().length > 0) {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: TenantEventStats | null; busy: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const statusLabel = event.status === 'published'
|
||||
? t('events.status.published', 'Veröffentlicht')
|
||||
: event.status === 'draft'
|
||||
? t('events.status.draft', 'Entwurf')
|
||||
: t('events.status.archived', 'Archiviert');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" />
|
||||
{t('events.workspace.sections.statusTitle', 'Eventstatus & Sichtbarkeit')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('events.workspace.sections.statusSubtitle', 'Aktiviere dein Event für Gäste oder verstecke es vorübergehend.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<InfoRow icon={<Sparkles className="h-4 w-4 text-pink-500" />} label={t('events.workspace.fields.status', 'Status')} value={statusLabel} />
|
||||
<InfoRow icon={<Circle className="h-4 w-4 text-amber-500" />} label={t('events.workspace.fields.active', 'Aktiv für Gäste')} value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')} />
|
||||
<InfoRow icon={<CalendarIcon />} label={t('events.workspace.fields.date', 'Eventdatum')} value={formatDate(event.event_date)} />
|
||||
<InfoRow icon={<Smile className="h-4 w-4 text-rose-500" />} label={t('events.workspace.fields.eventType', 'Event-Typ')} value={resolveEventType(event)} />
|
||||
|
||||
{stats && (
|
||||
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-4 text-xs text-pink-900">
|
||||
<p className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
|
||||
<p>
|
||||
{t('events.workspace.fields.uploadsTotal', { defaultValue: '{{count}} Uploads gesamt', count: stats.uploads_total })}
|
||||
{' · '}
|
||||
{t('events.workspace.fields.uploadsToday', { defaultValue: '{{count}} Uploads (24h)', count: stats.uploads_24h })}
|
||||
</p>
|
||||
<p>
|
||||
{t('events.workspace.fields.likesTotal', { defaultValue: '{{count}} Likes vergeben', count: stats.likes_total })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button onClick={onToggle} disabled={busy} className="bg-gradient-to-r from-pink-500 via-rose-500 to-purple-500 text-white shadow-md shadow-rose-200/60">
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
|
||||
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise<void>; navigate: ReturnType<typeof useNavigate> }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const actions = [
|
||||
{
|
||||
icon: <Camera className="h-4 w-4" />,
|
||||
label: t('events.quickActions.moderate', 'Fotos moderieren'),
|
||||
onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)),
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'),
|
||||
onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)),
|
||||
},
|
||||
{
|
||||
icon: <QrCode className="h-4 w-4" />,
|
||||
label: t('events.quickActions.invites', 'Layouts & QR verwalten'),
|
||||
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`),
|
||||
},
|
||||
{
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
label: t('events.quickActions.roles', 'Team & Rollen anpassen'),
|
||||
onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)),
|
||||
},
|
||||
{
|
||||
icon: <Printer className="h-4 w-4" />,
|
||||
label: t('events.quickActions.print', 'Layouts als PDF drucken'),
|
||||
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`),
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle2 className="h-4 w-4" />,
|
||||
label: t('events.quickActions.toggle', 'Status ändern'),
|
||||
onClick: () => { void onToggle(); },
|
||||
disabled: busy,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-violet-100/60">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-violet-500" />
|
||||
{t('events.quickActions.title', 'Schnellaktionen')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('events.quickActions.subtitle', 'Nutze die wichtigsten Schritte vor und während deines Events.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (action.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = action.onClick();
|
||||
if (result instanceof Promise) {
|
||||
void result;
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3 text-left text-sm text-slate-700 transition hover:border-violet-200 hover:bg-violet-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-600">
|
||||
{action.icon}
|
||||
</span>
|
||||
{action.label}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: TenantEventStats | null }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const cards = [
|
||||
{
|
||||
icon: <Camera className="h-5 w-5 text-emerald-500" />,
|
||||
label: t('events.metrics.uploadsTotal', 'Uploads gesamt'),
|
||||
value: metrics?.uploads_total ?? stats?.uploads_total ?? 0,
|
||||
},
|
||||
{
|
||||
icon: <Camera className="h-5 w-5 text-sky-500" />,
|
||||
label: t('events.metrics.uploads24h', 'Uploads (24h)'),
|
||||
value: metrics?.uploads_24h ?? stats?.uploads_24h ?? 0,
|
||||
},
|
||||
{
|
||||
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
||||
label: t('events.metrics.pending', 'Fotos in Moderation'),
|
||||
value: metrics?.pending_photos ?? stats?.pending_photos ?? 0,
|
||||
},
|
||||
{
|
||||
icon: <QrCode className="h-5 w-5 text-indigo-500" />,
|
||||
label: t('events.metrics.activeInvites', 'Aktive Einladungen'),
|
||||
value: metrics?.active_invites ?? 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card, index) => (
|
||||
<Card key={index} className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100">{card.icon}</span>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['invites'] | undefined; navigateToInvites: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-xl border border-pink-100 bg-white/70 px-3 py-2">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||
<span className="text-sm font-medium text-slate-800">{value}</span>
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<QrCode className="h-5 w-5 text-amber-500" />
|
||||
{t('events.invites.title', 'QR-Einladungen')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-slate-700">
|
||||
<div className="flex gap-2 text-sm text-slate-900">
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('events.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites?.summary.active ?? 0 })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('events.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites?.summary.total ?? 0 })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{invites?.items?.length ? (
|
||||
<ul className="space-y-2 text-xs">
|
||||
{invites.items.slice(0, 3).map((invite) => (
|
||||
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
|
||||
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
|
||||
<p className="truncate text-[11px] text-amber-700">{invite.url}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
|
||||
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks'] | undefined; navigateToTasks: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" />
|
||||
{t('events.tasks.title', 'Aktive Aufgaben')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('events.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{t('events.tasks.summary', {
|
||||
defaultValue: '{{completed}} von {{total}} erledigt',
|
||||
completed: tasks?.summary.completed ?? 0,
|
||||
total: tasks?.summary.total ?? 0,
|
||||
})}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-slate-700">
|
||||
{tasks?.items?.length ? (
|
||||
<div className="space-y-2">
|
||||
{tasks.items.slice(0, 4).map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={navigateToTasks} className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<Sparkles className="mr-2 h-4 w-4" /> {t('events.tasks.manage', 'Aufgabenbereich öffnen')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: EventToolkitTask }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between rounded-lg border border-pink-100 bg-white/80 px-3 py-2 text-xs text-slate-600">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-900">{task.title}</p>
|
||||
{task.description ? <p>{task.description}</p> : null}
|
||||
</div>
|
||||
<Badge variant={task.is_completed ? 'default' : 'outline'} className={task.is_completed ? 'bg-emerald-500/20 text-emerald-600' : 'border-pink-200 text-pink-600'}>
|
||||
{task.is_completed ? 'Erledigt' : 'Offen'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChip({ label, value }: { label: string; value: string | number }) {
|
||||
function PendingPhotosCard({ photos, navigateToModeration }: { photos: TenantPhoto[]; navigateToModeration: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-sky-100 bg-white/80 px-4 py-3 text-center shadow-sm">
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div className="text-lg font-semibold text-slate-900">{value}</div>
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Camera className="h-5 w-5 text-emerald-500" />
|
||||
{t('events.photos.pendingTitle', 'Fotos in Moderation')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('events.photos.pendingSubtitle', 'Schnell prüfen, bevor Gäste live gehen.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-emerald-200 text-emerald-600">
|
||||
{t('events.photos.pendingCount', { defaultValue: '{{count}} Fotos offen', count: photos.length })}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-slate-700">
|
||||
{photos.length ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.slice(0, 6).map((photo) => (
|
||||
<img key={photo.id} src={photo.thumbnail_url ?? photo.url} alt={photo.caption ?? 'Foto'} className="h-24 w-full rounded-lg object-cover" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{t('events.photos.pendingEmpty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={navigateToModeration} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Camera className="mr-2 h-4 w-4" /> {t('events.photos.openModeration', 'Moderation öffnen')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Camera className="h-5 w-5 text-sky-500" />
|
||||
{t('events.photos.recentTitle', 'Neueste Uploads')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('events.photos.recentSubtitle', 'Halte Ausschau nach Highlight-Momenten der Gäste.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-slate-700">
|
||||
{photos.length ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.slice(0, 6).map((photo) => (
|
||||
<img key={photo.id} src={photo.thumbnail_url ?? photo.url} alt={photo.caption ?? 'Foto'} className="h-24 w-full rounded-lg object-cover" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{t('events.photos.recentEmpty', 'Noch keine neuen Uploads.')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCard({ slug }: { slug: string }) {
|
||||
const { t } = useTranslation('management');
|
||||
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
|
||||
const [message, setMessage] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [submitted, setSubmitted] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const copy = {
|
||||
positive: t('events.feedback.positive', 'Super Lauf!'),
|
||||
neutral: t('events.feedback.neutral', 'Läuft'),
|
||||
negative: t('events.feedback.negative', 'Braucht Support'),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<MessageSquare className="h-5 w-5 text-slate-500" />
|
||||
{t('events.feedback.title', 'Wie läuft dein Event?')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('events.feedback.subtitle', 'Feedback hilft uns, neue Features zu priorisieren.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
variant={sentiment === key ? 'default' : 'outline'}
|
||||
className={sentiment === key ? 'bg-slate-900 text-white' : 'border-slate-300 text-slate-700'}
|
||||
onClick={() => setSentiment(key)}
|
||||
>
|
||||
{copy[key]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder={t('events.feedback.placeholder', 'Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.')}
|
||||
className="min-h-[120px] w-full rounded-md border border-slate-200 bg-white p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-slate-900 text-white hover:bg-slate-800"
|
||||
disabled={busy || submitted}
|
||||
onClick={async () => {
|
||||
if (busy || submitted) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await submitTenantFeedback({
|
||||
category: 'event_workspace',
|
||||
event_slug: slug,
|
||||
sentiment: sentiment ?? undefined,
|
||||
message: message.trim() ? message.trim() : undefined,
|
||||
});
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
setError(isAuthError(err)
|
||||
? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.')
|
||||
: t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />} {submitted ? t('events.feedback.submitted', 'Danke!') : t('events.feedback.submit', 'Feedback senden')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
|
||||
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600">{icon}</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{value || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return 'Noch kein Datum';
|
||||
const date = new Date(iso);
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Unbekanntes Datum';
|
||||
return '—';
|
||||
}
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
function resolveEventType(event: TenantEvent): string {
|
||||
if (event.event_type?.name) {
|
||||
if (typeof event.event_type.name === 'string') {
|
||||
return event.event_type.name;
|
||||
}
|
||||
return event.event_type.name.de ?? event.event_type.name.en ?? Object.values(event.event_type.name)[0] ?? '—';
|
||||
}
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
return '—';
|
||||
}
|
||||
|
||||
function AlertList({ alerts }: { alerts: string[] }) {
|
||||
if (!alerts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert, index) => (
|
||||
<Alert key={`workspace-alert-${index}`} variant="default">
|
||||
<AlertTitle>{alert}</AlertTitle>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon(): JSX.Element {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<SkeletonCard key={`metric-skeleton-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard(): JSX.Element {
|
||||
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function EventFormPage() {
|
||||
});
|
||||
const [autoSlug, setAutoSlug] = React.useState(true);
|
||||
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
|
||||
const slugSuffixRef = React.useRef<string | null>(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
|
||||
@@ -172,6 +173,7 @@ export default function EventFormPage() {
|
||||
}
|
||||
: null);
|
||||
setAutoSlug(false);
|
||||
slugSuffixRef.current = null;
|
||||
}, [isEdit, loadedEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -186,31 +188,41 @@ export default function EventFormPage() {
|
||||
|
||||
const loading = isEdit ? eventLoading : false;
|
||||
|
||||
function ensureSlugSuffix(): string {
|
||||
if (!slugSuffixRef.current) {
|
||||
slugSuffixRef.current = Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
return slugSuffixRef.current;
|
||||
}
|
||||
|
||||
function buildAutoSlug(value: string): string {
|
||||
const base = slugify(value).replace(/^-+|-+$/g, '');
|
||||
const suffix = ensureSlugSuffix();
|
||||
const safeBase = base || 'event';
|
||||
|
||||
return `${safeBase}-${suffix}`;
|
||||
}
|
||||
|
||||
function handleNameChange(value: string) {
|
||||
setForm((prev) => ({ ...prev, name: value }));
|
||||
if (autoSlug) {
|
||||
setForm((prev) => ({ ...prev, slug: slugify(value) }));
|
||||
setForm((prev) => ({ ...prev, slug: buildAutoSlug(value) }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlugChange(value: string) {
|
||||
setAutoSlug(false);
|
||||
setForm((prev) => ({ ...prev, slug: slugify(value) }));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmedName = form.name.trim();
|
||||
const trimmedSlug = form.slug.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
setError('Bitte gib einen Eventnamen ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedSlug) {
|
||||
setError('Bitte wähle einen Slug für die Event-URL.');
|
||||
return;
|
||||
let finalSlug = form.slug.trim();
|
||||
if (!finalSlug || autoSlug) {
|
||||
finalSlug = buildAutoSlug(trimmedName);
|
||||
}
|
||||
|
||||
if (!form.eventTypeId) {
|
||||
@@ -230,7 +242,7 @@ export default function EventFormPage() {
|
||||
|
||||
const payload = {
|
||||
name: trimmedName,
|
||||
slug: trimmedSlug,
|
||||
slug: finalSlug,
|
||||
event_type_id: form.eventTypeId,
|
||||
event_date: form.date || undefined,
|
||||
status,
|
||||
@@ -376,18 +388,8 @@ export default function EventFormPage() {
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="event-slug">Slug / interne Kennung</Label>
|
||||
<Input
|
||||
id="event-slug"
|
||||
placeholder="sommerfest-2025"
|
||||
value={form.slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Diese Kennung wird intern verwendet. Gäste betreten dein Event ausschließlich über ihre
|
||||
Einladungslinks und die dazugehörigen QR-Layouts.
|
||||
Die Kennung und Event-URL werden automatisch aus dem Namen generiert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
updateEventQrInvite,
|
||||
EventQrInviteLayout,
|
||||
} from '../api';
|
||||
import { authorizedFetch, isAuthError } from '../auth/tokens';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
@@ -30,6 +29,20 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
} from '../constants';
|
||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
|
||||
import {
|
||||
buildDefaultElements,
|
||||
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;
|
||||
@@ -40,6 +53,105 @@ interface PageState {
|
||||
|
||||
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(): JSX.Element {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -52,7 +164,6 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
|
||||
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
||||
const [customizerResetting, setCustomizerResetting] = React.useState(false);
|
||||
const [designerMode, setDesignerMode] = React.useState<'standard' | 'advanced'>('standard');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get('tab');
|
||||
const initialTab = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout';
|
||||
@@ -112,6 +223,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
[state.invites, selectedInviteId]
|
||||
);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
setExportError(null);
|
||||
setExportDownloadBusy(null);
|
||||
@@ -140,13 +252,133 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
|
||||
}, [selectedInvite]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentCustomization?.mode === 'advanced') {
|
||||
setDesignerMode('advanced');
|
||||
} else if (designerMode !== 'standard' && currentCustomization) {
|
||||
setDesignerMode('standard');
|
||||
const exportLayout = React.useMemo(() => {
|
||||
if (!selectedInvite || selectedInvite.layouts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
}, [currentCustomization?.mode]);
|
||||
|
||||
const targetId = currentCustomization?.layout_id;
|
||||
if (targetId) {
|
||||
const match = selectedInvite.layouts.find((layout) => layout.id === targetId);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return selectedInvite.layouts[0];
|
||||
}, [selectedInvite, currentCustomization?.layout_id]);
|
||||
|
||||
const exportPreview = React.useMemo(() => {
|
||||
if (!exportLayout || !selectedInvite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customization = currentCustomization ?? 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 = normalizeHexColor(customization?.secondary_color ?? (layoutPreview.secondary as string | undefined)) ?? '#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) ?? (exportLayout.qr?.size_px ?? null);
|
||||
|
||||
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(exportLayout.paper),
|
||||
orientationLabel:
|
||||
exportLayout.orientation === 'landscape'
|
||||
? t('invites.export.meta.orientationLandscape', 'Querformat')
|
||||
: 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, currentCustomization, selectedInvite, eventName, t]);
|
||||
|
||||
const exportElements = React.useMemo<LayoutElement[]>(() => {
|
||||
if (!exportLayout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (currentCustomization?.mode === 'advanced' && Array.isArray(currentCustomization.elements) && currentCustomization.elements.length) {
|
||||
return normalizeElements(payloadToElements(currentCustomization.elements));
|
||||
}
|
||||
|
||||
const baseForm: QrLayoutCustomization = {
|
||||
...currentCustomization,
|
||||
layout_id: exportLayout.id,
|
||||
link_label: currentCustomization?.link_label ?? selectedInvite?.url ?? '',
|
||||
badge_label: currentCustomization?.badge_label ?? exportLayout.badge_label ?? undefined,
|
||||
instructions: ensureInstructionList(currentCustomization?.instructions, exportLayout.instructions ?? []),
|
||||
instructions_heading: currentCustomization?.instructions_heading ?? exportLayout.instructions_heading ?? undefined,
|
||||
logo_data_url: currentCustomization?.logo_data_url ?? undefined,
|
||||
logo_url: currentCustomization?.logo_url ?? undefined,
|
||||
};
|
||||
|
||||
return buildDefaultElements(
|
||||
exportLayout,
|
||||
baseForm,
|
||||
eventName,
|
||||
exportLayout.preview?.qr_size_px ?? exportLayout.qr?.size_px ?? 480
|
||||
);
|
||||
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
|
||||
|
||||
const exportCanvasKey = React.useMemo(
|
||||
() => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`,
|
||||
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
|
||||
);
|
||||
|
||||
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? exportLayout?.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 inviteCountSummary = React.useMemo(() => {
|
||||
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
|
||||
@@ -271,113 +503,118 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
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 (layout: EventQrInviteLayout, format: string, rawUrl?: string | null) => {
|
||||
if (!selectedInvite) {
|
||||
async (format: string) => {
|
||||
if (!selectedInvite || !exportLayout || !exportPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedFormat = format.toLowerCase();
|
||||
const sourceUrl = rawUrl ?? layout.download_urls?.[normalizedFormat];
|
||||
|
||||
if (!sourceUrl) {
|
||||
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const busyKey = `${layout.id}-${normalizedFormat}`;
|
||||
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 {
|
||||
const response = await authorizedFetch(resolveInternalUrl(sourceUrl), {
|
||||
headers: {
|
||||
Accept: normalizedFormat === 'pdf' ? 'application/pdf' : 'image/svg+xml',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected status ${response.status}`);
|
||||
if (normalizedFormat === 'png') {
|
||||
const dataUrl = await generatePngDataUrl(exportOptions);
|
||||
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
|
||||
} else if (normalizedFormat === 'pdf') {
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
exportLayout.paper ?? 'a4',
|
||||
exportLayout.orientation ?? 'portrait',
|
||||
);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
} else {
|
||||
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const filenameStem = `${selectedInvite.token || 'invite'}-${layout.id}`;
|
||||
link.href = objectUrl;
|
||||
link.download = `${filenameStem}.${normalizedFormat}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
} catch (error) {
|
||||
console.error('[Invites] Export download failed', error);
|
||||
setExportError(
|
||||
isAuthError(error)
|
||||
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
|
||||
: t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'),
|
||||
);
|
||||
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
|
||||
} finally {
|
||||
setExportDownloadBusy(null);
|
||||
}
|
||||
},
|
||||
[selectedInvite, t]
|
||||
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
|
||||
);
|
||||
|
||||
const handleExportPrint = React.useCallback(
|
||||
async (layout: EventQrInviteLayout) => {
|
||||
if (!selectedInvite) {
|
||||
async () => {
|
||||
if (!selectedInvite || !exportLayout || !exportPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrl = layout.download_urls?.pdf ?? layout.download_urls?.a4 ?? null;
|
||||
if (!rawUrl) {
|
||||
setExportError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setExportPrintBusy(layout.id);
|
||||
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 response = await authorizedFetch(resolveInternalUrl(rawUrl), {
|
||||
headers: { Accept: 'application/pdf' },
|
||||
});
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
exportLayout.paper ?? 'a4',
|
||||
exportLayout.orientation ?? 'portrait',
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (!printWindow) {
|
||||
throw new Error('window-blocked');
|
||||
}
|
||||
|
||||
printWindow.onload = () => {
|
||||
try {
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
} catch (printError) {
|
||||
console.error('[Invites] Export print window failed', printError);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
await openPdfInNewTab(pdfBytes);
|
||||
} catch (error) {
|
||||
console.error('[Invites] Export print failed', error);
|
||||
setExportError(
|
||||
isAuthError(error)
|
||||
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
|
||||
: t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'),
|
||||
);
|
||||
setExportError(t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'));
|
||||
} finally {
|
||||
setExportPrintBusy(null);
|
||||
}
|
||||
},
|
||||
[selectedInvite, t]
|
||||
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
|
||||
);
|
||||
|
||||
const actions = (
|
||||
@@ -452,29 +689,16 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
<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.designer.heading', 'Einladungslayout anpassen')}</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'Einladungslayout anpassen')}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('invites.designer.subheading', 'Standardlayouts sind direkt startklar. Für individuelle Gestaltung kannst du in den freien Editor wechseln.')}
|
||||
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={designerMode}
|
||||
onValueChange={(value) => value && setDesignerMode(value as 'standard' | 'advanced')}
|
||||
className="self-start rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm"
|
||||
>
|
||||
<ToggleGroupItem value="standard" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{t('invites.designer.mode.standard', 'Standard-Layoutraster')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="advanced" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{t('invites.designer.mode.advanced', 'Freier Editor (Beta)')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{state.loading ? (
|
||||
<InviteCustomizerSkeleton />
|
||||
) : designerMode === 'standard' ? (
|
||||
) : (
|
||||
<InviteLayoutCustomizerPanel
|
||||
invite={selectedInvite ?? null}
|
||||
eventName={eventName}
|
||||
@@ -484,8 +708,6 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
onReset={handleResetCustomization}
|
||||
initialCustomization={currentCustomization}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedDesignerPlaceholder onBack={() => setDesignerMode('standard')} />
|
||||
)}
|
||||
</section>
|
||||
</TabsContent>
|
||||
@@ -499,7 +721,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
{t('invites.export.title', 'Drucken & Export')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t('invites.export.description', 'Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.')}
|
||||
{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">
|
||||
@@ -533,72 +755,225 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
<CardContent className="space-y-6">
|
||||
{exportError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('invites.export.errorTitle', 'Download fehlgeschlagen')}</AlertTitle>
|
||||
<AlertTitle>{t('invites.export.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
<AlertDescription>{exportError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{selectedInvite ? (
|
||||
selectedInvite.layouts.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{selectedInvite.layouts.map((layout) => {
|
||||
const printBusy = exportPrintBusy === layout.id;
|
||||
return (
|
||||
<div
|
||||
key={layout.id}
|
||||
className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-5 shadow-sm transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
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">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</h3>
|
||||
{layout.subtitle ? (
|
||||
<p className="text-xs text-muted-foreground">{layout.subtitle}</p>
|
||||
<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>
|
||||
{layout.formats?.length ? (
|
||||
<Badge className="bg-amber-500/15 text-amber-700">
|
||||
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
|
||||
</Badge>
|
||||
) : null}
|
||||
<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>
|
||||
{layout.description ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{layout.description}</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleExportPrint(layout)}
|
||||
disabled={printBusy || Boolean(exportDownloadBusy)}
|
||||
>
|
||||
{printBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Printer className="mr-1 h-4 w-4" />}
|
||||
{t('invites.export.actions.print', 'Direkt drucken')}
|
||||
</Button>
|
||||
{layout.formats?.map((format) => {
|
||||
const key = String(format ?? '').toLowerCase();
|
||||
const url = layout.download_urls?.[key];
|
||||
if (!url) return null;
|
||||
const busyKey = `${layout.id}-${key}`;
|
||||
const isBusy = exportDownloadBusy === busyKey;
|
||||
return (
|
||||
<Button
|
||||
key={`${layout.id}-${key}`}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={(!!exportDownloadBusy && !isBusy) || printBusy}
|
||||
onClick={() => void handleExportDownload(layout, key, url)}
|
||||
>
|
||||
{isBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Download className="mr-1 h-4 w-4" />}
|
||||
{key.toUpperCase()}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<div className="mt-6 flex justify-center">
|
||||
{exportElements.length ? (
|
||||
<div className="pointer-events-none">
|
||||
<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={0.34}
|
||||
layoutKey={exportCanvasKey}
|
||||
/>
|
||||
</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)]">
|
||||
@@ -681,19 +1056,6 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInternalUrl(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) {
|
||||
return parsed.pathname + parsed.search;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Invites] Unable to resolve download url', error);
|
||||
}
|
||||
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
function InviteCustomizerSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -709,30 +1071,6 @@ function InviteCustomizerSkeleton(): JSX.Element {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedDesignerPlaceholder({ onBack }: { onBack: () => void }): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6 rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/80 p-10 text-sm text-muted-foreground transition-colors">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-foreground">Freier Editor – bald verfügbar</h3>
|
||||
<p>
|
||||
Wir arbeiten gerade an einem drag-&-drop-Designer, mit dem du Elemente wie QR-Code, Texte und Logos frei platzieren
|
||||
kannst. In der Zwischenzeit kannst du unsere optimierten Standardlayouts mit vergrößertem QR-Code nutzen.
|
||||
</p>
|
||||
<p>
|
||||
Wenn du Vorschläge für zusätzliche Layouts oder Funktionen hast, schreib uns gern über den Support – wir sammeln Feedback
|
||||
für die nächste Ausbaustufe.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={onBack} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
Zurück zum Standard-Layout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteListCard({
|
||||
invite,
|
||||
selected,
|
||||
|
||||
@@ -1,569 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
QrCode,
|
||||
} 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 { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
} from '../constants';
|
||||
import {
|
||||
EventToolkit,
|
||||
EventToolkitTask,
|
||||
getEventToolkit,
|
||||
submitTenantFeedback,
|
||||
TenantPhoto,
|
||||
TenantEvent,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
interface ToolkitState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
data: EventToolkit | null;
|
||||
}
|
||||
import EventDetailPage from './EventDetailPage';
|
||||
|
||||
export default function EventToolkitPage(): JSX.Element {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
const [state, setState] = React.useState<ToolkitState>({ loading: true, error: null, data: null });
|
||||
const [feedbackSentiment, setFeedbackSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
|
||||
const [feedbackMessage, setFeedbackMessage] = React.useState('');
|
||||
const [feedbackSubmitting, setFeedbackSubmitting] = React.useState(false);
|
||||
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setState({ loading: false, error: t('toolkit.errors.missingSlug', 'Kein Event-Slug angegeben.'), data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const toolkit = await getEventToolkit(slug);
|
||||
setState({ loading: false, error: null, data: toolkit });
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState({ loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit konnte nicht geladen werden.'), data: null });
|
||||
}
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const { data, loading } = state;
|
||||
const eventName = data?.event ? resolveEventName(data.event.name, i18n.language) : '';
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('toolkit.actions.backToEvent', 'Zurück zum Event')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))}>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('toolkit.actions.manageTasks', 'Tasks öffnen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(slug ?? ''))}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
{t('toolkit.actions.manageInvites', 'QR-Einladungen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('toolkit.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName || t('toolkit.titleFallback', 'Event-Day Toolkit')}
|
||||
subtitle={t('toolkit.subtitle', 'Behalte Uploads, Aufgaben und Einladungen am Eventtag im Blick.')}
|
||||
actions={actions}
|
||||
>
|
||||
{state.error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>{t('toolkit.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{state.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<ToolkitSkeleton />
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<AlertList alerts={data.alerts} />
|
||||
|
||||
<MetricsGrid metrics={data.metrics} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||
<PendingPhotosCard
|
||||
photos={data.photos.pending}
|
||||
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}
|
||||
/>
|
||||
<InviteSummary invites={data.invites} navigateToEvent={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TaskOverviewCard tasks={data.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))} />
|
||||
<RecentUploadsCard photos={data.photos.recent} />
|
||||
</div>
|
||||
|
||||
<FeedbackCard
|
||||
submitting={feedbackSubmitting}
|
||||
submitted={feedbackSubmitted}
|
||||
sentiment={feedbackSentiment}
|
||||
message={feedbackMessage}
|
||||
onSelectSentiment={setFeedbackSentiment}
|
||||
onMessageChange={setFeedbackMessage}
|
||||
onSubmit={async () => {
|
||||
if (!slug) return;
|
||||
setFeedbackSubmitting(true);
|
||||
try {
|
||||
await submitTenantFeedback({
|
||||
category: 'event_toolkit',
|
||||
sentiment: feedbackSentiment ?? undefined,
|
||||
message: feedbackMessage ? feedbackMessage.trim() : undefined,
|
||||
event_slug: slug,
|
||||
});
|
||||
setFeedbackSentiment(null);
|
||||
setFeedbackMessage('');
|
||||
setFeedbackSubmitted(true);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: t('toolkit.errors.feedbackFailed', 'Feedback konnte nicht gesendet werden.'),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setFeedbackSubmitting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEventName(name: TenantEvent['name'], locale?: string): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
if (name && typeof name === 'object') {
|
||||
if (locale && name[locale]) {
|
||||
return name[locale];
|
||||
}
|
||||
const short = locale && locale.includes('-') ? locale.split('-')[0] : null;
|
||||
if (short && name[short]) {
|
||||
return name[short];
|
||||
}
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function AlertList({ alerts }: { alerts: string[] }) {
|
||||
const { t } = useTranslation('management');
|
||||
if (!alerts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alertMap: Record<string, string> = {
|
||||
no_tasks: t('toolkit.alerts.noTasks', 'Noch keine Tasks zugeordnet.'),
|
||||
no_invites: t('toolkit.alerts.noInvites', 'Es gibt keine aktiven QR-Einladungen.'),
|
||||
pending_photos: t('toolkit.alerts.pendingPhotos', 'Es warten Fotos auf Moderation.'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((code) => (
|
||||
<Alert key={code} variant="warning" className="border-amber-200 bg-amber-50 text-amber-900">
|
||||
<AlertTitle>{t('toolkit.alerts.attention', 'Achtung')}</AlertTitle>
|
||||
<AlertDescription>{alertMap[code] ?? code}</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsGrid({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: EventToolkit['metrics'];
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const cards = [
|
||||
{
|
||||
label: t('toolkit.metrics.uploadsTotal', 'Uploads gesamt'),
|
||||
value: metrics.uploads_total,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.uploads24h', 'Uploads (24h)'),
|
||||
value: metrics.uploads_24h,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.pendingPhotos', 'Unmoderierte Fotos'),
|
||||
value: metrics.pending_photos,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.activeInvites', 'Aktive Einladungen'),
|
||||
value: metrics.active_invites,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.engagementMode', 'Modus'),
|
||||
value:
|
||||
metrics.engagement_mode === 'photo_only'
|
||||
? t('toolkit.metrics.modePhotoOnly', 'Foto-Modus')
|
||||
: t('toolkit.metrics.modeTasks', 'Aufgaben'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="border-0 bg-white/90 shadow-sm shadow-amber-100/50">
|
||||
<CardContent className="space-y-1 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingPhotosCard({
|
||||
photos,
|
||||
navigateToModeration,
|
||||
}: {
|
||||
photos: TenantPhoto[];
|
||||
navigateToModeration: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-slate-100/70">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Camera className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.pending.title', 'Wartende Fotos')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.pending.subtitle', 'Moderationsempfehlung für neue Uploads.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={navigateToModeration}>
|
||||
{t('toolkit.pending.cta', 'Zur Moderation')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.pending.empty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.id} className="flex gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
className="h-16 w-16 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="space-y-1 text-xs text-slate-600">
|
||||
<p className="font-semibold text-slate-800">{photo.uploader_name ?? t('toolkit.pending.unknownUploader', 'Unbekannter Gast')}</p>
|
||||
<p>{t('toolkit.pending.uploadedAt', 'Hochgeladen:')} {formatDateTime(photo.uploaded_at)}</p>
|
||||
<p className="text-[11px] text-amber-700">{t('toolkit.pending.statusPending', 'Status: Prüfung ausstehend')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteSummary({
|
||||
invites,
|
||||
navigateToEvent,
|
||||
}: {
|
||||
invites: EventToolkit['invites'];
|
||||
navigateToEvent: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.invites.title', 'QR-Einladungen')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.invites.subtitle', 'Aktive Links und Layouts im Blick behalten.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-xs text-slate-600">
|
||||
<div className="flex gap-2 text-sm text-slate-900">
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('toolkit.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites.summary.active })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('toolkit.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites.summary.total })}
|
||||
</Badge>
|
||||
</div>
|
||||
{invites.items.length === 0 ? (
|
||||
<p>{t('toolkit.invites.empty', 'Noch keine QR-Einladungen erstellt.')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{invites.items.map((invite) => (
|
||||
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
|
||||
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
|
||||
<p className="truncate text-xs text-slate-500">{invite.url}</p>
|
||||
<p className="text-[11px] text-amber-700">
|
||||
{invite.is_active
|
||||
? t('toolkit.invites.statusActive', 'Aktiv')
|
||||
: t('toolkit.invites.statusInactive', 'Inaktiv')}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button variant="outline" onClick={navigateToEvent}>
|
||||
{t('toolkit.invites.manage', 'Einladungen verwalten')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks']; navigateToTasks: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" />
|
||||
{t('toolkit.tasks.title', 'Aktive Aufgaben')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{t('toolkit.tasks.summary', {
|
||||
defaultValue: '{{completed}} von {{total}} erledigt',
|
||||
completed: tasks.summary.completed,
|
||||
total: tasks.summary.total,
|
||||
})}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tasks.items.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.items.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" onClick={navigateToTasks}>
|
||||
{t('toolkit.tasks.manage', 'Tasks verwalten')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: EventToolkitTask }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-pink-100 bg-white/80 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${task.is_completed ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{task.is_completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||
{task.is_completed ? t('toolkit.tasks.completed', 'Erledigt') : t('toolkit.tasks.open', 'Offen')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-sky-100/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Camera className="h-5 w-5 text-sky-500" />
|
||||
{t('toolkit.recent.title', 'Neueste Uploads')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.recent.subtitle', 'Ein Blick auf die letzten Fotos der Gäste.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.recent.empty', 'Noch keine freigegebenen Fotos vorhanden.')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((photo) => (
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
className="h-24 w-full rounded-lg object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCard({
|
||||
submitting,
|
||||
submitted,
|
||||
sentiment,
|
||||
message,
|
||||
onSelectSentiment,
|
||||
onMessageChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
submitted: boolean;
|
||||
sentiment: 'positive' | 'neutral' | 'negative' | null;
|
||||
message: string;
|
||||
onSelectSentiment: (value: 'positive' | 'neutral' | 'negative') => void;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/95 shadow-lg shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<MessageSquare className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.feedback.title', 'Wie hilfreich ist dieses Toolkit?')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.feedback.subtitle', 'Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{submitted ? (
|
||||
<Alert variant="success">
|
||||
<AlertTitle>{t('toolkit.feedback.thanksTitle', 'Danke!')}</AlertTitle>
|
||||
<AlertDescription>{t('toolkit.feedback.thanksDescription', 'Wir haben dein Feedback erhalten.')}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'positive' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('positive')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" /> {t('toolkit.feedback.positive', 'Hilfreich')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'neutral' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('neutral')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" /> {t('toolkit.feedback.neutral', 'Ganz okay')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'negative' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('negative')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-4 w-4" /> {t('toolkit.feedback.negative', 'Verbesserungsbedarf')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={t('toolkit.feedback.placeholder', 'Erzähle uns kurz, was dir gefallen hat oder was fehlt …')}
|
||||
value={message}
|
||||
onChange={(event) => onMessageChange(event.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{t('toolkit.feedback.disclaimer', 'Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void onSubmit()} disabled={submitting || (!sentiment && message.trim() === '')}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
{t('toolkit.feedback.submit', 'Feedback senden')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolkitSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) {
|
||||
return '–';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '–';
|
||||
}
|
||||
return date.toLocaleString();
|
||||
return <EventDetailPage mode="toolkit" />;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ function EventCard({ event }: { event: TenantEvent }) {
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
|
||||
<p className="text-sm text-slate-600">Slug: {event.slug}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-3 py-1 font-medium text-pink-700">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles, ShieldCheck, Images, ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_HOME_PATH } from '../constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const featureIcons = [Sparkles, ShieldCheck, Images];
|
||||
|
||||
export default function LoginPage(): JSX.Element {
|
||||
const { status, login } = useAuth();
|
||||
const { t } = useTranslation('auth');
|
||||
const location = useLocation();
|
||||
@@ -35,26 +42,120 @@ export default function LoginPage() {
|
||||
return ADMIN_HOME_PATH;
|
||||
}, [location.state]);
|
||||
|
||||
const featureList = React.useMemo(() => {
|
||||
const raw = t('login.features', { returnObjects: true }) as unknown;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [] as Array<{ text: string; Icon: typeof Sparkles }>;
|
||||
}
|
||||
return (raw as string[]).map((entry, index) => ({
|
||||
text: entry,
|
||||
Icon: featureIcons[index % featureIcons.length],
|
||||
}));
|
||||
}, [t]);
|
||||
|
||||
const isLoading = status === 'loading';
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">{t('login.title')}</h1>
|
||||
<AppearanceToggleDropdown />
|
||||
</div>
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>{t('login.lead')}</p>
|
||||
{oauthError && (
|
||||
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||
{t('login.oauth_error', { message: oauthError })}
|
||||
<div className="relative min-h-screen overflow-hidden bg-[var(--brand-navy)] text-white">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,var(--brand-rose-soft)_0%,rgba(3,7,18,0.65)_55%,rgba(15,76,117,0.9)_100%)] opacity-95" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-[-25%] w-[55%] bg-[radial-gradient(circle_at_center,var(--brand-sky)_0%,rgba(255,255,255,0)_70%)] opacity-40" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-[-20%] w-[45%] bg-[radial-gradient(circle_at_center,var(--brand-rose)_0%,rgba(255,255,255,0)_65%)] opacity-35" />
|
||||
|
||||
<div className="relative z-10 flex min-h-screen flex-col">
|
||||
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 pt-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
|
||||
<AppLogoIcon className="h-7 w-7 text-white" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge')}</p>
|
||||
<p className="text-lg font-semibold">Fotospiel</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'loading'}
|
||||
onClick={() => login(redirectTarget)}
|
||||
>
|
||||
{status === 'loading' ? t('login.loading') : t('login.cta')}
|
||||
</Button>
|
||||
<AppearanceToggleDropdown />
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-6 pb-16 pt-12">
|
||||
<div className="grid flex-1 gap-12 lg:grid-cols-[0.95fr_1.05fr]" data-testid="tenant-login-layout">
|
||||
<section className="order-2 space-y-10 lg:order-1">
|
||||
<div className="space-y-5">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1 text-sm font-medium text-white/80 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4 text-[var(--brand-gold)]" />
|
||||
{t('login.badge')}
|
||||
</span>
|
||||
<h1 className="text-4xl font-semibold leading-tight sm:text-5xl">
|
||||
{t('login.hero_title')}
|
||||
</h1>
|
||||
<p className="max-w-xl text-base text-white/80 sm:text-lg">
|
||||
{t('login.hero_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{featureList.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{featureList.map(({ text, Icon }, index) => (
|
||||
<div
|
||||
key={`login-feature-${index}`}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/15 bg-white/10 p-5 shadow-lg shadow-black/5 backdrop-blur transition hover:border-white/35"
|
||||
>
|
||||
<Icon className="mb-3 h-5 w-5 text-[var(--brand-gold)] transition group-hover:text-white" />
|
||||
<p className="text-sm text-white/90">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="flex items-center gap-2 text-sm text-white/70">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
{t('login.lead')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="order-1 lg:order-2">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 -translate-y-4 translate-x-6 scale-95 rounded-3xl bg-white/20 opacity-50 blur-2xl" />
|
||||
<div className="relative overflow-hidden rounded-3xl border border-white/20 bg-white/90 p-10 text-slate-900 shadow-2xl shadow-black/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 dark:text-slate-50">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold">{t('login.title')}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">{t('login.panel_copy')}</p>
|
||||
</div>
|
||||
|
||||
{oauthError ? (
|
||||
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
|
||||
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
|
||||
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--brand-rose)] via-[var(--brand-gold)] to-[var(--brand-sky)] px-8 py-3 text-base font-semibold text-slate-900 shadow-lg shadow-rose-400/30 transition hover:opacity-90 focus-visible:ring-4 focus-visible:ring-brand-rose/40 dark:text-slate-900"
|
||||
disabled={isLoading}
|
||||
onClick={() => login(redirectTarget)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('login.cta')}
|
||||
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-300">
|
||||
{t('login.support')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,719 @@
|
||||
import React from 'react';
|
||||
import * as fabric from 'fabric';
|
||||
|
||||
import {
|
||||
CANVAS_HEIGHT,
|
||||
CANVAS_WIDTH,
|
||||
LayoutElement,
|
||||
clamp,
|
||||
LayoutElementType,
|
||||
} from './schema';
|
||||
|
||||
type DesignerCanvasProps = {
|
||||
elements: LayoutElement[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onChange: (id: string, patch: Partial<LayoutElement>) => void;
|
||||
background: string;
|
||||
gradient: { angle?: number; stops?: string[] } | null;
|
||||
accent: string;
|
||||
text: string;
|
||||
secondary: string;
|
||||
badge: string;
|
||||
qrCodeDataUrl: string | null;
|
||||
logoDataUrl: string | null;
|
||||
scale: number;
|
||||
layoutKey?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type FabricObjectWithId = fabric.Object & { elementId?: string };
|
||||
|
||||
const DEFAULT_MIN_SCALE = 0.15;
|
||||
const DEFAULT_MAX_SCALE = 0.85;
|
||||
|
||||
export function DesignerCanvas({
|
||||
elements,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onChange,
|
||||
background,
|
||||
gradient,
|
||||
accent,
|
||||
text,
|
||||
secondary,
|
||||
badge,
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
scale,
|
||||
layoutKey,
|
||||
readOnly = false,
|
||||
}: DesignerCanvasProps): React.JSX.Element {
|
||||
const canvasElementRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const fabricCanvasRef = React.useRef<fabric.Canvas | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const disposeTokenRef = React.useRef(0);
|
||||
const pendingDisposeRef = React.useRef<number | null>(null);
|
||||
const pendingTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
const destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fabricCanvasRef.current === canvas) {
|
||||
fabricCanvasRef.current = null;
|
||||
}
|
||||
|
||||
const upperEl = canvas.upperCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
|
||||
if (upperEl) {
|
||||
if (upperEl.__canvas === canvas) {
|
||||
delete upperEl.__canvas;
|
||||
}
|
||||
if (upperEl.__fabricCanvas === canvas) {
|
||||
delete upperEl.__fabricCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
const lowerEl = canvas.lowerCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
|
||||
if (lowerEl) {
|
||||
if (lowerEl.__canvas === canvas) {
|
||||
delete lowerEl.__canvas;
|
||||
}
|
||||
if (lowerEl.__fabricCanvas === canvas) {
|
||||
delete lowerEl.__fabricCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
const targetEl = canvas.getElement() as (HTMLCanvasElement & Record<string, unknown>) | undefined;
|
||||
if (targetEl) {
|
||||
if (targetEl.__canvas === canvas) {
|
||||
delete targetEl.__canvas;
|
||||
}
|
||||
if (targetEl.__fabricCanvas === canvas) {
|
||||
delete targetEl.__fabricCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = (canvas as unknown as { wrapperEl?: (HTMLElement & Record<string, unknown>) }).wrapperEl;
|
||||
if (wrapper) {
|
||||
if (wrapper.__fabricCanvas === canvas) {
|
||||
delete wrapper.__fabricCanvas;
|
||||
}
|
||||
if (Object.getOwnPropertyDescriptor(wrapper, '__canvas')) {
|
||||
try {
|
||||
delete wrapper.__canvas;
|
||||
} catch (error) {
|
||||
console.warn('[Invites][Fabric] failed to delete wrapper __canvas', error);
|
||||
}
|
||||
}
|
||||
delete wrapper.dataset?.fabric;
|
||||
}
|
||||
|
||||
if ((window as unknown as Record<string, unknown>).__inviteCanvas === canvas) {
|
||||
delete (window as unknown as Record<string, unknown>).__inviteCanvas;
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[Invites][Fabric] dispose failed', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = canvasElementRef.current;
|
||||
if (!element) {
|
||||
console.warn('[Invites][Fabric] canvas element missing');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (pendingTimeoutRef.current !== null) {
|
||||
window.clearTimeout(pendingTimeoutRef.current);
|
||||
pendingTimeoutRef.current = null;
|
||||
pendingDisposeRef.current = null;
|
||||
}
|
||||
|
||||
destroyCanvas(fabricCanvasRef.current);
|
||||
|
||||
console.warn('[Invites][Fabric] initializing canvas element');
|
||||
|
||||
const canvas = new fabric.Canvas(element, {
|
||||
selection: !readOnly,
|
||||
preserveObjectStacking: true,
|
||||
perPixelTargetFind: true,
|
||||
});
|
||||
|
||||
fabricCanvasRef.current = canvas;
|
||||
const disposeToken = ++disposeTokenRef.current;
|
||||
|
||||
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
|
||||
(element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas;
|
||||
if (containerRef.current) {
|
||||
const wrapper = containerRef.current as (HTMLElement & Record<string, unknown>);
|
||||
wrapper.__fabricCanvas = canvas;
|
||||
Object.defineProperty(wrapper, '__canvas', {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: canvas,
|
||||
});
|
||||
wrapper.dataset.fabric = 'ready';
|
||||
}
|
||||
|
||||
return () => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (disposeTokenRef.current !== disposeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
destroyCanvas(canvas);
|
||||
pendingTimeoutRef.current = null;
|
||||
pendingDisposeRef.current = null;
|
||||
}, 0);
|
||||
pendingTimeoutRef.current = timeoutId;
|
||||
pendingDisposeRef.current = disposeToken;
|
||||
};
|
||||
}, [destroyCanvas, readOnly]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.selection = !readOnly;
|
||||
canvas.forEachObject((object) => {
|
||||
object.set({
|
||||
selectable: !readOnly,
|
||||
hoverCursor: readOnly ? 'default' : 'move',
|
||||
});
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSelection = () => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const active = canvas.getActiveObject() as FabricObjectWithId | null;
|
||||
if (!active || typeof active.elementId !== 'string') {
|
||||
onSelect(null);
|
||||
return;
|
||||
}
|
||||
onSelect(active.elementId);
|
||||
};
|
||||
|
||||
const handleSelectionCleared = () => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
onSelect(null);
|
||||
};
|
||||
|
||||
const handleObjectModified = (event: fabric.IEvent<fabric.Object>) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as FabricObjectWithId | undefined;
|
||||
if (!target || typeof target.elementId !== 'string') {
|
||||
return;
|
||||
}
|
||||
const elementId = target.elementId;
|
||||
|
||||
const bounds = target.getBoundingRect(true, true);
|
||||
const nextPatch: Partial<LayoutElement> = {
|
||||
x: clamp(Math.round(bounds.left ?? 0), 0, CANVAS_WIDTH),
|
||||
y: clamp(Math.round(bounds.top ?? 0), 0, CANVAS_HEIGHT),
|
||||
width: clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH),
|
||||
height: clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT),
|
||||
};
|
||||
|
||||
target.set({
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
left: nextPatch.x,
|
||||
top: nextPatch.y,
|
||||
width: nextPatch.width,
|
||||
height: nextPatch.height,
|
||||
});
|
||||
|
||||
onChange(elementId, nextPatch);
|
||||
canvas.requestRenderAll();
|
||||
};
|
||||
|
||||
canvas.on('selection:created', handleSelection);
|
||||
canvas.on('selection:updated', handleSelection);
|
||||
canvas.on('selection:cleared', handleSelectionCleared);
|
||||
canvas.on('object:modified', handleObjectModified);
|
||||
|
||||
return () => {
|
||||
canvas.off('selection:created', handleSelection);
|
||||
canvas.off('selection:updated', handleSelection);
|
||||
canvas.off('selection:cleared', handleSelectionCleared);
|
||||
canvas.off('object:modified', handleObjectModified);
|
||||
};
|
||||
}, [onChange, onSelect, readOnly]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderFabricLayout(canvas, {
|
||||
elements,
|
||||
accentColor: accent,
|
||||
textColor: text,
|
||||
secondaryColor: secondary,
|
||||
badgeColor: badge,
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
backgroundColor: background,
|
||||
backgroundGradient: gradient,
|
||||
readOnly,
|
||||
selectedId,
|
||||
}).catch((error) => {
|
||||
console.error('[Fabric] Failed to render layout', error);
|
||||
});
|
||||
}, [
|
||||
elements,
|
||||
accent,
|
||||
text,
|
||||
secondary,
|
||||
badge,
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
background,
|
||||
gradient,
|
||||
selectedId,
|
||||
readOnly,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.setZoom(scale);
|
||||
canvas.setDimensions(
|
||||
{
|
||||
width: CANVAS_WIDTH * scale,
|
||||
height: CANVAS_HEIGHT * scale,
|
||||
},
|
||||
{ cssOnly: true },
|
||||
);
|
||||
canvas.requestRenderAll();
|
||||
}, [scale]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-block max-w-full">
|
||||
<canvas
|
||||
ref={canvasElementRef}
|
||||
width={CANVAS_WIDTH}
|
||||
height={CANVAS_HEIGHT}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type FabricRenderOptions = {
|
||||
elements: LayoutElement[];
|
||||
accentColor: string;
|
||||
textColor: string;
|
||||
secondaryColor: string;
|
||||
badgeColor: string;
|
||||
qrCodeDataUrl: string | null;
|
||||
logoDataUrl: string | null;
|
||||
backgroundColor: string;
|
||||
backgroundGradient: { angle?: number; stops?: string[] } | null;
|
||||
readOnly: boolean;
|
||||
selectedId?: string | null;
|
||||
};
|
||||
|
||||
export async function renderFabricLayout(
|
||||
canvas: fabric.Canvas,
|
||||
options: FabricRenderOptions,
|
||||
): Promise<void> {
|
||||
const {
|
||||
elements,
|
||||
accentColor,
|
||||
textColor,
|
||||
secondaryColor,
|
||||
badgeColor,
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
backgroundColor,
|
||||
backgroundGradient,
|
||||
readOnly,
|
||||
selectedId,
|
||||
} = options;
|
||||
|
||||
canvas.discardActiveObject();
|
||||
canvas.getObjects().forEach((object) => canvas.remove(object));
|
||||
|
||||
applyBackground(canvas, backgroundColor, backgroundGradient);
|
||||
|
||||
console.debug('[Invites][Fabric] render', {
|
||||
elementCount: elements.length,
|
||||
backgroundColor,
|
||||
hasGradient: Boolean(backgroundGradient),
|
||||
readOnly,
|
||||
});
|
||||
|
||||
const objectPromises = elements.map((element) =>
|
||||
createFabricObject({
|
||||
element,
|
||||
accentColor,
|
||||
textColor,
|
||||
secondaryColor,
|
||||
badgeColor,
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
readOnly,
|
||||
}),
|
||||
);
|
||||
|
||||
const fabricObjects = await Promise.all(objectPromises);
|
||||
|
||||
fabricObjects.forEach((object) => {
|
||||
if (!object) {
|
||||
console.debug('[Invites][Fabric] Skip null fabric object');
|
||||
return;
|
||||
}
|
||||
if (readOnly) {
|
||||
object.set({
|
||||
selectable: false,
|
||||
hoverCursor: 'default',
|
||||
});
|
||||
}
|
||||
try {
|
||||
canvas.add(object);
|
||||
if (typeof object.setCoords === 'function') {
|
||||
object.setCoords();
|
||||
}
|
||||
const bounds = object.getBoundingRect(true, true);
|
||||
console.warn('[Invites][Fabric] added object', {
|
||||
elementId: (object as FabricObjectWithId).elementId,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Invites][Fabric] failed to add object', error);
|
||||
}
|
||||
});
|
||||
|
||||
console.warn('[Invites][Fabric] object count', canvas.getObjects().length);
|
||||
|
||||
if (!readOnly && selectedId) {
|
||||
const match = canvas
|
||||
.getObjects()
|
||||
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
|
||||
if (match) {
|
||||
canvas.setActiveObject(match);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.renderAll();
|
||||
}
|
||||
|
||||
export function applyBackground(
|
||||
canvas: fabric.Canvas,
|
||||
color: string,
|
||||
gradient: { angle?: number; stops?: string[] } | null,
|
||||
): void {
|
||||
let background: string | fabric.Gradient = color;
|
||||
|
||||
if (gradient?.stops?.length) {
|
||||
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
|
||||
const halfWidth = CANVAS_WIDTH / 2;
|
||||
const halfHeight = CANVAS_HEIGHT / 2;
|
||||
const x = Math.cos(angle);
|
||||
const y = Math.sin(angle);
|
||||
|
||||
background = new fabric.Gradient({
|
||||
type: 'linear',
|
||||
coords: {
|
||||
x1: halfWidth - x * halfWidth,
|
||||
y1: halfHeight - y * halfHeight,
|
||||
x2: halfWidth + x * halfWidth,
|
||||
y2: halfHeight + y * halfHeight,
|
||||
},
|
||||
colorStops: gradient.stops.map((stop, index) => ({
|
||||
offset: gradient.stops.length === 1 ? 0 : index / (gradient.stops.length - 1),
|
||||
color: stop,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const canvasWithBackgroundFn = canvas as fabric.Canvas & {
|
||||
setBackgroundColor?: (value: string | fabric.Gradient, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
if (typeof canvasWithBackgroundFn.setBackgroundColor === 'function') {
|
||||
canvasWithBackgroundFn.setBackgroundColor(background, () => canvas.requestRenderAll());
|
||||
} else {
|
||||
canvas.backgroundColor = background;
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
}
|
||||
|
||||
export type FabricObjectFactoryContext = {
|
||||
element: LayoutElement;
|
||||
accentColor: string;
|
||||
textColor: string;
|
||||
secondaryColor: string;
|
||||
badgeColor: string;
|
||||
qrCodeDataUrl: string | null;
|
||||
logoDataUrl: string | null;
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
export async function createFabricObject({
|
||||
element,
|
||||
accentColor,
|
||||
textColor,
|
||||
secondaryColor,
|
||||
badgeColor,
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
readOnly,
|
||||
}: FabricObjectFactoryContext): Promise<fabric.Object | null> {
|
||||
console.debug('[Invites][Fabric] create element', {
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
content: element.content,
|
||||
});
|
||||
|
||||
const baseConfig = {
|
||||
left: element.x,
|
||||
top: element.y,
|
||||
elementId: element.id,
|
||||
selectable: !readOnly,
|
||||
hasBorders: !readOnly,
|
||||
hasControls: !readOnly,
|
||||
} as FabricObjectWithId;
|
||||
|
||||
switch (element.type) {
|
||||
case 'headline':
|
||||
case 'subtitle':
|
||||
case 'description':
|
||||
case 'text':
|
||||
return new fabric.Textbox(element.content ?? '', {
|
||||
...baseConfig,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 26,
|
||||
fill: textColor,
|
||||
textAlign: mapTextAlign(element.align),
|
||||
});
|
||||
case 'link':
|
||||
return new fabric.Textbox(element.content ?? '', {
|
||||
...baseConfig,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 24,
|
||||
fill: accentColor,
|
||||
underline: true,
|
||||
textAlign: mapTextAlign(element.align),
|
||||
});
|
||||
case 'badge':
|
||||
return createTextBadge({
|
||||
baseConfig,
|
||||
text: element.content ?? '',
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
backgroundColor: badgeColor,
|
||||
textColor: '#ffffff',
|
||||
fontSize: element.fontSize ?? 22,
|
||||
});
|
||||
case 'cta':
|
||||
return createTextBadge({
|
||||
baseConfig,
|
||||
text: element.content ?? '',
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
backgroundColor: accentColor,
|
||||
textColor: '#ffffff',
|
||||
fontSize: element.fontSize ?? 24,
|
||||
cornerRadius: 18,
|
||||
});
|
||||
case 'logo':
|
||||
if (logoDataUrl) {
|
||||
return loadImageObject(logoDataUrl, element, baseConfig, {
|
||||
objectFit: 'contain',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
case 'qr':
|
||||
if (qrCodeDataUrl) {
|
||||
return loadImageObject(qrCodeDataUrl, element, baseConfig, {
|
||||
shadow: 'rgba(15,23,42,0.25)',
|
||||
});
|
||||
}
|
||||
return new fabric.Rect({
|
||||
...baseConfig,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fill: secondaryColor,
|
||||
rx: 20,
|
||||
ry: 20,
|
||||
});
|
||||
default:
|
||||
return new fabric.Textbox(element.content ?? '', {
|
||||
...baseConfig,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 24,
|
||||
fill: secondaryColor,
|
||||
textAlign: mapTextAlign(element.align),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createTextBadge({
|
||||
baseConfig,
|
||||
text,
|
||||
width,
|
||||
height,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
fontSize,
|
||||
cornerRadius = 12,
|
||||
}: {
|
||||
baseConfig: FabricObjectWithId;
|
||||
text: string;
|
||||
width: number;
|
||||
height: number;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
cornerRadius?: number;
|
||||
}): fabric.Group {
|
||||
const rect = new fabric.Rect({
|
||||
width,
|
||||
height,
|
||||
rx: cornerRadius,
|
||||
ry: cornerRadius,
|
||||
fill: backgroundColor,
|
||||
left: 0,
|
||||
top: 0,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
const label = new fabric.Textbox(text, {
|
||||
width: width - 32,
|
||||
left: 16,
|
||||
top: height / 2,
|
||||
fontSize,
|
||||
fill: textColor,
|
||||
originY: 'center',
|
||||
textAlign: 'center',
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
return new fabric.Group([rect, label], {
|
||||
...baseConfig,
|
||||
width,
|
||||
height,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
}) as fabric.Group & FabricObjectWithId;
|
||||
}
|
||||
|
||||
export async function loadImageObject(
|
||||
source: string,
|
||||
element: LayoutElement,
|
||||
baseConfig: FabricObjectWithId,
|
||||
options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
|
||||
): Promise<fabric.Object | null> {
|
||||
return new Promise((resolve) => {
|
||||
fabric.Image.fromURL(
|
||||
source,
|
||||
(image) => {
|
||||
if (!image) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleX = element.width / (image.width ?? element.width);
|
||||
const scaleY = element.height / (image.height ?? element.height);
|
||||
|
||||
image.set({
|
||||
...baseConfig,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
scaleX,
|
||||
scaleY,
|
||||
});
|
||||
|
||||
if (options?.shadow) {
|
||||
image.set('shadow', options.shadow);
|
||||
}
|
||||
|
||||
if (options?.objectFit === 'contain') {
|
||||
const ratio = Math.min(scaleX, scaleY);
|
||||
image.set({
|
||||
scaleX: ratio,
|
||||
scaleY: ratio,
|
||||
left: element.x + (element.width - (image.width ?? 0) * ratio) / 2,
|
||||
top: element.y + (element.height - (image.height ?? 0) * ratio) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
resolve(image);
|
||||
},
|
||||
{ crossOrigin: 'anonymous' },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center' | 'right' {
|
||||
switch (align) {
|
||||
case 'center':
|
||||
return 'center';
|
||||
case 'right':
|
||||
return 'right';
|
||||
default:
|
||||
return 'left';
|
||||
}
|
||||
}
|
||||
|
||||
export function CanvasScaleControl({
|
||||
scale,
|
||||
min = DEFAULT_MIN_SCALE,
|
||||
max = DEFAULT_MAX_SCALE,
|
||||
onChange,
|
||||
}: {
|
||||
scale: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
onChange: (value: number) => void;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-4 py-2 text-xs">
|
||||
<span className="font-medium text-muted-foreground">Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.025}
|
||||
value={scale}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="h-1 w-32 overflow-hidden rounded-full"
|
||||
/>
|
||||
<span className="tabular-nums text-muted-foreground">{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as fabric from 'fabric';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './schema';
|
||||
import { FabricRenderOptions, renderFabricLayout } from './DesignerCanvas';
|
||||
|
||||
const PDF_PAGE_SIZES: Record<string, { width: number; height: number }> = {
|
||||
a4: { width: 595.28, height: 841.89 },
|
||||
letter: { width: 612, height: 792 },
|
||||
};
|
||||
|
||||
export async function withFabricCanvas<T>(
|
||||
options: FabricRenderOptions,
|
||||
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const canvasElement = document.createElement('canvas');
|
||||
canvasElement.width = CANVAS_WIDTH;
|
||||
canvasElement.height = CANVAS_HEIGHT;
|
||||
|
||||
const canvas = new fabric.Canvas(canvasElement, {
|
||||
selection: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await renderFabricLayout(canvas, {
|
||||
...options,
|
||||
readOnly: true,
|
||||
selectedId: null,
|
||||
});
|
||||
return await handler(canvas, canvasElement);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
canvasElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePngDataUrl(
|
||||
options: FabricRenderOptions,
|
||||
multiplier = 2,
|
||||
): Promise<string> {
|
||||
return withFabricCanvas(options, async (canvas) =>
|
||||
canvas.toDataURL({ format: 'png', multiplier }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generatePdfBytes(
|
||||
options: FabricRenderOptions,
|
||||
paper: string,
|
||||
orientation: string,
|
||||
multiplier = 2,
|
||||
): Promise<Uint8Array> {
|
||||
const dataUrl = await generatePngDataUrl(options, multiplier);
|
||||
return createPdfFromPng(dataUrl, paper, orientation);
|
||||
}
|
||||
|
||||
export async function createPdfFromPng(
|
||||
dataUrl: string,
|
||||
paper: string,
|
||||
orientation: string,
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const baseSize = PDF_PAGE_SIZES[paper.toLowerCase()] ?? PDF_PAGE_SIZES.a4;
|
||||
const landscape = orientation === 'landscape';
|
||||
const pageWidth = landscape ? baseSize.height : baseSize.width;
|
||||
const pageHeight = landscape ? baseSize.width : baseSize.height;
|
||||
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
const pngBytes = dataUrlToUint8Array(dataUrl);
|
||||
const pngImage = await pdfDoc.embedPng(pngBytes);
|
||||
|
||||
const imageWidth = pngImage.width;
|
||||
const imageHeight = pngImage.height;
|
||||
const scale = Math.min(pageWidth / imageWidth, pageHeight / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
|
||||
page.drawImage(pngImage, {
|
||||
x: (pageWidth - drawWidth) / 2,
|
||||
y: (pageHeight - drawHeight) / 2,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
});
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
|
||||
export function triggerDownloadFromDataUrl(dataUrl: string, filename: string): Promise<void> {
|
||||
return fetch(dataUrl)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => triggerDownloadFromBlob(blob, filename));
|
||||
}
|
||||
|
||||
export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
}
|
||||
|
||||
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<void> {
|
||||
const blobUrl = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
|
||||
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (!printWindow) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
throw new Error('window-blocked');
|
||||
}
|
||||
|
||||
printWindow.onload = () => {
|
||||
try {
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
} catch (error) {
|
||||
console.error('[FabricExport] Browser print failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}
|
||||
|
||||
function dataUrlToUint8Array(dataUrl: string): Uint8Array {
|
||||
const [, base64] = dataUrl.split(',');
|
||||
const decoded = atob(base64 ?? '');
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) {
|
||||
bytes[index] = decoded.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
530
resources/js/admin/pages/components/invite-layout/schema.ts
Normal file
530
resources/js/admin/pages/components/invite-layout/schema.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import type { EventQrInviteLayout } from '../../api';
|
||||
|
||||
export const CANVAS_WIDTH = 1240;
|
||||
export const CANVAS_HEIGHT = 1754;
|
||||
|
||||
export type LayoutElementType =
|
||||
| 'qr'
|
||||
| 'headline'
|
||||
| 'subtitle'
|
||||
| 'description'
|
||||
| 'link'
|
||||
| 'badge'
|
||||
| 'logo'
|
||||
| 'cta'
|
||||
| 'text';
|
||||
|
||||
export type LayoutTextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface LayoutElement {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
content?: string | null;
|
||||
fontFamily?: string | null;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
fill?: string | null;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
}
|
||||
|
||||
type PresetValue = number | ((context: LayoutPresetContext) => number);
|
||||
|
||||
type LayoutPresetElement = {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: PresetValue;
|
||||
y: PresetValue;
|
||||
width?: PresetValue;
|
||||
height?: PresetValue;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
};
|
||||
|
||||
type LayoutPreset = LayoutPresetElement[];
|
||||
|
||||
interface LayoutPresetContext {
|
||||
qrSize: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
export interface LayoutElementPayload {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
font_size?: number;
|
||||
align?: LayoutTextAlign;
|
||||
content?: string | null;
|
||||
font_family?: string | null;
|
||||
letter_spacing?: number;
|
||||
line_height?: number;
|
||||
fill?: string | null;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutSerializationContext {
|
||||
form: QrLayoutCustomization;
|
||||
eventName: string;
|
||||
inviteUrl: string;
|
||||
instructions: string[];
|
||||
qrSize: number;
|
||||
badgeFallback: string;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
export type QrLayoutCustomization = {
|
||||
layout_id?: string;
|
||||
headline?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
badge_label?: string;
|
||||
instructions_heading?: string;
|
||||
instructions?: string[];
|
||||
link_heading?: string;
|
||||
link_label?: string;
|
||||
cta_label?: string;
|
||||
accent_color?: string;
|
||||
text_color?: string;
|
||||
background_color?: string;
|
||||
secondary_color?: string;
|
||||
badge_color?: string;
|
||||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||||
logo_data_url?: string | null;
|
||||
logo_url?: string | null;
|
||||
mode?: 'standard' | 'advanced';
|
||||
elements?: LayoutElementPayload[];
|
||||
};
|
||||
|
||||
export const MIN_QR_SIZE = 240;
|
||||
export const MAX_QR_SIZE = 720;
|
||||
export const MIN_TEXT_WIDTH = 160;
|
||||
export const MIN_TEXT_HEIGHT = 80;
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (Number.isNaN(value)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function clampElement(element: LayoutElement): LayoutElement {
|
||||
return {
|
||||
...element,
|
||||
x: clamp(element.x, 0, CANVAS_WIDTH - element.width),
|
||||
y: clamp(element.y, 0, CANVAS_HEIGHT - element.height),
|
||||
width: clamp(element.width, 40, CANVAS_WIDTH),
|
||||
height: clamp(element.height, 40, CANVAS_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
|
||||
headline: { width: 620, height: 200, fontSize: 68, align: 'left' },
|
||||
subtitle: { width: 580, height: 140, fontSize: 34, align: 'left' },
|
||||
description: { width: 620, height: 280, fontSize: 28, align: 'left' },
|
||||
link: { width: 400, height: 110, fontSize: 28, align: 'center' },
|
||||
badge: { width: 280, height: 80, fontSize: 24, align: 'center' },
|
||||
logo: { width: 240, height: 180, align: 'center' },
|
||||
cta: { width: 400, height: 110, fontSize: 26, align: 'center' },
|
||||
qr: { width: 520, height: 520 },
|
||||
text: { width: 560, height: 200, fontSize: 26, align: 'left' },
|
||||
};
|
||||
|
||||
const DEFAULT_PRESET: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 120, y: 140, width: 320, height: 80, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 260, width: 620, height: 200, fontSize: 68, align: 'left' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 440, width: 600, height: 140, fontSize: 34, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 120, y: 600, width: 620, height: 280, fontSize: 28, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - context.qrSize - 140,
|
||||
y: 360,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 400 + context.qrSize,
|
||||
width: 400,
|
||||
height: 110,
|
||||
fontSize: 28,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 420 + context.qrSize + 140,
|
||||
width: 400,
|
||||
height: 110,
|
||||
fontSize: 26,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
const evergreenVowsPreset: LayoutPreset = [
|
||||
{ id: 'logo', type: 'logo', x: 120, y: 140, width: 240, height: 180 },
|
||||
{ id: 'badge', type: 'badge', x: 400, y: 160, width: 320, height: 80, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 360, width: 620, height: 220, fontSize: 70, align: 'left' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 560, width: 600, height: 140, fontSize: 34, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 120, y: 720, width: 620, height: 280, fontSize: 28, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - context.qrSize - 160,
|
||||
y: 460,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 500 + context.qrSize,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 520 + context.qrSize + 150,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const midnightGalaPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 360, y: 160, width: 520, height: 90, align: 'center', fontSize: 26 },
|
||||
{ id: 'headline', type: 'headline', x: 220, y: 300, width: 800, height: 220, fontSize: 76, align: 'center' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 36, align: 'center' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => (context.canvasWidth - context.qrSize) / 2,
|
||||
y: 700,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 740 + context.qrSize,
|
||||
width: 420,
|
||||
height: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 770 + context.qrSize + 150,
|
||||
width: 420,
|
||||
height: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{ id: 'description', type: 'description', x: 200, y: 1040, width: 840, height: 260, fontSize: 28, align: 'center' },
|
||||
];
|
||||
|
||||
const gardenBrunchPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 160, y: 160, width: 360, height: 80, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 160, y: 300, width: 560, height: 200, fontSize: 66, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 160, y: 520, width: 560, height: 260, fontSize: 28, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: 160,
|
||||
y: 840,
|
||||
width: (context) => Math.min(context.qrSize, 520),
|
||||
height: (context) => Math.min(context.qrSize, 520),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: 160,
|
||||
y: (context) => 880 + Math.min(context.qrSize, 520),
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: 160,
|
||||
y: (context) => 910 + Math.min(context.qrSize, 520) + 140,
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: 780, y: 320, width: 320, height: 140, fontSize: 32, align: 'left' },
|
||||
{ id: 'text-strip', type: 'text', x: 780, y: 480, width: 320, height: 320, fontSize: 24, align: 'left' },
|
||||
];
|
||||
|
||||
const sparklerSoireePreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 360, y: 150, width: 520, height: 90, align: 'center', fontSize: 26 },
|
||||
{ id: 'headline', type: 'headline', x: 200, y: 300, width: 840, height: 220, fontSize: 72, align: 'center' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 34, align: 'center' },
|
||||
{ id: 'description', type: 'description', x: 220, y: 680, width: 800, height: 240, fontSize: 28, align: 'center' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => (context.canvasWidth - context.qrSize) / 2,
|
||||
y: 960,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 1000 + context.qrSize,
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 1030 + context.qrSize + 140,
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const confettiBashPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 140, y: 180, width: 360, height: 90, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 140, y: 320, width: 520, height: 220, fontSize: 68, align: 'left' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 140, y: 520, width: 520, height: 140, fontSize: 34, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 140, y: 680, width: 520, height: 240, fontSize: 26, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - context.qrSize - 200,
|
||||
y: 360,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 400 + context.qrSize,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 430 + context.qrSize + 140,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{ id: 'text-strip', type: 'text', x: 140, y: 960, width: 860, height: 220, fontSize: 26, align: 'left' },
|
||||
];
|
||||
|
||||
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
|
||||
'default': DEFAULT_PRESET,
|
||||
'evergreen-vows': evergreenVowsPreset,
|
||||
'midnight-gala': midnightGalaPreset,
|
||||
'garden-brunch': gardenBrunchPreset,
|
||||
'sparkler-soiree': sparklerSoireePreset,
|
||||
'confetti-bash': confettiBashPreset,
|
||||
};
|
||||
|
||||
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
|
||||
if (typeof value === 'function') {
|
||||
const resolved = value(context);
|
||||
return typeof resolved === 'number' ? resolved : fallback;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function buildDefaultElements(
|
||||
layout: EventQrInviteLayout,
|
||||
form: QrLayoutCustomization,
|
||||
eventName: string,
|
||||
qrSize: number
|
||||
): LayoutElement[] {
|
||||
const size = clamp(qrSize, MIN_QR_SIZE, MAX_QR_SIZE);
|
||||
const context: LayoutPresetContext = {
|
||||
qrSize: size,
|
||||
canvasWidth: CANVAS_WIDTH,
|
||||
canvasHeight: CANVAS_HEIGHT,
|
||||
};
|
||||
|
||||
const preset = LAYOUT_PRESETS[layout.id ?? ''] ?? DEFAULT_PRESET;
|
||||
|
||||
const instructionsHeading = form.instructions_heading ?? layout.instructions_heading ?? "So funktioniert's";
|
||||
const instructionsList = Array.isArray(form.instructions) && form.instructions.length
|
||||
? form.instructions
|
||||
: (layout.instructions ?? []);
|
||||
|
||||
const baseContent: Record<string, string | null> = {
|
||||
headline: form.headline ?? eventName,
|
||||
subtitle: form.subtitle ?? layout.subtitle ?? '',
|
||||
description: form.description ?? layout.description ?? '',
|
||||
badge: form.badge_label ?? layout.badge_label ?? 'Digitale Gästebox',
|
||||
link: form.link_label ?? '',
|
||||
cta: form.cta_label ?? layout.cta_label ?? 'Scan mich & starte direkt',
|
||||
instructions_heading: instructionsHeading,
|
||||
instructions_text: instructionsList[0] ?? null,
|
||||
};
|
||||
|
||||
const elements = preset.map((config) => {
|
||||
const typeStyle = DEFAULT_TYPE_STYLES[config.type] ?? { width: 420, height: 160 };
|
||||
const widthFallback = config.type === 'qr' ? size : typeStyle.width;
|
||||
const heightFallback = config.type === 'qr' ? size : typeStyle.height;
|
||||
const element: LayoutElement = {
|
||||
id: config.id,
|
||||
type: config.type,
|
||||
x: resolvePresetValue(config.x, context, 0),
|
||||
y: resolvePresetValue(config.y, context, 0),
|
||||
width: resolvePresetValue(config.width, context, widthFallback),
|
||||
height: resolvePresetValue(config.height, context, heightFallback),
|
||||
fontSize: config.fontSize ?? typeStyle.fontSize,
|
||||
align: config.align ?? typeStyle.align ?? 'left',
|
||||
content: null,
|
||||
locked: config.locked ?? typeStyle.locked ?? false,
|
||||
initial: config.initial ?? true,
|
||||
};
|
||||
|
||||
if (config.type === 'description') {
|
||||
element.lineHeight = 1.4;
|
||||
}
|
||||
|
||||
switch (config.id) {
|
||||
case 'headline':
|
||||
element.content = baseContent.headline;
|
||||
break;
|
||||
case 'subtitle':
|
||||
element.content = baseContent.subtitle;
|
||||
break;
|
||||
case 'description':
|
||||
element.content = baseContent.description;
|
||||
break;
|
||||
case 'badge':
|
||||
element.content = baseContent.badge;
|
||||
break;
|
||||
case 'link':
|
||||
element.content = baseContent.link;
|
||||
break;
|
||||
case 'cta':
|
||||
element.content = baseContent.cta;
|
||||
break;
|
||||
case 'text-strip':
|
||||
element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.';
|
||||
break;
|
||||
case 'logo':
|
||||
element.content = form.logo_data_url ?? form.logo_url ?? layout.logo_url ?? null;
|
||||
break;
|
||||
default:
|
||||
if (config.type === 'text') {
|
||||
element.content = element.content ?? 'Individualisiere diesen Textblock mit eigenen Infos.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (config.type === 'qr') {
|
||||
element.locked = false;
|
||||
}
|
||||
|
||||
const clamped = clampElement(element);
|
||||
return {
|
||||
...clamped,
|
||||
initial: element.initial ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
export function payloadToElements(payload?: LayoutElementPayload[] | null): LayoutElement[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload.map((entry) =>
|
||||
clampElement({
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Number(entry.x ?? 0),
|
||||
y: Number(entry.y ?? 0),
|
||||
width: Number(entry.width ?? 100),
|
||||
height: Number(entry.height ?? 100),
|
||||
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
|
||||
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
|
||||
align: entry.align ?? 'left',
|
||||
content: entry.content ?? null,
|
||||
fontFamily: entry.font_family ?? null,
|
||||
letterSpacing: entry.letter_spacing ?? undefined,
|
||||
lineHeight: entry.line_height ?? undefined,
|
||||
fill: entry.fill ?? null,
|
||||
locked: Boolean(entry.locked),
|
||||
initial: Boolean(entry.initial),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function elementsToPayload(elements: LayoutElement[]): LayoutElementPayload[] {
|
||||
return elements.map((element) => ({
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
rotation: element.rotation ?? 0,
|
||||
font_size: element.fontSize,
|
||||
align: element.align,
|
||||
content: element.content ?? null,
|
||||
font_family: element.fontFamily ?? null,
|
||||
letter_spacing: element.letterSpacing,
|
||||
line_height: element.lineHeight,
|
||||
fill: element.fill ?? null,
|
||||
locked: element.locked ?? false,
|
||||
initial: element.initial ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
|
||||
const seen = new Set<string>();
|
||||
return elements
|
||||
.filter((element) => {
|
||||
if (!element.id) {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(element.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(element.id);
|
||||
return true;
|
||||
})
|
||||
.map(clampElement);
|
||||
}
|
||||
Reference in New Issue
Block a user