-
Bottom Navigation
+
+ {t('branding.preview.bottomNav', 'Bottom navigation')}
+
diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx
index 87e81bd..179b632 100644
--- a/resources/js/admin/pages/EventDetailPage.tsx
+++ b/resources/js/admin/pages/EventDetailPage.tsx
@@ -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
>(new Set());
{error && (
@@ -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 (
+
+
+
+
+
+
+ {t('events.recap.galleryTitle', 'Galerie-Status')}
+
+
{galleryStatusLabel}
+
+ {t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
+
+
+
+ {event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
+
+
+
+
+ {busy ? : }
+ {event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
+
+ navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
+
+ {t('events.recap.moderate', 'Uploads ansehen')}
+
+
+ {event.public_url ? (
+
+ {event.public_url}
+ navigator.clipboard.writeText(event.public_url!)}>
+
+
+
+ ) : null}
+
+
+
+
+
+
+ {t('events.recap.exportTitle', 'Export & Backup')}
+
+
+ {t('events.recap.exportCopy', 'Alle Assets sichern')}
+
+
+ {t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
+
+
+
+ {t('events.recap.backup', 'Backup')}
+
+
+
+
navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
+
+ {t('events.recap.exportAll', 'Alles exportieren')}
+
+
navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
+
+ {t('events.recap.exportHighlights', 'Highlights exportieren')}
+
+
+
+
+
+
+
+
+
+
+ {t('events.recap.retentionTitle', 'Aufbewahrung & Verlängerung')}
+
+
+ {galleryExpiresAt
+ ? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
+ : t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
+
+
+ {t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
+
+
+
+ {t('events.recap.expiry', 'Ablauf')}
+
+
+
+
+
+ {t('events.recap.extend30', '+30 Tage verlängern')}
+
+ navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}>
+ {t('events.recap.archive', 'Archivieren/Löschen')}
+
+
+
+
+
+
+
+
+ {t('events.recap.commsTitle', 'Kommunikation')}
+
+
+ {t('events.recap.commsCopy', 'Kein Gast-Newsletter aktiv')}
+
+
+ {t('events.recap.commsHint', 'Push wirkt vor allem live. Teile den Link manuell mit deinem Team oder auf Social Media.')}
+
+
+
+ {t('events.recap.manual', 'Manuell')}
+
+
+ {event.public_url ? (
+
+
{event.public_url}
+
navigator.clipboard.writeText(event.public_url!)}>
+ {t('events.recap.copyLink', 'Link kopieren')}
+
+
+ ) : null}
+
+
+
+ );
+}
+
function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType }) {
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 (
@@ -671,7 +841,7 @@ function TaskRow({ task }: { task: EventToolkitTask }) {
{task.description ?
{task.description}
: null}
- {task.is_completed ? 'Erledigt' : 'Offen'}
+ {task.is_completed ? t('events.tasks.status.completed', 'Done') : t('events.tasks.status.open', 'Open')}
);
diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx
index 77e8b03..80ca52c 100644
--- a/resources/js/admin/pages/EventFormPage.tsx
+++ b/resources/js/admin/pages/EventFormPage.tsx
@@ -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({
@@ -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"
>
- Zurück zur Liste
+ {tForm('actions.backToList', 'Zurück zur Liste')}
);
return (
{error && (
- Hinweis
+ {tForm('errors.notice', 'Hinweis')}
{error.split('\n').map((line, index) => (
{line}
@@ -459,7 +472,7 @@ export default function EventFormPage() {
{showUpgradeHint && (
navigate(ADMIN_BILLING_PATH)}>
- {tCommon('goToBilling')}
+ {tErrors('goToBilling', 'Zum Billing')}
)}
@@ -490,10 +503,10 @@ export default function EventFormPage() {
- Eventdetails
+ {tForm('sections.details.title', 'Eventdetails')}
- 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.')}
@@ -503,20 +516,20 @@ export default function EventFormPage() {
diff --git a/resources/js/admin/pages/EventRecapPage.tsx b/resources/js/admin/pages/EventRecapPage.tsx
new file mode 100644
index 0000000..23513e0
--- /dev/null
+++ b/resources/js/admin/pages/EventRecapPage.tsx
@@ -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
(null);
+ const [stats, setStats] = React.useState(null);
+ const [loading, setLoading] = React.useState(true);
+ const [busy, setBusy] = React.useState(false);
+ const [settingsBusy, setSettingsBusy] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [joinTokens, setJoinTokens] = React.useState([]);
+ const [addonsCatalog, setAddonsCatalog] = React.useState([]);
+ const [addonBusyKey, setAddonBusyKey] = React.useState(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 (
+
+
+ {t('events.errors.notFoundTitle', 'Event nicht gefunden')}
+ {t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
+
+
+ );
+ }
+
+ 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 (
+
+ {error && (
+
+ {t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}
+ {error}
+
+ )}
+
+ {loading ? (
+
+ ) : event && stats ? (
+ {
+ 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)}
+ />
+ ) : (
+
+ {t('events.errors.notFoundTitle', 'Event nicht gefunden')}
+ {t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
+
+ )}
+
+ );
+}
+
+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(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(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).photos ?? (increments as Record).extra_photos;
+ const guests = (increments as Record).guests ?? (increments as Record).extra_guests;
+ const galleryDays = (increments as Record).gallery_days
+ ?? (increments as Record).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 (
+
+
+
+
+ {t('events.recap.badge', 'Nachbereitung')}
+
+
{resolveName(event.name)}
+
+ {t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
+
+
+
+
+ {event.status === 'published' ? t('events.status.published', 'Veröffentlicht') : t('events.status.draft', 'Entwurf')}
+
+
+
+ {event.event_date ? formatEventDate(event.event_date, undefined) : t('events.workspace.noDate', 'Kein Datum')}
+
+
+
+
+
+ {t('events.actions.backToList', 'Zurück zur Liste')}
+
+
+ {t('events.actions.edit', 'Bearbeiten')}
+
+
+ {busy ? : }
+ {event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
+
+
+
+
+
+
+
+
+
+ {t('events.recap.galleryTitle', 'Galerie-Status')}
+
+
{galleryStatusLabel}
+
+ {t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
+
+
+
+ {event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
+
+
+
+
+ {busy ? : }
+ {event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
+
+
+
+ {t('events.recap.moderate', 'Uploads ansehen')}
+
+
+
+
+
+
+ {t('events.recap.shareLink', 'Gäste-Link')}
+
+ {guestLink ? (
+
{guestLink}
+ ) : (
+
+ {t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
+
+ )}
+
+ {guestLink && onCopyLink ? (
+
+ {t('events.recap.copyLink', 'Link kopieren')}
+
+ ) : null}
+
+
+
+
+
{t('events.recap.allowDownloads', 'Downloads erlauben')}
+
{t('events.recap.allowDownloadsHint', 'Gäste dürfen Fotos speichern')}
+
+
onToggleDownloads(Boolean(checked))}
+ disabled={settingsBusy}
+ />
+
+
+
+
{t('events.recap.allowSharing', 'Teilen erlauben')}
+
{t('events.recap.allowSharingHint', 'Gäste dürfen Links teilen')}
+
+
onToggleSharing(Boolean(checked))}
+ disabled={settingsBusy}
+ />
+
+
+ {guestQrCodeDataUrl ? (
+
+
+
+
+
+
+
{t('events.recap.qrTitle', 'QR-Code teilen')}
+ {guestLink ? (
+
{guestLink}
+ ) : null}
+
+
+
+ {t('events.recap.qrDownload', 'QR-Code herunterladen')}
+
+
+ {t('events.recap.qrShare', 'Link/QR teilen')}
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ {t('events.recap.exportTitle', 'Export & Backup')}
+
+
+ {t('events.recap.exportCopy', 'Alle Assets sichern')}
+
+
+ {t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
+
+
+
+ {t('events.recap.backup', 'Backup')}
+
+
+
+
+
+ {t('events.recap.downloadAll', 'Alles herunterladen')}
+
+
+
+ {t('events.recap.downloadHighlights', 'Highlights herunterladen')}
+
+
+
+ {t('events.recap.highlightsHint', '“Highlights” = als Highlight markierte Fotos in der Galerie.')}
+
+
+
+
+
+
+
+
+
+ {t('events.recap.retentionTitle', 'Verlängerung / Archivierung')}
+
+
+ {galleryExpiresAt
+ ? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
+ : t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
+
+
+ {t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
+
+
+
+ {t('events.recap.expiry', 'Ablauf')}
+
+
+
+ defaultAddon && onCheckoutAddon(defaultAddon.key)}
+ >
+ {addonBusyKey === defaultAddon?.key ? : }
+ {defaultAddon?.label ?? t('events.actions.extendGallery', 'Galerie verlängern')}
+
+ setArchiveOpen(true)}>
+ {t('events.recap.archive', 'Archivieren/Löschen')}
+
+
+
+ {addonsToShow.length ? (
+
+
+ {t('events.recap.extendOptions', 'Alle Add-ons für dieses Event')}
+
+
+
+ {addonsToShow.map((addon) => (
+
+
+
+ {addon.label}
+
+
+ {describeAddon(addon) ?? t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
+
+
+
onCheckoutAddon(addon.key)}
+ >
+ {addonBusyKey === addon.key ? : }
+ {addon.price_id ? t('addons.buyNow', 'Jetzt freischalten') : t('events.recap.priceMissing', 'Preis nicht verknüpft')}
+
+
+ ))}
+
+
+
+ {t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
+
+
+ ) : (
+
+ {t('events.recap.noAddons', 'Aktuell keine Add-ons verfügbar.')}
+
+ )}
+
+
+
+
+
+
+ {t('events.feedback.badge', 'Feedback')}
+
+
+ {t('events.feedback.afterEventTitle', 'Event beendet – kurzes Feedback?')}
+
+
+ {t('events.feedback.afterEventCopy', 'Hat alles geklappt? Deine Antwort hilft uns für kommende Events.')}
+ {t('events.feedback.privacyHint', 'Nur Admin-Feedback, keine Gastdaten')}
+
+
+
+ {t('events.feedback.badgeShort', 'Feedback')}
+
+
+
+
+
+ {resolveName(event.name)}
+
+ {event.event_date ? (
+
+ {formatEventDate(event.event_date, undefined)}
+
+ ) : null}
+
+
+ {feedbackSubmitted ? (
+
+
{t('events.feedback.submitted', 'Danke!')}
+
{t('events.feedback.afterEventThanks', 'Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.')}
+
+ { setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
+ {t('events.feedback.sendAnother', 'Weiteres Feedback senden')}
+
+ { setFeedbackNeedsSupport(true); setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
+ {t('events.feedback.supportFollowup', 'Support anfragen')}
+
+
+
+ ) : (
+
+
setFeedbackOpen(true)} disabled={feedbackBusy}>
+
+ {t('events.feedback.cta', 'Feedback geben')}
+
+
+ {t('events.feedback.quickSentiment', 'Stimmung auswählbar (positiv/neutral/Support).')}
+
+
+ )}
+
+ {feedbackError ? (
+
+ {t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}
+ {feedbackError}
+
+ ) : null}
+
+
+
+
{ setFeedbackOpen(open); setFeedbackError(undefined); }}>
+
+
+ {t('events.feedback.dialogTitle', 'Kurzes After-Event Feedback')}
+
+ {t('events.feedback.dialogCopy', 'Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.')}
+
+
+
+
+
+
{t('events.feedback.sentiment', 'Stimmung')}
+
+ {(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
+ setSentiment(key)}
+ >
+ {copy[key]}
+
+ ))}
+
+
+
+
+
{t('events.feedback.bestQuestion', 'Was lief am besten?')}
+
+ {bestAreaOptions.map((option) => (
+ setFeedbackBestArea(option.key)}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+
{t('events.feedback.improve', 'Was sollen wir verbessern?')}
+
+
+
+ setFeedbackNeedsSupport(Boolean(checked))}
+ className="mt-0.5"
+ />
+
+ {t('events.feedback.supportHelp', 'Ich hätte gern ein kurzes Follow-up (Support).')}
+
+
+
+
+
+ setFeedbackOpen(false)} disabled={feedbackBusy}>
+ {t('common.cancel', 'Abbrechen')}
+
+ { void handleFeedbackSubmit(); }} disabled={feedbackBusy}>
+ {feedbackBusy ? : }
+ {t('events.feedback.submit', 'Feedback senden')}
+
+
+
+
+
+
{
+ setArchiveOpen(open);
+ if (!open) {
+ setArchiveConfirmed(false);
+ }
+ }}>
+
+
+ {t('events.recap.archiveTitle', 'Event archivieren')}
+
+ {t('events.recap.archiveDesc', 'Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte solltest du vorher abschließen.')}
+
+
+
+
+
{t('events.recap.archiveImpact', 'Was passiert?')}
+
+ {t('events.recap.archiveImpactClose', 'Gäste-Zugriff wird beendet, Uploads/Downloads werden deaktiviert.')}
+ {t('events.recap.archiveImpactLinks', 'Öffentliche Links und QR-Codes werden ungültig, bestehende Sessions laufen aus.')}
+ {t('events.recap.archiveImpactData', 'Daten bleiben intern für Compliance & Support sichtbar, können aber auf Anfrage gelöscht werden (DSGVO).')}
+
+
+
+
+ setArchiveConfirmed(Boolean(checked))}
+ className="mt-0.5"
+ />
+
+ {t('events.recap.archiveConfirm', 'Ich habe Exporte abgeschlossen und möchte die Galerie jetzt archivieren.')}
+
+
+
+
+ setArchiveOpen(false)} disabled={archiveBusy}>
+ {t('common.cancel', 'Abbrechen')}
+
+
+ {archiveBusy ? : null}
+ {t('events.recap.archiveConfirmCta', 'Archivierung starten')}
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+function SkeletonCard() {
+ return
;
+}
diff --git a/resources/js/admin/pages/EventTasksPage.tsx b/resources/js/admin/pages/EventTasksPage.tsx
index 0f07620..952a416 100644
--- a/resources/js/admin/pages/EventTasksPage.tsx
+++ b/resources/js/admin/pages/EventTasksPage.tsx
@@ -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 = (
navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
- {t('management.tasks.actions.back', 'Zurück zur Übersicht')}
+ {t('actions.back', 'Zurück zur Übersicht')}
);
return (
{error && (
- {t('dashboard.alerts.errorTitle', 'Fehler')}
+ {tDashboard('alerts.errorTitle', 'Fehler')}
{error}
)}
@@ -327,22 +327,22 @@ export default function EventTasksPage() {
) : !event ? (
- {t('management.tasks.alerts.notFoundTitle', 'Event nicht gefunden')}
- {t('management.tasks.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}
+ {t('alerts.notFoundTitle', 'Event nicht gefunden')}
+ {t('alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}
) : (
<>
setTab(value as 'tasks' | 'packs')} className="space-y-6">
- {t('management.tasks.tabs.tasks', 'Aufgaben')}
- {t('management.tasks.tabs.packs', 'Mission Packs')}
+ {t('tabs.tasks', 'Aufgaben')}
+ {t('tabs.packs', 'Mission Packs')}
{renderName(event.name, t)}
- {t('management.tasks.eventStatus', {
+ {t('eventStatus', {
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
})}
@@ -350,52 +350,44 @@ export default function EventTasksPage() {
- {t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
+ {t('modes.title', 'Aufgaben & Foto-Modus')}
- {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.')}
- {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')}
{modeSaving ? (
- {t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
+ {t('modes.updating', 'Einstellung wird gespeichert ...')}
) : null}
@@ -403,14 +395,11 @@ export default function EventTasksPage() {
- {t('management.tasks.library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')}
+ {t('library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')}
- {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.')}
navigate(buildEngagementTabPath('tasks'))}
>
- {t('management.tasks.library.open', 'Aufgaben-Bibliothek öffnen')}
+ {t('library.open', 'Aufgaben-Bibliothek öffnen')}
@@ -429,14 +418,14 @@ export default function EventTasksPage() {
- {t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}
+ {t('sections.assigned.title', 'Zugeordnete Tasks')}
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"
/>
@@ -446,8 +435,8 @@ export default function EventTasksPage() {
) : (
@@ -462,11 +451,11 @@ export default function EventTasksPage() {
- {t('management.tasks.sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
+ {t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
{availableTasks.length === 0 ? (
-
+
) : (
availableTasks.map((task) => (
@@ -477,7 +466,7 @@ export default function EventTasksPage() {
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
)
}
- disabled={isPhotoOnlyMode}
+ disabled={!tasksEnabled}
/>
{task.title}
@@ -489,9 +478,9 @@ export default function EventTasksPage() {
void handleAssign()}
- disabled={saving || selected.length === 0 || isPhotoOnlyMode}
+ disabled={saving || selected.length === 0 || !tasksEnabled}
>
- {saving ? : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
+ {saving ? : t('actions.assign', 'Ausgewählte Tasks zuweisen')}
@@ -577,7 +566,7 @@ function MissionPackGrid({
importingId: number | null;
onViewAll: () => void;
}) {
- const { t } = useTranslation('management');
+ const { t } = useTranslation('management', { keyPrefix: 'eventTasks.collections' });
return (
@@ -585,20 +574,20 @@ function MissionPackGrid({
- {t('management.tasks.collections.title', 'Mission Packs')}
+ {t('title', 'Mission Packs')}
- {t('management.tasks.collections.subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')}
+ {t('subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')}
- {t('management.tasks.collections.viewAll', 'Alle Kollektionen ansehen')}
+ {t('viewAll', 'Alle Kollektionen ansehen')}
{error ? (
- {t('management.tasks.collections.errorTitle', 'Kollektionen nicht verfügbar')}
+ {t('errorTitle', 'Kollektionen nicht verfügbar')}
{error}
) : null}
@@ -610,7 +599,7 @@ function MissionPackGrid({
))}
) : collections.length === 0 ? (
-
+
) : (
{collections.map((collection) => (
@@ -621,15 +610,15 @@ function MissionPackGrid({
{collection.description}
) : null}
- {t('management.tasks.collections.tasksCount', {
+ {t('tasksCount', {
defaultValue: '{{count}} Aufgaben',
count: collection.tasks_count,
})}
- {collection.event_type?.name ?? t('management.tasks.collections.genericType', 'Allgemein')}
- {collection.is_global ? t('management.tasks.collections.global', 'Global') : t('management.tasks.collections.custom', 'Custom')}
+ {collection.event_type?.name ?? t('genericType', 'Allgemein')}
+ {collection.is_global ? t('global', 'Global') : t('custom', 'Custom')}
) : (
- t('management.tasks.collections.importCta', 'Mission Pack importieren')
+ t('importCta', 'Mission Pack importieren')
)}
@@ -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');
}
}
diff --git a/resources/js/admin/pages/LoginStartPage.tsx b/resources/js/admin/pages/LoginStartPage.tsx
index ad4094f..d2bc35b 100644
--- a/resources/js/admin/pages/LoginStartPage.tsx
+++ b/resources/js/admin/pages/LoginStartPage.tsx
@@ -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 (
-
Weiterleitung zum Login …
+
{t('redirecting', 'Redirecting to login …')}
);
}
diff --git a/resources/js/admin/pages/TasksPage.tsx b/resources/js/admin/pages/TasksPage.tsx
index 654d267..2185cf9 100644
--- a/resources/js/admin/pages/TasksPage.tsx
+++ b/resources/js/admin/pages/TasksPage.tsx
@@ -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([]);
const [meta, setMeta] = React.useState(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 (
{error && (
- Fehler
+ {t('errors.title')}
{error}
)}
@@ -201,21 +200,21 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
- {t('navigation.collections')}
+ {tc('navigation.collections')}
- Neu
+ {t('actions.new')}
{
setPage(1);
@@ -225,7 +224,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
/>
{meta && meta.total > 0 ? (
- 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,
+ })}
) : null}
@@ -251,11 +254,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
{meta && meta.last_page > 1 ? (
- 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 })}
setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
- Zurück
+ {t('pagination.prev')}
setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
disabled={meta.current_page >= (meta.last_page ?? 1)}
>
- Weiter
+ {t('pagination.next')}
@@ -274,11 +277,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
- {editingTask ? 'Task bearbeiten' : 'Neue Task erstellen'}
+ {editingTask ? t('form.editTitle') : t('form.createTitle')}