added missing translations

This commit is contained in:
Codex Agent
2025-11-26 14:41:39 +01:00
parent ff168834b4
commit ecac9507a4
35 changed files with 2812 additions and 256 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
@@ -7,6 +8,7 @@ import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
export default function AuthCallbackPage(): React.ReactElement {
const { status } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation('auth');
const [redirected, setRedirected] = React.useState(false);
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
@@ -35,10 +37,8 @@ export default function AuthCallbackPage(): React.ReactElement {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet </span>
<p className="max-w-xs text-xs text-muted-foreground">
Bitte warte einen Moment. Wir richten dein Dashboard ein.
</p>
<span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span>
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
</div>
);
}

View File

@@ -681,6 +681,7 @@ export default function EventBrandingPage(): React.ReactElement {
}
function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: 'light' | 'dark' }) {
const { t } = useTranslation('management');
const textColor = getContrastingTextColor(branding.palette.primary, '#0f172a', '#ffffff');
const headerStyle: React.CSSProperties = {
background: `linear-gradient(135deg, ${branding.palette.primary}, ${branding.palette.secondary})`,
@@ -709,9 +710,11 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
</div>
<div className="flex flex-col">
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>
Demo Event
{t('branding.preview.demoTitle', 'Demo Event')}
</CardTitle>
<span className="text-xs opacity-80">Gastansicht · {branding.mode}</span>
<span className="text-xs opacity-80">
{t('branding.preview.guestView', { mode: branding.mode, defaultValue: 'Guest view · {{mode}}' })}
</span>
</div>
</div>
</div>
@@ -719,7 +722,7 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
<CardContent className="space-y-4 bg-[var(--surface)] px-4 py-5" style={{ ['--surface' as string]: branding.palette.surface }}>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-200" style={{ fontFamily: branding.typography.body || undefined }}>
CTA & Buttons spiegeln den gewählten Stil wider.
{t('branding.preview.ctaCopy', 'CTA & buttons reflect the chosen style.')}
</p>
<Button
className="shadow-md transition"
@@ -730,12 +733,14 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
paddingBlock: '10px',
}}
>
Jetzt Fotos hochladen
{t('branding.preview.cta', 'Upload photos now')}
</Button>
</div>
<Separator />
<div className="flex items-center justify-between rounded-2xl border border-dashed border-slate-200 p-3 text-sm dark:border-slate-700" style={{ borderRadius: branding.buttons.radius }}>
<span style={{ color: branding.buttons.linkColor || branding.palette.secondary }}>Bottom Navigation</span>
<span style={{ color: branding.buttons.linkColor || branding.palette.secondary }}>
{t('branding.preview.bottomNav', 'Bottom navigation')}
</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.primary }} />
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.secondary }} />

View File

@@ -59,6 +59,8 @@ import {
ADMIN_EVENT_BRANDING_PATH,
buildEngagementTabPath,
} from '../constants';
import { buildEventTabs } from '../lib/eventTabs';
import { formatEventDate } from '../lib/events';
import {
SectionCard,
SectionHeader,
@@ -207,6 +209,18 @@ export default function EventDetailPage() {
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const currentTabKey = 'overview';
const eventTabs = React.useMemo(() => {
if (!event) return [];
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const counts = {
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
tasks: toolkitData?.tasks?.summary?.total ?? event.tasks_count ?? undefined,
invites: event.active_invites_count ?? event.total_invites_count ?? undefined,
};
return buildEventTabs(event, translateMenu, counts);
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);
const limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
@@ -352,6 +366,8 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
<AdminLayout
title={eventName}
subtitle={subtitle}
tabs={eventTabs}
currentTabKey={currentTabKey}
>
{error && (
<Alert variant="destructive">
@@ -538,6 +554,159 @@ function resolveName(name: TenantEvent['name']): string {
return 'Event';
}
type RecapContentProps = {
event: TenantEvent;
stats: EventStats;
busy: boolean;
onToggleEvent: () => void;
onExtendGallery: () => void;
};
function RecapContent({ event, stats, busy, onToggleEvent, onExtendGallery }: RecapContentProps) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null;
const galleryStatusLabel = event.is_active
? t('events.recap.galleryOpen', 'Galerie geöffnet')
: t('events.recap.galleryClosed', 'Galerie geschlossen');
const counts = {
photos: stats.uploads_total ?? stats.total ?? 0,
pending: stats.pending_photos ?? 0,
likes: stats.likes_total ?? stats.likes ?? 0,
};
return (
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-500">
{t('events.recap.galleryTitle', 'Galerie-Status')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{galleryStatusLabel}</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
</p>
</div>
<Badge variant={event.is_active ? 'default' : 'outline'} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-100">
{event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant={event.is_active ? 'secondary' : 'default'} disabled={busy} onClick={onToggleEvent}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
<Camera className="mr-2 h-4 w-4" />
{t('events.recap.moderate', 'Uploads ansehen')}
</Button>
</div>
{event.public_url ? (
<div className="mt-4 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<span className="truncate" title={event.public_url}>{event.public_url}</span>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => navigator.clipboard.writeText(event.public_url!)}>
<QrCode className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500">
{t('events.recap.exportTitle', 'Export & Backup')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{t('events.recap.exportCopy', 'Alle Assets sichern')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
</p>
</div>
<Badge variant="outline" className="border-indigo-200 text-indigo-700 dark:border-indigo-800 dark:text-indigo-200">
{t('events.recap.backup', 'Backup')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
<ArrowLeft className="mr-2 h-4 w-4 rotate-180" />
{t('events.recap.exportAll', 'Alles exportieren')}
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.exportHighlights', 'Highlights exportieren')}
</Button>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">
{t('events.recap.retentionTitle', 'Aufbewahrung & Verlängerung')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{galleryExpiresAt
? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
: t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
</p>
</div>
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-100">
{t('events.recap.expiry', 'Ablauf')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="default" onClick={onExtendGallery}>
<Clock3 className="mr-2 h-4 w-4" />
{t('events.recap.extend30', '+30 Tage verlängern')}
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}>
{t('events.recap.archive', 'Archivieren/Löschen')}
</Button>
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
{t('events.recap.commsTitle', 'Kommunikation')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{t('events.recap.commsCopy', 'Kein Gast-Newsletter aktiv')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.commsHint', 'Push wirkt vor allem live. Teile den Link manuell mit deinem Team oder auf Social Media.')}
</p>
</div>
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
{t('events.recap.manual', 'Manuell')}
</Badge>
</div>
{event.public_url ? (
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-3 text-sm text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-slate-200">
<div className="flex-1 truncate" title={event.public_url}>{event.public_url}</div>
<Button size="sm" variant="ghost" onClick={() => navigator.clipboard.writeText(event.public_url!)}>
{t('events.recap.copyLink', 'Link kopieren')}
</Button>
</div>
) : null}
</div>
</div>
</div>
);
}
function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType<typeof useNavigate> }) {
const { t } = useTranslation('management');
@@ -664,6 +833,7 @@ function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tas
}
function TaskRow({ task }: { task: EventToolkitTask }) {
const { t } = useTranslation('management');
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">
@@ -671,7 +841,7 @@ function TaskRow({ task }: { task: EventToolkitTask }) {
{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'}
{task.is_completed ? t('events.tasks.status.completed', 'Done') : t('events.tasks.status.open', 'Open')}
</Badge>
</div>
);

View File

@@ -26,7 +26,7 @@ import {
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { isApiError } from '../lib/apiError';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
@@ -68,7 +68,8 @@ export default function EventFormPage() {
const isEdit = Boolean(slugParam);
const navigate = useNavigate();
const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' });
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
const [form, setForm] = React.useState<EventFormState>({
@@ -195,6 +196,21 @@ export default function EventFormPage() {
slugSuffixRef.current = null;
}, [isEdit, loadedEvent]);
React.useEffect(() => {
if (!isEdit || !loadedEvent || !eventTypes || eventTypes.length === 0) {
return;
}
if (loadedEvent.event_type_id) {
return;
}
setForm((prev) => ({
...prev,
eventTypeId: prev.eventTypeId ?? eventTypes[0]!.id,
}));
}, [eventTypes, isEdit, loadedEvent]);
React.useEffect(() => {
if (!isEdit || !eventLoadError) {
return;
@@ -266,7 +282,7 @@ export default function EventFormPage() {
const trimmedName = form.name.trim();
if (!trimmedName) {
setError('Bitte gib einen Eventnamen ein.');
setError(tForm('errors.nameRequired', 'Bitte gib einen Eventnamen ein.'));
return;
}
@@ -276,7 +292,7 @@ export default function EventFormPage() {
}
if (!form.eventTypeId) {
setError('Bitte wähle einen Event-Typ aus.');
setError(tForm('errors.typeRequired', 'Bitte wähle einen Event-Typ aus.'));
return;
}
@@ -297,9 +313,7 @@ export default function EventFormPage() {
event_type_id: form.eventTypeId,
event_date: form.date || undefined,
status,
...(shouldIncludePackage && packageIdForSubmit
? { package_id: Number(packageIdForSubmit) }
: {}),
...(packageIdForSubmit ? { package_id: Number(packageIdForSubmit) } : {}),
};
try {
@@ -324,25 +338,24 @@ export default function EventFormPage() {
const limit = Number(err.meta?.limit ?? 0);
const used = Number(err.meta?.used ?? 0);
const remaining = Number(err.meta?.remaining ?? Math.max(0, limit - used));
const detail = limit > 0
? tCommon('eventLimitDetails', { used, limit, remaining })
: '';
setError(`${tCommon('eventLimit')}${detail ? `\n${detail}` : ''}`);
const detail = limit > 0 ? tErrors('eventLimitDetails', { used, limit, remaining }) : '';
setError(`${tErrors('eventLimit')}${detail ? `\n${detail}` : ''}`);
setShowUpgradeHint(true);
break;
}
case 'event_credits_exhausted': {
setError(tCommon('creditsExhausted'));
setError(tErrors('creditsExhausted'));
setShowUpgradeHint(true);
break;
}
default: {
setError(err.message || tCommon('generic'));
const metaErrors = Array.isArray(err.meta?.errors) ? err.meta.errors.filter(Boolean).join('\n') : null;
setError(metaErrors || err.message || tErrors('generic'));
setShowUpgradeHint(false);
}
}
} else {
setError(tCommon('generic'));
setError(getApiErrorMessage(err, tErrors('generic')));
setShowUpgradeHint(false);
}
}
@@ -439,19 +452,19 @@ export default function EventFormPage() {
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
<ArrowLeft className="h-4 w-4" /> {tForm('actions.backToList', 'Zurück zur Liste')}
</Button>
);
return (
<AdminLayout
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'}
subtitle="Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen."
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
subtitle={tForm('subtitle', 'Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen.')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Hinweis</AlertTitle>
<AlertTitle>{tForm('errors.notice', 'Hinweis')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
{error.split('\n').map((line, index) => (
<span key={index}>{line}</span>
@@ -459,7 +472,7 @@ export default function EventFormPage() {
{showUpgradeHint && (
<div>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_BILLING_PATH)}>
{tCommon('goToBilling')}
{tErrors('goToBilling', 'Zum Billing')}
</Button>
</div>
)}
@@ -490,10 +503,10 @@ export default function EventFormPage() {
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails
<Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.
{tForm('sections.details.description', 'Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -503,20 +516,20 @@ export default function EventFormPage() {
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="event-name">Eventname</Label>
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
<Input
id="event-name"
placeholder="z. B. Sommerfest 2025"
placeholder={tForm('fields.name.placeholder', 'z. B. Sommerfest 2025')}
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
autoFocus
/>
<p className="text-xs text-slate-500">
Die Kennung und Event-URL werden automatisch aus dem Namen generiert.
{tForm('fields.name.help', 'Die Kennung und Event-URL werden automatisch aus dem Namen generiert.')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="event-date">Datum</Label>
<Label htmlFor="event-date">{tForm('fields.date.label', 'Datum')}</Label>
<Input
id="event-date"
type="date"
@@ -525,7 +538,7 @@ export default function EventFormPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-type">Event-Typ</Label>
<Label htmlFor="event-type">{tForm('fields.type.label', 'Event-Typ')}</Label>
<Select
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
@@ -533,7 +546,11 @@ export default function EventFormPage() {
>
<SelectTrigger id="event-type">
<SelectValue
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswählen'}
placeholder={
eventTypesLoading
? tForm('fields.type.loading', 'Event-Typ wird geladen…')
: tForm('fields.type.placeholder', 'Event-Typ auswählen')
}
/>
</SelectTrigger>
<SelectContent>
@@ -546,7 +563,7 @@ export default function EventFormPage() {
</Select>
{!eventTypesLoading && (!sortedEventTypes || sortedEventTypes.length === 0) ? (
<p className="text-xs text-amber-600">
Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.
{tForm('fields.type.empty', 'Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.')}
</p>
) : null}
</div>
@@ -560,10 +577,10 @@ export default function EventFormPage() {
/>
<div>
<Label htmlFor="event-published" className="text-sm font-medium text-slate-800">
Event sofort veroeffentlichen
{tForm('fields.publish.label', 'Event sofort veröffentlichen')}
</Label>
<p className="text-xs text-slate-600">
Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.
{tForm('fields.publish.help', 'Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.')}
</p>
</div>
</div>
@@ -576,16 +593,16 @@ export default function EventFormPage() {
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Speichert
<Loader2 className="h-4 w-4 animate-spin" /> {tForm('actions.saving', 'Speichert')}
</>
) : (
<>
<Save className="h-4 w-4" /> Speichern
<Save className="h-4 w-4" /> {tForm('actions.save', 'Speichern')}
</>
)}
</Button>
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
Abbrechen
{tForm('actions.cancel', 'Abbrechen')}
</Button>
</div>
<div className="sm:col-span-2 mt-6">

View File

@@ -0,0 +1,931 @@
// @ts-nocheck
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Camera, Clock3, Loader2, MessageSquare, Printer, ShoppingCart, Sparkles } from 'lucide-react';
import toast from 'react-hot-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { AdminLayout } from '../components/AdminLayout';
import {
createEventAddonCheckout,
EventQrInvite,
EventStats,
getEvent,
getEventStats,
getEventQrInvites,
getAddonCatalog,
type EventAddonCatalogItem,
TenantEvent,
toggleEvent,
submitTenantFeedback,
} from '../api';
import { updateEvent } from '../api';
import { buildEventTabs } from '../lib/eventTabs';
import { formatEventDate } from '../lib/events';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import {
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENTS_PATH,
} from '../constants';
export default function EventRecapPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const [searchParams, setSearchParams] = useSearchParams();
const slug = slugParam ?? null;
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [settingsBusy, setSettingsBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [joinTokens, setJoinTokens] = React.useState<EventQrInvite[]>([]);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const [addonBusyKey, setAddonBusyKey] = React.useState<string | null>(null);
const loadEventData = React.useCallback(async () => {
if (!slug) {
setLoading(false);
setError(t('events.errors.missingSlug', 'Kein Event ausgewählt.'));
return;
}
setLoading(true);
setError(null);
try {
const [eventData, statsData, invites, addons] = await Promise.all([
getEvent(slug),
getEventStats(slug),
getEventQrInvites(slug),
getAddonCatalog(),
]);
setEvent(eventData);
setStats(statsData);
setJoinTokens(invites);
setAddonsCatalog(addons);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void loadEventData();
}, [loadEventData]);
React.useEffect(() => {
if (!searchParams.get('addon_success')) {
return;
}
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void loadEventData();
const next = new URLSearchParams(searchParams);
next.delete('addon_success');
setSearchParams(next, { replace: true });
}, [loadEventData, searchParams, setSearchParams, t]);
const handleToggleEvent = React.useCallback(async () => {
if (!slug) return;
setBusy(true);
setError(null);
try {
const updated = await toggleEvent(slug);
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
}
} finally {
setBusy(false);
}
}, [slug, t]);
const handleAddonCheckout = React.useCallback(async (addonKey: string) => {
if (!slug) return;
setAddonBusyKey(addonKey);
setError(null);
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
}
} catch (err) {
if (!isAuthError(err)) {
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
}
} finally {
setAddonBusyKey(null);
}
}, [slug, t]);
const handleArchive = React.useCallback(async () => {
if (!slug || !event) return;
setArchiveBusy(true);
setError(null);
try {
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
setEvent(updated);
setArchiveOpen(false);
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
}
} finally {
setArchiveBusy(false);
}
}, [event, slug, t]);
const handleToggleSetting = React.useCallback(async (key: 'guest_downloads_enabled' | 'guest_sharing_enabled', value: boolean) => {
if (!slug || !event) return;
setSettingsBusy(true);
setError(null);
try {
const updated = await updateEvent(slug, {
settings: {
...(event.settings ?? {}),
[key]: value,
},
});
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
}
} finally {
setSettingsBusy(false);
}
}, [event, slug, t]);
if (!slug) {
return (
<AdminLayout
title={t('events.errors.notFoundTitle', 'Event nicht gefunden')}
subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}
>
<Alert variant="destructive">
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
</Alert>
</AdminLayout>
);
}
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const activeInvite = joinTokens.find((token) => token.is_active);
const guestLinkRaw = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
const guestLink = buildAbsoluteGuestLink(guestLinkRaw);
const guestQrCodeDataUrl = activeInvite?.qr_code_data_url ?? null;
const eventTabs = event
? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), {
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
tasks: stats?.uploads_total ?? event.tasks_count ?? undefined,
invites: event.active_invites_count ?? event.total_invites_count ?? undefined,
})
: [];
return (
<AdminLayout
title={eventName}
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.')}
tabs={eventTabs}
currentTabKey="recap"
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<WorkspaceSkeleton />
) : event && stats ? (
<RecapContent
event={event}
stats={stats}
busy={busy}
onToggleEvent={handleToggleEvent}
guestLink={guestLink}
guestQrCodeDataUrl={guestQrCodeDataUrl}
addonsCatalog={addonsCatalog}
addonBusyKey={addonBusyKey}
onCheckoutAddon={handleAddonCheckout}
onArchive={handleArchive}
onCopyLink={guestLink ? () => {
navigator.clipboard.writeText(guestLink);
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
} : undefined}
onOpenPhotos={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
onEditEvent={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
onBack={() => navigate(ADMIN_EVENTS_PATH)}
settingsBusy={settingsBusy}
onToggleDownloads={(value) => handleToggleSetting('guest_downloads_enabled', value)}
onToggleSharing={(value) => handleToggleSetting('guest_sharing_enabled', value)}
/>
) : (
<Alert variant="destructive">
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
</Alert>
)}
</AdminLayout>
);
}
type RecapContentProps = {
event: TenantEvent;
stats: EventStats;
busy: boolean;
onToggleEvent: () => void;
guestLink: string | null;
guestQrCodeDataUrl: string | null;
addonsCatalog: EventAddonCatalogItem[];
addonBusyKey: string | null;
onCheckoutAddon: (addonKey: string) => void;
onArchive: () => void;
onCopyLink?: () => void;
onOpenPhotos: () => void;
onEditEvent: () => void;
onBack: () => void;
settingsBusy: boolean;
onToggleDownloads: (value: boolean) => void;
onToggleSharing: (value: boolean) => void;
};
function RecapContent({
event,
stats,
busy,
onToggleEvent,
guestLink,
guestQrCodeDataUrl,
addonsCatalog,
addonBusyKey,
onCheckoutAddon,
onArchive,
onCopyLink,
onOpenPhotos,
onEditEvent,
onBack,
settingsBusy,
onToggleDownloads,
onToggleSharing,
}: RecapContentProps) {
const { t } = useTranslation('management');
const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null;
const galleryStatusLabel = event.is_active
? t('events.recap.galleryOpen', 'Galerie geöffnet')
: t('events.recap.galleryClosed', 'Galerie geschlossen');
const counts = {
photos: stats.uploads_total ?? stats.total ?? 0,
pending: stats.pending_photos ?? 0,
likes: stats.likes_total ?? stats.likes ?? 0,
};
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [feedbackMessage, setFeedbackMessage] = React.useState('');
const [feedbackBusy, setFeedbackBusy] = React.useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
const [feedbackError, setFeedbackError] = React.useState<string | undefined>(undefined);
const [archiveOpen, setArchiveOpen] = React.useState(false);
const [archiveBusy, setArchiveBusy] = React.useState(false);
const [archiveConfirmed, setArchiveConfirmed] = React.useState(false);
const [feedbackOpen, setFeedbackOpen] = React.useState(false);
const [feedbackBestArea, setFeedbackBestArea] = React.useState<string | null>(null);
const [feedbackNeedsSupport, setFeedbackNeedsSupport] = React.useState(false);
const galleryAddons = React.useMemo(
() => addonsCatalog.filter((addon) => addon.key.includes('gallery') || addon.key.includes('boost')),
[addonsCatalog],
);
const addonsToShow = galleryAddons.length ? galleryAddons : addonsCatalog;
const defaultAddon = addonsToShow[0] ?? null;
const describeAddon = React.useCallback((addon: EventAddonCatalogItem): string | null => {
const increments = addon.increments ?? {};
const photos = (increments as Record<string, number | undefined>).photos ?? (increments as Record<string, number | undefined>).extra_photos;
const guests = (increments as Record<string, number | undefined>).guests ?? (increments as Record<string, number | undefined>).extra_guests;
const galleryDays = (increments as Record<string, number | undefined>).gallery_days
?? (increments as Record<string, number | undefined>).extra_gallery_days;
const parts: string[] = [];
if (typeof photos === 'number' && photos > 0) {
parts.push(t('events.sections.addons.summary.photos', `+${photos} Fotos`, { count: photos.toLocaleString() }));
}
if (typeof guests === 'number' && guests > 0) {
parts.push(t('events.sections.addons.summary.guests', `+${guests} Gäste`, { count: guests.toLocaleString() }));
}
if (typeof galleryDays === 'number' && galleryDays > 0) {
parts.push(t('events.sections.addons.summary.gallery', `+${galleryDays} Tage`, { count: galleryDays }));
}
return parts.length ? parts.join(' · ') : null;
}, [t]);
const copy = {
positive: t('events.feedback.positive', 'War super'),
neutral: t('events.feedback.neutral', 'In Ordnung'),
negative: t('events.feedback.negative', 'Brauch(t)e Unterstützung'),
};
const bestAreaOptions = [
{ key: 'uploads', label: t('events.feedback.best.uploads', 'Uploads & Geschwindigkeit') },
{ key: 'invites', label: t('events.feedback.best.invites', 'QR-Einladungen & Layouts') },
{ key: 'moderation', label: t('events.feedback.best.moderation', 'Moderation & Export') },
{ key: 'experience', label: t('events.feedback.best.experience', 'Allgemeine App-Erfahrung') },
];
const handleQrDownload = React.useCallback(() => {
if (!guestQrCodeDataUrl) return;
const link = document.createElement('a');
link.href = guestQrCodeDataUrl;
link.download = 'guest-gallery-qr.png';
link.click();
}, [guestQrCodeDataUrl]);
const handleQrShare = React.useCallback(async () => {
if (!guestLink) return;
if (navigator.share) {
try {
await navigator.share({
title: resolveName(event.name),
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
url: guestLink,
});
return;
} catch {
// Ignore share cancellation and fall back to copy.
}
}
try {
await navigator.clipboard.writeText(guestLink);
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
} catch {
toast.error(t('events.recap.copyError', 'Link konnte nicht geteilt werden.'));
}
}, [event.name, guestLink, t]);
const handleFeedbackSubmit = React.useCallback(async () => {
if (feedbackBusy) return;
setFeedbackBusy(true);
setFeedbackError(undefined);
try {
await submitTenantFeedback({
category: 'event_workspace_after_event',
event_slug: event.slug,
sentiment: sentiment ?? undefined,
message: feedbackMessage.trim() ? feedbackMessage.trim() : undefined,
metadata: {
best_area: feedbackBestArea,
needs_support: feedbackNeedsSupport,
event_name: resolveName(event.name),
guest_link: guestLink,
},
});
setFeedbackSubmitted(true);
setFeedbackOpen(false);
setFeedbackMessage('');
setFeedbackNeedsSupport(false);
toast.success(t('events.feedback.submitted', 'Danke!'));
} catch (err) {
setFeedbackError(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 {
setFeedbackBusy(false);
}
}, [event.slug, event.name, feedbackBestArea, feedbackBusy, feedbackMessage, feedbackNeedsSupport, feedbackSubmitted, guestLink, sentiment, t]);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{t('events.recap.badge', 'Nachbereitung')}
</p>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
</p>
<div className="flex flex-wrap gap-2 text-xs">
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
<Sparkles className="h-3.5 w-3.5" />
{event.status === 'published' ? t('events.status.published', 'Veröffentlicht') : t('events.status.draft', 'Entwurf')}
</Badge>
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
<Clock3 className="h-3.5 w-3.5" />
{event.event_date ? formatEventDate(event.event_date, undefined) : t('events.workspace.noDate', 'Kein Datum')}
</Badge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onBack} className="rounded-full 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>
<Button variant="outline" size="sm" onClick={onEditEvent} className="rounded-full 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"
size="sm"
onClick={onToggleEvent}
disabled={busy}
className="rounded-full border-slate-200"
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-500">
{t('events.recap.galleryTitle', 'Galerie-Status')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{galleryStatusLabel}</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
</p>
</div>
<Badge variant={event.is_active ? 'default' : 'outline'} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-100">
{event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant={event.is_active ? 'secondary' : 'default'} disabled={busy} onClick={onToggleEvent}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
</Button>
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
<Camera className="mr-2 h-4 w-4" />
{t('events.recap.moderate', 'Uploads ansehen')}
</Button>
</div>
<div className="mt-4 space-y-3 rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 p-3 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-emerald-600 dark:text-emerald-200">
{t('events.recap.shareLink', 'Gäste-Link')}
</p>
{guestLink ? (
<span className="block truncate text-emerald-900" title={guestLink}>{guestLink}</span>
) : (
<p className="text-xs text-emerald-800/80 dark:text-emerald-100">
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
</p>
)}
</div>
{guestLink && onCopyLink ? (
<Button size="sm" variant="secondary" className="rounded-full bg-emerald-600 text-white hover:bg-emerald-700" onClick={onCopyLink}>
{t('events.recap.copyLink', 'Link kopieren')}
</Button>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowDownloads', 'Downloads erlauben')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowDownloadsHint', 'Gäste dürfen Fotos speichern')}</p>
</div>
<Switch
checked={Boolean((event.settings as any)?.guest_downloads_enabled ?? true)}
onCheckedChange={(checked) => onToggleDownloads(Boolean(checked))}
disabled={settingsBusy}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowSharing', 'Teilen erlauben')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowSharingHint', 'Gäste dürfen Links teilen')}</p>
</div>
<Switch
checked={Boolean((event.settings as any)?.guest_sharing_enabled ?? true)}
onCheckedChange={(checked) => onToggleSharing(Boolean(checked))}
disabled={settingsBusy}
/>
</div>
</div>
{guestQrCodeDataUrl ? (
<div className="mt-2 grid gap-3 rounded-2xl border border-emerald-100/80 bg-white/90 p-3 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-white/10 dark:text-emerald-50 sm:grid-cols-[auto,1fr]">
<div className="flex items-center justify-center rounded-xl border border-emerald-100/70 bg-white/70 p-2 dark:border-emerald-900/50 dark:bg-emerald-900/30">
<img src={guestQrCodeDataUrl} alt={t('events.recap.qrAlt', 'QR-Code zur Gäste-Galerie')} className="h-28 w-28 rounded-lg" />
</div>
<div className="flex min-w-0 flex-col gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.qrTitle', 'QR-Code teilen')}</p>
{guestLink ? (
<p className="truncate text-sm text-emerald-800 dark:text-emerald-100" title={guestLink}>{guestLink}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={handleQrDownload}>
{t('events.recap.qrDownload', 'QR-Code herunterladen')}
</Button>
<Button size="sm" variant="outline" onClick={handleQrShare}>
{t('events.recap.qrShare', 'Link/QR teilen')}
</Button>
</div>
</div>
</div>
) : null}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500">
{t('events.recap.exportTitle', 'Export & Backup')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{t('events.recap.exportCopy', 'Alle Assets sichern')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
</p>
</div>
<Badge variant="outline" className="border-indigo-200 text-indigo-700 dark:border-indigo-800 dark:text-indigo-200">
{t('events.recap.backup', 'Backup')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" onClick={onOpenPhotos}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.downloadAll', 'Alles herunterladen')}
</Button>
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.downloadHighlights', 'Highlights herunterladen')}
</Button>
</div>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{t('events.recap.highlightsHint', '“Highlights” = als Highlight markierte Fotos in der Galerie.')}
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">
{t('events.recap.retentionTitle', 'Verlängerung / Archivierung')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{galleryExpiresAt
? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
: t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
</p>
</div>
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-100">
{t('events.recap.expiry', 'Ablauf')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="default"
disabled={!defaultAddon || addonBusyKey === defaultAddon?.key}
onClick={() => defaultAddon && onCheckoutAddon(defaultAddon.key)}
>
{addonBusyKey === defaultAddon?.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Clock3 className="mr-2 h-4 w-4" />}
{defaultAddon?.label ?? t('events.actions.extendGallery', 'Galerie verlängern')}
</Button>
<Button size="sm" variant="outline" onClick={() => setArchiveOpen(true)}>
{t('events.recap.archive', 'Archivieren/Löschen')}
</Button>
</div>
{addonsToShow.length ? (
<div className="mt-4 space-y-3 rounded-2xl border border-amber-100/80 bg-white/80 p-3 text-sm text-slate-700 shadow-sm dark:border-amber-900/40 dark:bg-white/10 dark:text-slate-200">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-600 dark:text-amber-200">
{t('events.recap.extendOptions', 'Alle Add-ons für dieses Event')}
</p>
<div className="space-y-2">
{addonsToShow.map((addon) => (
<div
key={addon.key}
className="flex flex-col gap-2 rounded-xl border border-amber-100/60 bg-white/80 p-3 shadow-sm dark:border-amber-900/40 dark:bg-amber-900/20 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<p className="truncate font-semibold text-slate-900 dark:text-white" title={addon.label}>
{addon.label}
</p>
<p className="text-xs text-slate-500 dark:text-slate-300">
{describeAddon(addon) ?? t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={!addon.price_id || addonBusyKey === addon.key}
onClick={() => onCheckoutAddon(addon.key)}
>
{addonBusyKey === addon.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ShoppingCart className="mr-2 h-4 w-4" />}
{addon.price_id ? t('addons.buyNow', 'Jetzt freischalten') : t('events.recap.priceMissing', 'Preis nicht verknüpft')}
</Button>
</div>
))}
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
{t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
</p>
</div>
) : (
<p className="mt-4 text-xs text-slate-500 dark:text-slate-300">
{t('events.recap.noAddons', 'Aktuell keine Add-ons verfügbar.')}
</p>
)}
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
{t('events.feedback.badge', 'Feedback')}
</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{t('events.feedback.afterEventTitle', 'Event beendet kurzes Feedback?')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.feedback.afterEventCopy', 'Hat alles geklappt? Deine Antwort hilft uns für kommende Events.')}<br />
<span className="text-[11px] text-slate-500">{t('events.feedback.privacyHint', 'Nur Admin-Feedback, keine Gastdaten')}</span>
</p>
</div>
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
{t('events.feedback.badgeShort', 'Feedback')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-600 dark:text-slate-300">
<Badge variant="secondary" className="rounded-full bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-50">
{resolveName(event.name)}
</Badge>
{event.event_date ? (
<Badge variant="outline" className="rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
{formatEventDate(event.event_date, undefined)}
</Badge>
) : null}
</div>
{feedbackSubmitted ? (
<div className="mt-4 rounded-2xl border border-emerald-200/60 bg-emerald-50/80 p-4 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
<p className="font-semibold">{t('events.feedback.submitted', 'Danke!')}</p>
<p className="text-sm">{t('events.feedback.afterEventThanks', 'Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.')}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => { setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
{t('events.feedback.sendAnother', 'Weiteres Feedback senden')}
</Button>
<Button size="sm" variant="secondary" className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => { setFeedbackNeedsSupport(true); setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
{t('events.feedback.supportFollowup', 'Support anfragen')}
</Button>
</div>
</div>
) : (
<div className="mt-4 flex flex-wrap items-center gap-3">
<Button size="sm" onClick={() => setFeedbackOpen(true)} disabled={feedbackBusy}>
<MessageSquare className="mr-2 h-4 w-4" />
{t('events.feedback.cta', 'Feedback geben')}
</Button>
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
<span>{t('events.feedback.quickSentiment', 'Stimmung auswählbar (positiv/neutral/Support).')}</span>
</div>
</div>
)}
{feedbackError ? (
<Alert variant="destructive" className="mt-4">
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
<AlertDescription>{feedbackError}</AlertDescription>
</Alert>
) : null}
</div>
</div>
<Dialog open={feedbackOpen} onOpenChange={(open) => { setFeedbackOpen(open); setFeedbackError(undefined); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.feedback.dialogTitle', 'Kurzes After-Event Feedback')}</DialogTitle>
<DialogDescription>
{t('events.feedback.dialogCopy', 'Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.sentiment', 'Stimmung')}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
<Button
key={key}
type="button"
size="sm"
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>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.bestQuestion', 'Was lief am besten?')}</p>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{bestAreaOptions.map((option) => (
<Button
key={option.key}
type="button"
variant={feedbackBestArea === option.key ? 'secondary' : 'outline'}
className={feedbackBestArea === option.key ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 text-slate-700'}
onClick={() => setFeedbackBestArea(option.key)}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.improve', 'Was sollen wir verbessern?')}</p>
<textarea
value={feedbackMessage}
onChange={(event) => setFeedbackMessage(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-lg border border-slate-200 bg-white/90 p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300 dark:border-white/10 dark:bg-white/5 dark:text-slate-100"
/>
</div>
<div className="flex items-start gap-2 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-xs text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<Checkbox
id="needs-support"
checked={feedbackNeedsSupport}
onCheckedChange={(checked) => setFeedbackNeedsSupport(Boolean(checked))}
className="mt-0.5"
/>
<Label htmlFor="needs-support" className="cursor-pointer text-sm leading-5">
{t('events.feedback.supportHelp', 'Ich hätte gern ein kurzes Follow-up (Support).')}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFeedbackOpen(false)} disabled={feedbackBusy}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button onClick={() => { void handleFeedbackSubmit(); }} disabled={feedbackBusy}>
{feedbackBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />}
{t('events.feedback.submit', 'Feedback senden')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={archiveOpen} onOpenChange={(open) => {
setArchiveOpen(open);
if (!open) {
setArchiveConfirmed(false);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.recap.archiveTitle', 'Event archivieren')}</DialogTitle>
<DialogDescription>
{t('events.recap.archiveDesc', 'Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte solltest du vorher abschließen.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 rounded-lg border border-amber-200/60 bg-amber-50/70 p-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-900/30 dark:text-amber-50">
<p className="font-semibold">{t('events.recap.archiveImpact', 'Was passiert?')}</p>
<ul className="list-disc space-y-1 pl-4 text-amber-900 dark:text-amber-50">
<li>{t('events.recap.archiveImpactClose', 'Gäste-Zugriff wird beendet, Uploads/Downloads werden deaktiviert.')}</li>
<li>{t('events.recap.archiveImpactLinks', 'Öffentliche Links und QR-Codes werden ungültig, bestehende Sessions laufen aus.')}</li>
<li>{t('events.recap.archiveImpactData', 'Daten bleiben intern für Compliance & Support sichtbar, können aber auf Anfrage gelöscht werden (DSGVO).')}</li>
</ul>
</div>
<div className="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-sm text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<Checkbox
id="archive-confirm"
checked={archiveConfirmed}
onCheckedChange={(checked) => setArchiveConfirmed(Boolean(checked))}
className="mt-0.5"
/>
<Label htmlFor="archive-confirm" className="cursor-pointer text-sm leading-5">
{t('events.recap.archiveConfirm', 'Ich habe Exporte abgeschlossen und möchte die Galerie jetzt archivieren.')}
</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiveBusy}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button variant="destructive" onClick={onArchive} disabled={!archiveConfirmed || archiveBusy}>
{archiveBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('events.recap.archiveConfirmCta', 'Archivierung starten')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
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 buildAbsoluteGuestLink(link: string | null): string | null {
if (!link) return null;
try {
const base = typeof window !== 'undefined' ? window.location.origin : undefined;
return base ? new URL(link, base).toString() : new URL(link).toString();
} catch {
return link;
}
}
function WorkspaceSkeleton() {
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={`recap-metric-skeleton-${index}`} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
</div>
);
}
function SkeletonCard() {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}

View File

@@ -36,7 +36,8 @@ import { filterEmotionsByEventType } from '../lib/emotions';
import { buildEventTabs } from '../lib/eventTabs';
export default function EventTasksPage() {
const { t } = useTranslation(['management', 'dashboard']);
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
const { t: tDashboard } = useTranslation('dashboard');
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null;
@@ -67,7 +68,7 @@ export default function EventTasksPage() {
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
} catch (err) {
if (!isAuthError(err)) {
setError(t('management.tasks.errors.assign', 'Tasks konnten nicht geladen werden.'));
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
}
}
}, [t]);
@@ -88,7 +89,7 @@ export default function EventTasksPage() {
React.useEffect(() => {
if (!slug) {
setError(t('management.tasks.errors.missingSlug', 'Kein Event-Slug angegeben.'));
setError(t('errors.missingSlug', 'Kein Event-Slug angegeben.'));
setLoading(false);
return;
}
@@ -120,7 +121,7 @@ export default function EventTasksPage() {
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(t('management.tasks.errors.load', 'Event-Tasks konnten nicht geladen werden.'));
setError(t('errors.load', 'Event-Tasks konnten nicht geladen werden.'));
}
} finally {
if (!cancelled) {
@@ -146,7 +147,7 @@ export default function EventTasksPage() {
setSelected([]);
} catch (err) {
if (!isAuthError(err)) {
setError(t('management.tasks.errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
setError(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
}
} finally {
setSaving(false);
@@ -178,14 +179,13 @@ export default function EventTasksPage() {
}, [event, assignedTasks.length, t]);
React.useEffect(() => {
if (!event?.event_type?.slug) {
return;
}
let cancelled = false;
setCollectionsLoading(true);
setCollectionsError(null);
getTaskCollections({ per_page: 6, event_type: event.event_type.slug })
const eventTypeSlug = event?.event_type?.slug ?? null;
const query = eventTypeSlug ? { per_page: 6, event_type: eventTypeSlug } : { per_page: 6 };
getTaskCollections(query)
.then((result) => {
if (cancelled) return;
setCollections(result.data);
@@ -193,7 +193,7 @@ export default function EventTasksPage() {
.catch((err) => {
if (cancelled) return;
if (!isAuthError(err)) {
setCollectionsError(t('management.tasks.collections.error', 'Kollektionen konnten nicht geladen werden.'));
setCollectionsError(t('collections.error', 'Kollektionen konnten nicht geladen werden.'));
}
})
.finally(() => {
@@ -244,7 +244,7 @@ export default function EventTasksPage() {
try {
await importTaskCollection(collection.id, slug);
toast.success(
t('management.tasks.collections.imported', {
t('collections.imported', {
defaultValue: 'Mission Pack "{{name}}" importiert.',
name: collection.name,
}),
@@ -252,16 +252,16 @@ export default function EventTasksPage() {
await hydrateTasks(event);
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('management.tasks.collections.importFailed', 'Mission Pack konnte nicht importiert werden.'));
toast.error(t('collections.importFailed', 'Mission Pack konnte nicht importiert werden.'));
}
} finally {
setImportingCollectionId(null);
}
}, [event, hydrateTasks, slug, t]);
const isPhotoOnlyMode = React.useMemo(() => {
const tasksEnabled = React.useMemo(() => {
const mode = event?.engagement_mode ?? (event?.settings as any)?.engagement_mode;
return mode === 'photo_only';
return mode !== 'photo_only';
}, [event?.engagement_mode, event?.settings]);
async function handleModeChange(checked: boolean) {
@@ -271,7 +271,7 @@ export default function EventTasksPage() {
setError(null);
try {
const nextMode = checked ? 'photo_only' : 'tasks';
const nextMode = checked ? 'tasks' : 'photo_only';
const updated = await updateEvent(slug, {
settings: {
...(event.settings ?? {}),
@@ -292,8 +292,8 @@ export default function EventTasksPage() {
if (!isAuthError(err)) {
setError(
checked
? t('management.tasks.errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
: t('management.tasks.errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
? t('errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
: t('errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
);
}
} finally {
@@ -304,21 +304,21 @@ export default function EventTasksPage() {
const actions = (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
<ArrowLeft className="h-4 w-4" />
{t('management.tasks.actions.back', 'Zurück zur Übersicht')}
{t('actions.back', 'Zurück zur Übersicht')}
</Button>
);
return (
<AdminLayout
title={t('management.tasks.title', 'Aufgaben & Missionen')}
subtitle={t('management.tasks.subtitle', 'Stelle Mission Cards und Aufgaben für dieses Event zusammen.')}
title={t('title', 'Aufgaben & Missionen')}
subtitle={t('subtitle', 'Stelle Mission Cards und Aufgaben für dieses Event zusammen.')}
actions={actions}
tabs={eventTabs}
currentTabKey="tasks"
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -327,22 +327,22 @@ export default function EventTasksPage() {
<TaskSkeleton />
) : !event ? (
<Alert variant="destructive">
<AlertTitle>{t('management.tasks.alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('management.tasks.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
<AlertTitle>{t('alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
</Alert>
) : (
<>
<Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs')} className="space-y-6">
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-2">
<TabsTrigger value="tasks">{t('management.tasks.tabs.tasks', 'Aufgaben')}</TabsTrigger>
<TabsTrigger value="packs">{t('management.tasks.tabs.packs', 'Mission Packs')}</TabsTrigger>
<TabsTrigger value="tasks">{t('tabs.tasks', 'Aufgaben')}</TabsTrigger>
<TabsTrigger value="packs">{t('tabs.packs', 'Mission Packs')}</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="space-y-6">
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('management.tasks.eventStatus', {
{t('eventStatus', {
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
})}
</CardDescription>
@@ -350,52 +350,44 @@ export default function EventTasksPage() {
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
{t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
{t('modes.title', 'Aufgaben & Foto-Modus')}
</p>
<p className="text-xs text-slate-600">
{isPhotoOnlyMode
? t(
'management.tasks.modes.photoOnlyHint',
'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.',
)
: t(
'management.tasks.modes.tasksHint',
'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.',
)}
{tasksEnabled
? t('modes.tasksHint', 'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.')
: t('modes.photoOnlyHint', 'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.')}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs uppercase tracking-wide text-slate-500">
{isPhotoOnlyMode
? t('management.tasks.modes.photoOnly', 'Foto-Modus')
: t('management.tasks.modes.tasks', 'Aufgaben aktiv')}
{tasksEnabled ? t('modes.tasks', 'Aufgaben aktiv') : t('modes.photoOnly', 'Foto-Modus')}
</span>
<Switch
checked={isPhotoOnlyMode}
checked={tasksEnabled}
onCheckedChange={handleModeChange}
disabled={modeSaving}
aria-label={t('management.tasks.modes.switchLabel', 'Foto-Modus aktivieren')}
aria-label={t('modes.switchLabel', 'Aufgaben aktivieren/deaktivieren')}
/>
</div>
</div>
{modeSaving ? (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
{t('modes.updating', 'Einstellung wird gespeichert ...')}
</div>
) : null}
<div className="grid gap-3 text-xs sm:grid-cols-3">
<SummaryPill
label={t('management.tasks.summary.assigned', 'Zugeordnete Tasks')}
label={t('summary.assigned', 'Zugeordnete Tasks')}
value={assignedTasks.length}
/>
<SummaryPill
label={t('management.tasks.summary.library', 'Bibliothek')}
label={t('summary.library', 'Bibliothek')}
value={availableTasks.length}
/>
<SummaryPill
label={t('management.tasks.summary.mode', 'Aktiver Modus')}
value={isPhotoOnlyMode ? t('management.tasks.summary.photoOnly', 'Nur Fotos') : t('management.tasks.summary.tasksMode', 'Mission Cards')}
label={t('summary.mode', 'Aktiver Modus')}
value={tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
/>
</div>
</div>
@@ -403,14 +395,11 @@ export default function EventTasksPage() {
<CardContent className="pb-0">
<Alert variant="default" className="rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 text-xs text-slate-700">
<AlertTitle className="text-sm font-semibold text-slate-900">
{t('management.tasks.library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')}
{t('library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')}
</AlertTitle>
<AlertDescription className="mt-1 flex flex-wrap items-center gap-2">
<span>
{t(
'management.tasks.library.hintCopy',
'Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.',
)}
{t('library.hintCopy', 'Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.')}
</span>
<Button
type="button"
@@ -419,7 +408,7 @@ export default function EventTasksPage() {
className="mt-1 rounded-full border-emerald-300 text-emerald-700 hover:bg-emerald-100"
onClick={() => navigate(buildEngagementTabPath('tasks'))}
>
{t('management.tasks.library.open', 'Aufgaben-Bibliothek öffnen')}
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
</Button>
</AlertDescription>
</Alert>
@@ -429,14 +418,14 @@ export default function EventTasksPage() {
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Sparkles className="h-4 w-4 text-pink-500" />
{t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}
{t('sections.assigned.title', 'Zugeordnete Tasks')}
</h3>
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
<Search className="h-4 w-4 text-slate-500" />
<Input
value={taskSearch}
onChange={(event) => setTaskSearch(event.target.value)}
placeholder={t('management.tasks.sections.assigned.search', 'Aufgaben suchen...')}
placeholder={t('sections.assigned.search', 'Aufgaben suchen...')}
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
/>
</div>
@@ -446,8 +435,8 @@ export default function EventTasksPage() {
<EmptyState
message={
taskSearch.trim()
? t('management.tasks.sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
: t('management.tasks.sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
}
/>
) : (
@@ -462,11 +451,11 @@ export default function EventTasksPage() {
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<PlusCircle className="h-4 w-4 text-emerald-500" />
{t('management.tasks.sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
</h3>
<div className="space-y-2 rounded-2xl border border-emerald-100 bg-white/90 p-3 shadow-sm max-h-72 overflow-y-auto">
{availableTasks.length === 0 ? (
<EmptyState message={t('management.tasks.sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
) : (
availableTasks.map((task) => (
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200">
@@ -477,7 +466,7 @@ export default function EventTasksPage() {
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
)
}
disabled={isPhotoOnlyMode}
disabled={!tasksEnabled}
/>
<div>
<p className="text-sm font-medium text-slate-900">{task.title}</p>
@@ -489,9 +478,9 @@ export default function EventTasksPage() {
</div>
<Button
onClick={() => void handleAssign()}
disabled={saving || selected.length === 0 || isPhotoOnlyMode}
disabled={saving || selected.length === 0 || !tasksEnabled}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.assign', 'Ausgewählte Tasks zuweisen')}
</Button>
</section>
</CardContent>
@@ -577,7 +566,7 @@ function MissionPackGrid({
importingId: number | null;
onViewAll: () => void;
}) {
const { t } = useTranslation('management');
const { t } = useTranslation('management', { keyPrefix: 'eventTasks.collections' });
return (
<Card className="border border-slate-200 bg-white/90">
@@ -585,20 +574,20 @@ function MissionPackGrid({
<div>
<CardTitle className="flex items-center gap-2 text-base text-slate-900">
<Layers className="h-5 w-5 text-pink-500" />
{t('management.tasks.collections.title', 'Mission Packs')}
{t('title', 'Mission Packs')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('management.tasks.collections.subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')}
{t('subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')}
</CardDescription>
</div>
<Button variant="outline" onClick={onViewAll}>
{t('management.tasks.collections.viewAll', 'Alle Kollektionen ansehen')}
{t('viewAll', 'Alle Kollektionen ansehen')}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{error ? (
<Alert variant="destructive">
<AlertTitle>{t('management.tasks.collections.errorTitle', 'Kollektionen nicht verfügbar')}</AlertTitle>
<AlertTitle>{t('errorTitle', 'Kollektionen nicht verfügbar')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
@@ -610,7 +599,7 @@ function MissionPackGrid({
))}
</div>
) : collections.length === 0 ? (
<EmptyState message={t('management.tasks.collections.empty', 'Keine empfohlenen Kollektionen gefunden.')} />
<EmptyState message={t('empty', 'Keine empfohlenen Kollektionen gefunden.')} />
) : (
<div className="grid gap-4 md:grid-cols-2">
{collections.map((collection) => (
@@ -621,15 +610,15 @@ function MissionPackGrid({
<p className="text-xs text-slate-500">{collection.description}</p>
) : null}
<Badge variant="outline" className="w-fit border-slate-200 text-slate-600">
{t('management.tasks.collections.tasksCount', {
{t('tasksCount', {
defaultValue: '{{count}} Aufgaben',
count: collection.tasks_count,
})}
</Badge>
</div>
<div className="mt-4 flex justify-between text-xs text-slate-500">
<span>{collection.event_type?.name ?? t('management.tasks.collections.genericType', 'Allgemein')}</span>
<span>{collection.is_global ? t('management.tasks.collections.global', 'Global') : t('management.tasks.collections.custom', 'Custom')}</span>
<span>{collection.event_type?.name ?? t('genericType', 'Allgemein')}</span>
<span>{collection.is_global ? t('global', 'Global') : t('custom', 'Custom')}</span>
</div>
<Button
className="mt-4 rounded-full bg-brand-rose text-white"
@@ -639,7 +628,7 @@ function MissionPackGrid({
{importingId === collection.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t('management.tasks.collections.importCta', 'Mission Pack importieren')
t('importCta', 'Mission Pack importieren')
)}
</Button>
</div>
@@ -793,13 +782,13 @@ function SummaryPill({ label, value }: { label: string; value: string | number }
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
switch (priority) {
case 'low':
return translate('management.tasks.priorities.low', 'Niedrig');
return translate('management.eventTasks.priorities.low', 'Niedrig');
case 'high':
return translate('management.tasks.priorities.high', 'Hoch');
return translate('management.eventTasks.priorities.high', 'Hoch');
case 'urgent':
return translate('management.tasks.priorities.urgent', 'Dringend');
return translate('management.eventTasks.priorities.urgent', 'Dringend');
default:
return translate('management.tasks.priorities.medium', 'Mittel');
return translate('management.eventTasks.priorities.medium', 'Mittel');
}
}

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ADMIN_LOGIN_PATH } from '../constants';
export default function LoginStartPage(): React.ReactElement {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation('auth');
useEffect(() => {
const params = new URLSearchParams(location.search);
@@ -20,7 +22,7 @@ export default function LoginStartPage(): React.ReactElement {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70">
<p className="text-sm font-medium">Weiterleitung zum Login </p>
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p>
</div>
);
}

View File

@@ -49,7 +49,8 @@ export type TasksSectionProps = {
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps) {
const navigate = useNavigate();
const { t } = useTranslation('common');
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
const { t: tc } = useTranslation('common');
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
@@ -75,7 +76,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
})
.catch((err) => {
if (!isAuthError(err)) {
setError('Tasks konnten nicht geladen werden.');
setError(t('errors.load'));
}
})
.finally(() => {
@@ -87,7 +88,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
return () => {
cancelled = true;
};
}, [page, search]);
}, [page, search, t]);
const openCreate = React.useCallback(() => {
setEditingTask(null);
@@ -179,16 +180,14 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
}
}
const title = embedded ? 'Aufgaben' : 'Task Bibliothek';
const subtitle = embedded
? 'Plane Aufgaben, Aktionen und Highlights für deine Gäste.'
: 'Weise Aufgaben zu und tracke Fortschritt rund um deine Events.';
const title = embedded ? t('titles.embedded') : t('titles.default');
const subtitle = embedded ? t('subtitles.embedded') : t('subtitles.default');
return (
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler</AlertTitle>
<AlertTitle>{t('errors.title')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -201,21 +200,21 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleNavigateToCollections}>
{t('navigation.collections')}
{tc('navigation.collections')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreate}
>
<Plus className="h-4 w-4" />
Neu
{t('actions.new')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder="Nach Aufgaben suchen ..."
placeholder={t('actions.searchPlaceholder')}
value={search}
onChange={(event) => {
setPage(1);
@@ -225,7 +224,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
/>
{meta && meta.total > 0 ? (
<div className="text-xs text-slate-500">
Seite {meta.current_page} von {meta.last_page} · {meta.total} Einträge
{t('pagination.page', {
current: meta.current_page,
total: meta.last_page,
count: meta.total,
})}
</div>
) : null}
</div>
@@ -251,11 +254,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
{meta && meta.last_page > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
<div className="text-slate-500">
Insgesamt {meta.total} Aufgaben · Seite {meta.current_page} von {meta.last_page}
{t('pagination.summary', { count: meta.total, current: meta.current_page, total: meta.last_page })}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
Zurück
{t('pagination.prev')}
</Button>
<Button
variant="outline"
@@ -263,7 +266,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
onClick={() => setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
disabled={meta.current_page >= (meta.last_page ?? 1)}
>
Weiter
{t('pagination.next')}
</Button>
</div>
</div>
@@ -274,11 +277,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTask ? 'Task bearbeiten' : 'Neue Task erstellen'}</DialogTitle>
<DialogTitle>{editingTask ? t('form.editTitle') : t('form.createTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="task-title">Titel</Label>
<Label htmlFor="task-title">{t('form.title')}</Label>
<Input
id="task-title"
value={form.title}
@@ -287,17 +290,17 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
/>
</div>
<div className="space-y-2">
<Label htmlFor="task-description">Beschreibung</Label>
<Label htmlFor="task-description">{t('form.description')}</Label>
<Input
id="task-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
placeholder="Was sollen Gäste machen?"
placeholder={t('form.descriptionPlaceholder')}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="task-priority">Priorität</Label>
<Label htmlFor="task-priority">{t('form.priority')}</Label>
<Select
value={form.priority ?? 'medium'}
onValueChange={(value) =>
@@ -305,18 +308,18 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
}
>
<SelectTrigger id="task-priority">
<SelectValue placeholder="Priorität wählen" />
<SelectValue placeholder={t('form.priorityPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="medium">Mittel</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
<SelectItem value="urgent">Dringend</SelectItem>
<SelectItem value="low">{t('priorities.low')}</SelectItem>
<SelectItem value="medium">{t('priorities.medium')}</SelectItem>
<SelectItem value="high">{t('priorities.high')}</SelectItem>
<SelectItem value="urgent">{t('priorities.urgent')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="task-due-date">Fälligkeitsdatum</Label>
<Label htmlFor="task-due-date">{t('form.dueDate')}</Label>
<Input
id="task-due-date"
type="date"
@@ -328,19 +331,19 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div>
<p className="text-sm font-medium text-slate-700">Bereits erledigt?</p>
<p className="text-xs text-slate-500">Markiere Aufgaben als abgeschlossen, wenn sie nicht mehr sichtbar sein sollen.</p>
<p className="text-sm font-medium text-slate-700">{t('form.completedTitle')}</p>
<p className="text-xs text-slate-500">{t('form.completedCopy')}</p>
</div>
<Switch checked={form.is_completed} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: checked }))} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
Abbrechen
{t('form.cancel')}
</Button>
<Button type="submit" disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Speichern
{t('form.save')}
</Button>
</div>
</form>
@@ -353,10 +356,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
export default function TasksPage() {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
return (
<AdminLayout
title={tc('navigation.tasks')}
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
subtitle={t('subtitles.default')}
>
<TasksSection onNavigateToCollections={() => navigate(buildEngagementTabPath('collections'))} />
</AdminLayout>
@@ -376,6 +380,7 @@ function TaskRow({
}) {
const isCompleted = task.is_completed;
const statusIcon = isCompleted ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Circle className="h-4 w-4 text-slate-300" />;
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
return (
<div className="flex flex-col gap-3 rounded-xl border border-slate-200/70 bg-white/80 p-4 shadow-sm shadow-pink-100/20 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
@@ -386,7 +391,7 @@ function TaskRow({
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-slate-900">{task.title}</span>
{task.priority ? <PriorityBadge priority={task.priority} /> : null}
{task.collection_id ? <Badge variant="secondary">Vorlage #{task.collection_id}</Badge> : null}
{task.collection_id ? <Badge variant="secondary">{t('list.template', { id: task.collection_id })}</Badge> : null}
</div>
{task.description ? <p className="text-xs text-slate-500">{task.description}</p> : null}
</div>
@@ -394,11 +399,11 @@ function TaskRow({
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Pencil className="mr-1 h-4 w-4" />
Bearbeiten
{t('list.edit')}
</Button>
<Button variant="ghost" size="sm" onClick={onDelete} className="text-slate-500 hover:text-rose-500">
<Trash2 className="mr-1 h-4 w-4" />
Löschen
{t('list.delete')}
</Button>
</div>
</div>
@@ -406,11 +411,12 @@ function TaskRow({
}
function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priority']> }) {
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
const mapping: Record<NonNullable<TaskPayload['priority']>, { label: string; className: string }> = {
low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
high: { label: 'Hoch', className: 'bg-rose-50 text-rose-600' },
urgent: { label: 'Dringend', className: 'bg-red-50 text-red-600' },
low: { label: t('priorities.low'), className: 'bg-emerald-50 text-emerald-600' },
medium: { label: t('priorities.medium'), className: 'bg-amber-50 text-amber-600' },
high: { label: t('priorities.high'), className: 'bg-rose-50 text-rose-600' },
urgent: { label: t('priorities.urgent'), className: 'bg-red-50 text-red-600' },
};
const { label, className } = mapping[priority];
return <Badge className={`border-none ${className}`}>{label}</Badge>;
@@ -427,15 +433,14 @@ function TasksSkeleton() {
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary.empty' });
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">Noch keine Tasks angelegt</h3>
<p className="text-sm text-slate-500">
Starte mit einer neuen Aufgabe oder importiere Aufgabenvorlagen, um deine Gäste zu inspirieren.
</p>
<h3 className="text-base font-semibold text-slate-800">{t('title')}</h3>
<p className="text-sm text-slate-500">{t('description')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
<Plus className="mr-1 h-4 w-4" />
Erste Task erstellen
{t('cta')}
</Button>
</div>
);

View File

@@ -39,6 +39,10 @@ vi.mock('../../onboarding', () => ({
}),
}));
vi.mock('../context/EventContext', () => ({
useEventContext: () => ({ events: [], activeEvent: null, selectEvent: vi.fn(), isLoading: false, isError: false, refetch: vi.fn() }),
}), { virtual: true });
vi.mock('../../api', () => ({
getDashboardSummary: vi.fn().mockResolvedValue(null),
getEvents: vi.fn().mockResolvedValue([]),
@@ -55,7 +59,7 @@ describe('DashboardPage onboarding guard', () => {
render(<DashboardPage />);
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_BASE_PATH, { replace: true });
expect(navigateMock).not.toHaveBeenCalled();
});
expect(markStepMock).not.toHaveBeenCalled();
});