coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.

This commit is contained in:
Codex Agent
2025-11-09 20:26:50 +01:00
parent f3c44be76d
commit 082b78cd43
80 changed files with 4855 additions and 435 deletions

View File

@@ -246,6 +246,8 @@ export type TenantTask = {
difficulty: 'easy' | 'medium' | 'hard' | null;
due_date: string | null;
is_completed: boolean;
event_type_id: number | null;
event_type?: TenantEventType | null;
tenant_id: number | null;
collection_id: number | null;
source_task_id: number | null;
@@ -693,6 +695,11 @@ function normalizeTask(task: JsonValue): TenantTask {
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
const exampleTranslations = normalizeTranslationMap(task.example_text ?? {});
const eventType = normalizeEventType(task.event_type ?? task.eventType ?? null);
const eventTypeId =
typeof task.event_type_id === 'number'
? Number(task.event_type_id)
: eventType?.id ?? null;
return {
id: Number(task.id ?? 0),
@@ -709,6 +716,8 @@ function normalizeTask(task: JsonValue): TenantTask {
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
due_date: task.due_date ?? null,
is_completed: Boolean(task.is_completed ?? false),
event_type_id: eventTypeId,
event_type: eventType,
tenant_id: task.tenant_id ?? null,
collection_id: task.collection_id ?? null,
source_task_id: task.source_task_id ?? null,

View File

@@ -1,33 +1,33 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
CalendarDays,
Sparkles,
CreditCard,
Settings as SettingsIcon,
} from 'lucide-react';
import { LayoutDashboard, CalendarDays, Camera, Settings } from 'lucide-react';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import {
ADMIN_HOME_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_BILLING_PATH,
ADMIN_ENGAGEMENT_PATH,
} from '../constants';
import { LanguageSwitcher } from './LanguageSwitcher';
import { registerApiErrorListener } from '../lib/apiError';
import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api';
import { NotificationCenter } from './NotificationCenter';
import { UserMenu } from './UserMenu';
import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav';
const navItems = [
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true },
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events', icon: CalendarDays },
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement', icon: Sparkles },
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing', icon: CreditCard },
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings', icon: SettingsIcon },
];
type NavItem = {
key: string;
to: string;
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
end?: boolean;
highlight?: boolean;
prefetchKey?: string;
};
interface AdminLayoutProps {
title: string;
@@ -39,6 +39,51 @@ interface AdminLayoutProps {
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
const { t } = useTranslation('common');
const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
const { events } = useEventContext();
const singleEvent = events.length === 1 ? events[0] : null;
const eventsPath = singleEvent?.slug ? ADMIN_EVENT_VIEW_PATH(singleEvent.slug) : ADMIN_EVENTS_PATH;
const eventsLabel = events.length === 1
? t('navigation.event', { defaultValue: 'Event' })
: t('navigation.events');
const photosPath = singleEvent?.slug ? ADMIN_EVENT_PHOTOS_PATH(singleEvent.slug) : ADMIN_EVENTS_PATH;
const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' });
const settingsLabel = t('navigation.settings');
const navItems = React.useMemo<NavItem[]>(() => [
{
key: 'dashboard',
to: ADMIN_HOME_PATH,
label: t('navigation.dashboard'),
icon: LayoutDashboard,
end: true,
prefetchKey: ADMIN_HOME_PATH,
},
{
key: 'events',
to: eventsPath,
label: eventsLabel,
icon: CalendarDays,
end: Boolean(singleEvent?.slug),
highlight: events.length === 1,
prefetchKey: ADMIN_EVENTS_PATH,
},
{
key: 'photos',
to: photosPath,
label: photosLabel,
icon: Camera,
end: Boolean(singleEvent?.slug),
prefetchKey: singleEvent?.slug ? undefined : ADMIN_EVENTS_PATH,
},
{
key: 'settings',
to: ADMIN_SETTINGS_PATH,
label: settingsLabel,
icon: Settings,
prefetchKey: ADMIN_SETTINGS_PATH,
},
], [eventsLabel, eventsPath, photosPath, photosLabel, settingsLabel, singleEvent, events.length, t]);
const prefetchers = React.useMemo(() => ({
[ADMIN_HOME_PATH]: () =>
@@ -48,7 +93,6 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
getTenantPackagesOverview(),
]).then(() => undefined),
[ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined),
[ADMIN_ENGAGEMENT_PATH]: () => getEvents().then(() => undefined),
[ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined),
[ADMIN_SETTINGS_PATH]: () => Promise.resolve(),
}), []);
@@ -109,35 +153,43 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<EventSwitcher />
{actions}
<LanguageSwitcher />
<NotificationCenter />
<UserMenu />
</div>
</div>
<nav className="hidden border-t border-slate-200/60 dark:border-white/5 sm:block">
<div className="mx-auto flex w-full max-w-6xl items-center gap-1 px-4 py-2 sm:px-6">
{navItems.map(({ to, labelKey, icon: Icon, end }) => (
{navItems.map((item) => (
<NavLink
key={to}
to={to}
end={end}
onPointerEnter={() => triggerPrefetch(to)}
onFocus={() => triggerPrefetch(to)}
onTouchStart={() => triggerPrefetch(to)}
key={item.key}
to={item.to}
end={item.end}
onPointerEnter={() => triggerPrefetch(item.prefetchKey ?? item.to)}
onFocus={() => triggerPrefetch(item.prefetchKey ?? item.to)}
onTouchStart={() => triggerPrefetch(item.prefetchKey ?? item.to)}
className={({ isActive }) =>
cn(
'flex items-center gap-2 rounded-2xl px-3 py-2 text-xs font-semibold uppercase tracking-wide transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
isActive
? 'bg-rose-600 text-white shadow shadow-rose-300/40'
: 'text-slate-500 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white'
: cn(
item.highlight
? 'text-rose-600 dark:text-rose-200'
: 'text-slate-500 dark:text-slate-300',
'hover:text-slate-900 dark:hover:text-white'
)
)
}
>
<Icon className="h-4 w-4" />
{t(labelKey)}
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</div>
</nav>
<EventMenuBar />
</header>
<main className="relative z-10 mx-auto w-full max-w-5xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+5.5rem)] pt-5 sm:px-6 md:pb-16">
@@ -154,7 +206,7 @@ function TenantMobileNav({
items,
onPrefetch,
}: {
items: typeof navItems;
items: NavItem[];
onPrefetch: (path: string) => void;
}) {
const { t } = useTranslation('common');
@@ -167,25 +219,30 @@ function TenantMobileNav({
/>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/80 bg-white/95 px-4 pb-[calc(env(safe-area-inset-bottom,0)+0.75rem)] pt-3 shadow-2xl shadow-rose-300/15 backdrop-blur supports-[backdrop-filter]:bg-white/90 dark:border-slate-800/70 dark:bg-slate-950/90">
<div className="mx-auto flex max-w-xl items-center justify-around gap-1">
{items.map(({ to, labelKey, icon: Icon, end }) => (
{items.map((item) => (
<NavLink
key={to}
to={to}
end={end}
onPointerEnter={() => onPrefetch(to)}
onFocus={() => onPrefetch(to)}
onTouchStart={() => onPrefetch(to)}
key={item.key}
to={item.to}
end={item.end}
onPointerEnter={() => onPrefetch(item.prefetchKey ?? item.to)}
onFocus={() => onPrefetch(item.prefetchKey ?? item.to)}
onTouchStart={() => onPrefetch(item.prefetchKey ?? item.to)}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-950',
isActive
? 'bg-rose-600 text-white shadow-md shadow-rose-400/25'
: 'hover:text-rose-700 dark:hover:text-rose-200'
: cn(
item.highlight
? 'text-rose-600 dark:text-rose-200'
: 'text-slate-600 dark:text-slate-300',
'hover:text-rose-700 dark:hover:text-rose-200'
)
)
}
>
<Icon className="h-5 w-5" />
<span>{t(labelKey)}</span>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
))}
</div>

View File

@@ -0,0 +1,225 @@
import React from 'react';
import { useNavigate, NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { useEventContext } from '../context/EventContext';
import {
ADMIN_EVENT_CREATE_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_VIEW_PATH,
} from '../constants';
import type { TenantEvent } from '../api';
import { cn } from '@/lib/utils';
function resolveEventName(event: TenantEvent): string {
const name = event.name;
if (typeof name === 'string' && name.trim().length > 0) {
return name;
}
if (name && typeof name === 'object') {
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
if (first) {
return first;
}
}
return event.slug ?? 'Event';
}
function formatEventDate(value?: string | null, locale = 'de-DE'): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}
function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']) {
return [
{ key: 'summary', label: t('eventMenu.summary', 'Übersicht'), href: ADMIN_EVENT_VIEW_PATH(slug) },
{ key: 'photos', label: t('eventMenu.photos', 'Uploads'), href: ADMIN_EVENT_PHOTOS_PATH(slug) },
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'toolkit', label: t('eventMenu.toolkit', 'Toolkit'), href: ADMIN_EVENT_TOOLKIT_PATH(slug) },
];
}
export function EventSwitcher() {
const { events, activeEvent, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('common');
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const buttonLabel = activeEvent ? resolveEventName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
const buttonHint = activeEvent?.event_date
? formatEventDate(activeEvent.event_date, locale)
: events.length > 1
? t('eventSwitcher.multiple', 'Mehrere Events')
: t('eventSwitcher.empty', 'Noch kein Event');
const handleSelect = (event: TenantEvent) => {
selectEvent(event.slug ?? null);
setOpen(false);
if (event.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(event.slug));
}
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white">
<CalendarDays className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">{buttonLabel}</span>
<span className="text-xs text-slate-500 dark:text-slate-300 sm:ml-2">
{buttonHint}
</span>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="rounded-t-3xl p-0">
<SheetHeader className="border-b border-slate-200 p-4 dark:border-white/10">
<SheetTitle>{t('eventSwitcher.title', 'Event auswählen')}</SheetTitle>
<SheetDescription>
{events.length === 0
? t('eventSwitcher.emptyDescription', 'Erstelle dein erstes Event, um loszulegen.')
: t('eventSwitcher.description', 'Wähle ein Event für die Bearbeitung oder lege ein neues an.')}
</SheetDescription>
</SheetHeader>
<div className="max-h-[60vh] overflow-y-auto p-4">
{events.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-600 dark:border-white/15 dark:text-slate-300">
{t('eventSwitcher.noEvents', 'Noch keine Events vorhanden.')}
</div>
) : (
<div className="space-y-2">
{events.map((event) => {
const isActive = activeEvent?.id === event.id;
const date = formatEventDate(event.event_date, locale);
return (
<button
key={event.id}
type="button"
onClick={() => handleSelect(event)}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition hover:border-rose-200 dark:border-white/10 dark:bg-white/5',
isActive
? 'border-rose-500 bg-rose-50/70 text-rose-900 dark:border-rose-300 dark:bg-rose-200/10 dark:text-rose-100'
: 'bg-white text-slate-900'
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">{resolveEventName(event)}</p>
<p className="text-xs text-slate-500 dark:text-slate-300">{date ?? t('eventSwitcher.noDate', 'Kein Datum')}</p>
</div>
{isActive ? (
<Badge className="bg-rose-600 text-white">{t('eventSwitcher.active', 'Aktiv')}</Badge>
) : null}
</div>
</button>
);
})}
</div>
)}
<Button
size="sm"
variant="secondary"
className="mt-4 w-full rounded-full"
onClick={() => {
setOpen(false);
navigate(ADMIN_EVENT_CREATE_PATH);
}}
>
<PlusCircle className="mr-2 h-4 w-4" />
{t('eventSwitcher.create', 'Neues Event anlegen')}
</Button>
{activeEvent?.slug ? (
<div className="mt-6 space-y-3">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300">
{t('eventSwitcher.actions', 'Event-Funktionen')}
</p>
<div className="grid gap-2">
{buildEventLinks(activeEvent.slug, t).map((action) => (
<Button
key={action.key}
variant="ghost"
className="justify-between rounded-2xl border border-slate-200 bg-white text-left text-sm font-semibold text-slate-700 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:text-white"
onClick={() => {
setOpen(false);
navigate(action.href);
}}
>
{action.label}
<ChevronDown className="rotate-[-90deg]" />
</Button>
))}
</div>
</div>
) : null}
</div>
</SheetContent>
</Sheet>
);
}
export function EventMenuBar() {
const { activeEvent } = useEventContext();
const { t } = useTranslation('common');
const location = useLocation();
if (!activeEvent?.slug) {
return null;
}
const links = buildEventLinks(activeEvent.slug, t);
return (
<div className="border-t border-slate-200 bg-white/80 px-4 py-2 dark:border-white/10 dark:bg-slate-950/80">
<div className="flex items-center gap-2 overflow-x-auto text-sm">
{links.map((link) => (
<NavLink
key={link.key}
to={link.href}
className={({ isActive }) =>
cn(
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold transition',
isActive || location.pathname.startsWith(link.href)
? 'bg-rose-600 text-white shadow shadow-rose-400/40'
: 'bg-white text-slate-600 ring-1 ring-slate-200 hover:text-rose-600 dark:bg-white/10 dark:text-white dark:ring-white/10'
)
}
>
{link.label}
</NavLink>
))}
</div>
</div>
);
}

View File

@@ -10,42 +10,13 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import i18n from '../i18n';
type SupportedLocale = 'de' | 'en';
const SUPPORTED_LANGUAGES: Array<{ code: SupportedLocale; labelKey: string }> = [
{ code: 'de', labelKey: 'language.de' },
{ code: 'en', labelKey: 'language.en' },
];
function getCsrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name=\"csrf-token\"]')?.content ?? '';
}
async function persistLocale(locale: SupportedLocale): Promise<void> {
const response = await fetch('/set-locale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ locale }),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`locale update failed with status ${response.status}`);
}
}
import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale';
export function LanguageSwitcher() {
const { t } = useTranslation('common');
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = (i18n.language || document.documentElement.lang || 'de') as SupportedLocale;
const currentLocale = getCurrentLocale();
const changeLanguage = React.useCallback(
async (locale: SupportedLocale) => {
@@ -55,12 +26,9 @@ export function LanguageSwitcher() {
setPendingLocale(locale);
try {
await persistLocale(locale);
await i18n.changeLanguage(locale);
document.documentElement.setAttribute('lang', locale);
await switchLocale(locale);
} catch (error) {
if (import.meta.env.DEV) {
console.error('Failed to switch language', error);
}
} finally {

View File

@@ -0,0 +1,306 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, Bell, CheckCircle2, Clock, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { getDashboardSummary, getEvents, type DashboardSummary, type TenantEvent } from '../api';
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { isAuthError } from '../auth/tokens';
export type NotificationTone = 'info' | 'warning' | 'success';
interface TenantNotification {
id: string;
title: string;
description?: string;
tone: NotificationTone;
action?: {
label: string;
onSelect: () => void;
};
}
export function NotificationCenter() {
const navigate = useNavigate();
const { t } = useTranslation('dashboard');
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [notifications, setNotifications] = React.useState<TenantNotification[]>([]);
const [dismissed, setDismissed] = React.useState<Set<string>>(new Set());
const visibleNotifications = React.useMemo(
() => notifications.filter((notification) => !dismissed.has(notification.id)),
[notifications, dismissed]
);
const unreadCount = visibleNotifications.length;
const refresh = React.useCallback(async () => {
setLoading(true);
try {
const [events, summary] = await Promise.all([
getEvents().catch(() => [] as TenantEvent[]),
getDashboardSummary().catch(() => null as DashboardSummary | null),
]);
setNotifications(buildNotifications({
events,
summary,
navigate,
t,
}));
} catch (error) {
if (!isAuthError(error)) {
console.error('[NotificationCenter] Failed to load data', error);
}
} finally {
setLoading(false);
}
}, [navigate, t]);
React.useEffect(() => {
refresh();
}, [refresh]);
const handleDismiss = React.useCallback((id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
}, []);
const iconForTone: Record<NotificationTone, React.ReactNode> = React.useMemo(
() => ({
info: <Clock className="h-4 w-4 text-slate-400" />,
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />,
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
}),
[]
);
return (
<DropdownMenu open={open} onOpenChange={(next) => {
setOpen(next);
if (next) {
refresh();
}
}}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative rounded-full border border-transparent text-slate-600 hover:text-rose-600 dark:text-slate-200"
aria-label={t('notifications.trigger', { defaultValue: 'Benachrichtigungen' })}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<Badge className="absolute -right-1 -top-1 rounded-full bg-rose-600 px-1.5 text-[10px] font-semibold text-white">
{unreadCount}
</Badge>
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 space-y-1 p-0">
<DropdownMenuLabel className="flex items-center justify-between py-2">
<span>{t('notifications.title', { defaultValue: 'Notifications' })}</span>
{!loading && unreadCount === 0 ? (
<Badge variant="outline">{t('notifications.empty', { defaultValue: 'Aktuell ruhig' })}</Badge>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{loading ? (
<div className="space-y-2 p-3">
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
</div>
) : (
<div className="max-h-80 space-y-1 overflow-y-auto p-1">
{visibleNotifications.length === 0 ? (
<p className="px-3 py-4 text-sm text-slate-500">
{t('notifications.empty.message', { defaultValue: 'Alles erledigt wir melden uns bei Neuigkeiten.' })}
</p>
) : (
visibleNotifications.map((item) => (
<DropdownMenuItem key={item.id} className="flex flex-col gap-1 py-3" onSelect={(event) => event.preventDefault()}>
<div className="flex items-start gap-3">
<span className="mt-0.5">{iconForTone[item.tone]}</span>
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{item.title}</p>
{item.description ? (
<p className="text-xs text-slate-600 dark:text-slate-300">{item.description}</p>
) : null}
<div className="flex flex-wrap gap-2 pt-1">
{item.action ? (
<Button
size="sm"
variant="outline"
className="h-7 rounded-full px-3 text-xs"
onClick={() => {
item.action?.onSelect();
setOpen(false);
}}
>
{item.action.label}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
className="h-7 rounded-full px-3 text-xs text-slate-500 hover:text-rose-600"
onClick={() => handleDismiss(item.id)}
>
{t('notifications.action.dismiss', { defaultValue: 'Ausblenden' })}
</Button>
</div>
</div>
</div>
</DropdownMenuItem>
))
)}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(event) => {
event.preventDefault();
setDismissed(new Set());
refresh();
}}
>
<Plus className="h-3.5 w-3.5" />
{t('notifications.action.refresh', { defaultValue: 'Neue Hinweise laden' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function buildNotifications({
events,
summary,
navigate,
t,
}: {
events: TenantEvent[];
summary: DashboardSummary | null;
navigate: ReturnType<typeof useNavigate>;
t: (key: string, options?: Record<string, unknown>) => string;
}): TenantNotification[] {
const items: TenantNotification[] = [];
const primary = events[0] ?? null;
const now = Date.now();
if (events.length === 0) {
items.push({
id: 'no-events',
title: t('notifications.noEvents.title', { defaultValue: 'Legen wir los' }),
description: t('notifications.noEvents.description', {
defaultValue: 'Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.',
}),
tone: 'warning',
action: {
label: t('notifications.noEvents.cta', { defaultValue: 'Event erstellen' }),
onSelect: () => navigate(ADMIN_EVENT_CREATE_PATH),
},
});
return items;
}
events.forEach((event) => {
if (event.status !== 'published') {
items.push({
id: `draft-${event.id}`,
title: t('notifications.draftEvent.title', { defaultValue: 'Event noch als Entwurf' }),
description: t('notifications.draftEvent.description', {
defaultValue: 'Veröffentliche das Event, um Einladungen und Galerie freizugeben.',
}),
tone: 'info',
action: event.slug
? {
label: t('notifications.draftEvent.cta', { defaultValue: 'Event öffnen' }),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
}
: undefined,
});
}
const eventDate = event.event_date ? new Date(event.event_date).getTime() : null;
if (eventDate && eventDate > now) {
const days = Math.round((eventDate - now) / (1000 * 60 * 60 * 24));
if (days <= 7) {
items.push({
id: `upcoming-${event.id}`,
title: t('notifications.upcomingEvent.title', { defaultValue: 'Event startet bald' }),
description: t('notifications.upcomingEvent.description', {
defaultValue: days === 0
? 'Heute findet ein Event statt checke Uploads und Tasks.'
: `Noch ${days} Tage bereite Einladungen und Aufgaben vor.`,
}),
tone: 'info',
action: event.slug
? {
label: t('notifications.upcomingEvent.cta', { defaultValue: 'Zum Event' }),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
}
: undefined,
});
}
}
const pendingUploads = Number(event.pending_photo_count ?? 0);
if (pendingUploads > 0) {
items.push({
id: `pending-uploads-${event.id}`,
title: t('notifications.pendingUploads.title', { defaultValue: 'Uploads warten auf Freigabe' }),
description: t('notifications.pendingUploads.description', {
defaultValue: `${pendingUploads} neue Uploads benötigen Moderation.`,
}),
tone: 'warning',
action: event.slug
? {
label: t('notifications.pendingUploads.cta', { defaultValue: 'Uploads öffnen' }),
onSelect: () => navigate(`${ADMIN_EVENT_VIEW_PATH(event.slug!)}#photos`),
}
: undefined,
});
}
});
if ((summary?.new_photos ?? 0) > 0) {
items.push({
id: 'summary-new-photos',
title: t('notifications.newPhotos.title', { defaultValue: 'Neue Fotos eingetroffen' }),
description: t('notifications.newPhotos.description', {
defaultValue: `${summary?.new_photos ?? 0} Uploads warten auf dich.`,
}),
tone: 'success',
action: primary?.slug
? {
label: t('notifications.newPhotos.cta', { defaultValue: 'Galerie öffnen' }),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(primary.slug!)),
}
: {
label: t('notifications.newPhotos.ctaFallback', { defaultValue: 'Events ansehen' }),
onSelect: () => navigate(ADMIN_EVENTS_PATH),
},
});
}
return items;
}

View File

@@ -0,0 +1,161 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { HelpCircle, LogOut, Monitor, Moon, Settings, Sun, User, Languages, CreditCard } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useAuth } from '../auth/context';
import { ADMIN_FAQ_PATH, ADMIN_PROFILE_PATH, ADMIN_SETTINGS_PATH, ADMIN_BILLING_PATH } from '../constants';
import { useAppearance } from '@/hooks/use-appearance';
import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale';
export function UserMenu() {
const { user, logout } = useAuth();
const { appearance, updateAppearance } = useAppearance();
const navigate = useNavigate();
const { t } = useTranslation('common');
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = getCurrentLocale();
const initials = React.useMemo(() => {
if (user?.name) {
return user.name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
if (user?.email) {
return user.email.charAt(0).toUpperCase();
}
return 'TU';
}, [user?.name, user?.email]);
const changeLanguage = React.useCallback(async (locale: SupportedLocale) => {
if (locale === currentLocale) {
return;
}
setPendingLocale(locale);
try {
await switchLocale(locale);
} finally {
setPendingLocale(null);
}
}, [currentLocale]);
const changeAppearance = React.useCallback(
(mode: 'light' | 'dark' | 'system') => {
updateAppearance(mode);
},
[updateAppearance]
);
const goTo = React.useCallback((path: string) => navigate(path), [navigate]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 rounded-full border border-transparent px-2">
<Avatar className="h-8 w-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold sm:inline">
{user?.name || user?.email || t('app.userMenu')}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>
<p className="text-sm font-semibold">{user?.name ?? t('user.unknown')}</p>
{user?.email ? <p className="text-xs text-slate-500">{user.email}</p> : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => goTo(ADMIN_PROFILE_PATH)}>
<User className="h-4 w-4" />
{t('navigation.profile', { defaultValue: 'Profil' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{t('navigation.settings', { defaultValue: 'Einstellungen' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_BILLING_PATH)}>
<CreditCard className="h-4 w-4" />
{t('navigation.billing', { defaultValue: 'Billing' })}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="h-4 w-4" />
<span>{t('app.languageSwitch')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent align="end">
{SUPPORTED_LANGUAGES.map(({ code, labelKey }) => (
<DropdownMenuItem
key={code}
className="flex items-center justify-between"
onSelect={(event) => {
event.preventDefault();
changeLanguage(code);
}}
disabled={pendingLocale === code}
>
<span>{t(labelKey)}</span>
{currentLocale === code ? <span className="text-xs text-rose-500">{t('app.languageActive', { defaultValue: 'Aktiv' })}</span> : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{appearance === 'dark' ? <Moon className="h-4 w-4" /> : appearance === 'light' ? <Sun className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
<span>{t('app.theme', { defaultValue: 'Darstellung' })}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent align="end">
{(['light', 'dark', 'system'] as const).map((mode) => (
<DropdownMenuItem key={mode} onSelect={() => changeAppearance(mode)}>
{mode === 'light' ? <Sun className="h-4 w-4" /> : mode === 'dark' ? <Moon className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
<span>{t(`app.theme_${mode}`, { defaultValue: mode })}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => goTo(ADMIN_FAQ_PATH)}>
<HelpCircle className="h-4 w-4" />
{t('app.help', { defaultValue: 'FAQ & Hilfe' })}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={(event) => {
event.preventDefault();
logout();
}}
>
<LogOut className="h-4 w-4" />
{t('app.logout', { defaultValue: 'Abmelden' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -11,6 +11,7 @@ export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile');
export const ADMIN_FAQ_PATH = adminPath('/faq');
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { getEvents, type TenantEvent } from '../api';
const STORAGE_KEY = 'tenant-admin.active-event';
export interface EventContextValue {
events: TenantEvent[];
isLoading: boolean;
activeEvent: TenantEvent | null;
selectEvent: (slug: string | null) => void;
}
const EventContext = React.createContext<EventContextValue | undefined>(undefined);
export function EventProvider({ children }: { children: React.ReactNode }) {
const [storedSlug, setStoredSlug] = React.useState<string | null>(() => {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(STORAGE_KEY);
});
const { data: events = [], isLoading } = useQuery<TenantEvent[]>({
queryKey: ['tenant-events'],
queryFn: async () => {
try {
return await getEvents();
} catch (error) {
console.warn('[EventContext] Failed to fetch events', error);
return [];
}
},
staleTime: 60 * 1000,
cacheTime: 5 * 60 * 1000,
});
React.useEffect(() => {
if (!storedSlug && events.length === 1 && events[0]?.slug && typeof window !== 'undefined') {
setStoredSlug(events[0].slug);
window.localStorage.setItem(STORAGE_KEY, events[0].slug);
}
}, [events, storedSlug]);
const activeEvent = React.useMemo(() => {
if (!events.length) {
return null;
}
const matched = events.find((event) => event.slug && event.slug === storedSlug);
if (matched) {
return matched;
}
if (!storedSlug && events.length === 1) {
return events[0];
}
return null;
}, [events, storedSlug]);
const selectEvent = React.useCallback((slug: string | null) => {
setStoredSlug(slug);
if (typeof window !== 'undefined') {
if (slug) {
window.localStorage.setItem(STORAGE_KEY, slug);
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
}
}, []);
const value = React.useMemo<EventContextValue>(
() => ({
events,
isLoading,
activeEvent,
selectEvent,
}),
[events, isLoading, activeEvent, selectEvent]
);
return <EventContext.Provider value={value}>{children}</EventContext.Provider>;
}
export function useEventContext(): EventContextValue {
const ctx = React.useContext(EventContext);
if (!ctx) {
throw new Error('useEventContext must be used within an EventProvider');
}
return ctx;
}

View File

@@ -1,18 +1,50 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"languageSwitch": "Sprache"
"languageSwitch": "Sprache",
"userMenu": "Konto",
"help": "FAQ & Hilfe",
"logout": "Abmelden",
"theme": "Darstellung",
"theme_light": "Hell",
"theme_dark": "Dunkel",
"theme_system": "System",
"languageActive": "Aktiv"
},
"navigation": {
"dashboard": "Dashboard",
"event": "Event",
"events": "Events",
"photos": "Fotos",
"tasks": "Aufgaben",
"collections": "Aufgabenvorlagen",
"emotions": "Emotionen",
"engagement": "Aufgaben & Co.",
"toolkit": "Toolkit",
"billing": "Abrechnung",
"settings": "Einstellungen"
},
"eventMenu": {
"summary": "Übersicht",
"photos": "Uploads",
"guests": "Team & Gäste",
"tasks": "Aufgaben",
"invites": "Einladungen",
"toolkit": "Toolkit"
},
"eventSwitcher": {
"title": "Event auswählen",
"description": "Wähle ein Event zur Bearbeitung oder lege ein neues an.",
"placeholder": "Event auswählen",
"multiple": "Mehrere Events",
"empty": "Kein Event",
"emptyDescription": "Erstelle dein erstes Event, um loszulegen.",
"noEvents": "Noch keine Events vorhanden.",
"noDate": "Kein Datum",
"active": "Aktiv",
"create": "Neues Event anlegen",
"actions": "Event-Funktionen"
},
"language": {
"de": "Deutsch",
"en": "Englisch"

View File

@@ -36,6 +36,17 @@
"lowCredits": "Auffüllen empfohlen"
}
},
"liveNow": {
"title": "Während des Events",
"description": "Direkter Zugriff, solange {{count}} Event(s) live sind.",
"status": "Jetzt live",
"noDate": "Kein Datum",
"actions": {
"photos": "Uploads",
"invites": "QR & Einladungen",
"tasks": "Aufgaben"
}
},
"readiness": {
"title": "Bereit für den Eventstart",
"description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.",
@@ -112,6 +123,31 @@
"noDate": "Kein Datum"
}
},
"faq": {
"title": "FAQ & Hilfe",
"subtitle": "Antworten und Hinweise rund um den Tenant Admin.",
"intro": {
"title": "Was dich erwartet",
"description": "Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt."
},
"events": {
"question": "Wie arbeite ich mit Events?",
"answer": "Wähle dein aktives Event, passe Aufgaben an und teile Einladungen. Ausführliche Dokumentation folgt."
},
"uploads": {
"question": "Wie moderiere ich Uploads?",
"answer": "Sobald Fotos eintreffen, findest du sie in der Event-Galerie und kannst sie freigeben oder ablehnen."
},
"support": {
"question": "Wo erhalte ich Support?",
"answer": "Dieses FAQ ist ein Platzhalter. Nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank live ist."
},
"cta": {
"needHelp": "Fehlt dir etwas?",
"description": "Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail wir ergänzen dieses FAQ mit deinen Themen.",
"contact": "Support kontaktieren"
}
},
"dashboard": {
"actions": {
"newEvent": "Neues Event",

View File

@@ -1,18 +1,50 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"languageSwitch": "Language"
"languageSwitch": "Language",
"userMenu": "Account",
"help": "FAQ & Help",
"logout": "Log out",
"theme": "Appearance",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System",
"languageActive": "Active"
},
"navigation": {
"dashboard": "Dashboard",
"event": "Event",
"events": "Events",
"photos": "Photos",
"tasks": "Tasks",
"collections": "Collections",
"emotions": "Emotions",
"engagement": "Tasks & More",
"toolkit": "Toolkit",
"billing": "Billing",
"settings": "Settings"
},
"eventMenu": {
"summary": "Overview",
"photos": "Uploads",
"guests": "Members",
"tasks": "Tasks",
"invites": "Invites",
"toolkit": "Toolkit"
},
"eventSwitcher": {
"title": "Select event",
"description": "Choose an event to work on or create a new one.",
"placeholder": "Select event",
"multiple": "Multiple events",
"empty": "No event",
"emptyDescription": "Create your first event to get started.",
"noEvents": "No events yet.",
"noDate": "No date",
"active": "Active",
"create": "Create new event",
"actions": "Event tools"
},
"language": {
"de": "German",
"en": "English"

View File

@@ -36,6 +36,17 @@
"lowCredits": "Top up recommended"
}
},
"liveNow": {
"title": "During the event",
"description": "Quick actions while {{count}} event(s) are live.",
"status": "Live now",
"noDate": "No date",
"actions": {
"photos": "Live uploads",
"invites": "QR & invites",
"tasks": "Tasks"
}
},
"readiness": {
"title": "Ready for event day",
"description": "Complete these steps so guests can join without friction.",
@@ -112,6 +123,31 @@
"noDate": "No date"
}
},
"faq": {
"title": "FAQ & Help",
"subtitle": "Answers and hints around the tenant admin.",
"intro": {
"title": "What to expect",
"description": "We are collecting feedback and will expand this help center step by step."
},
"events": {
"question": "How do I work with events?",
"answer": "Select your active event, adjust tasks, and share invites. More documentation will follow soon."
},
"uploads": {
"question": "How do I moderate uploads?",
"answer": "Once photos arrive you can review them in the event gallery and approve or reject them."
},
"support": {
"question": "Where do I get support?",
"answer": "This FAQ is a placeholder. Please reach out through the known support channel until the knowledge base ships."
},
"cta": {
"needHelp": "Missing something?",
"description": "Send us your feedback straight from the admin or via support mail well extend this FAQ with your topics.",
"contact": "Contact support"
}
},
"dashboard": {
"actions": {
"newEvent": "New Event",

View File

@@ -0,0 +1,40 @@
import i18n from '../i18n';
export type SupportedLocale = 'de' | 'en';
export const SUPPORTED_LANGUAGES: Array<{ code: SupportedLocale; labelKey: string }> = [
{ code: 'de', labelKey: 'language.de' },
{ code: 'en', labelKey: 'language.en' },
];
function getCsrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
}
async function persistLocale(locale: SupportedLocale): Promise<void> {
const response = await fetch('/set-locale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ locale }),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`locale update failed with status ${response.status}`);
}
}
export function getCurrentLocale(): SupportedLocale {
return (i18n.language || document.documentElement.lang || 'de') as SupportedLocale;
}
export async function switchLocale(locale: SupportedLocale): Promise<void> {
await persistLocale(locale);
await i18n.changeLanguage(locale);
document.documentElement.setAttribute('lang', locale);
}

View File

@@ -9,6 +9,7 @@ import './i18n';
import './dev-tools';
import { initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding';
import { EventProvider } from './context/EventContext';
const DevTenantSwitcher = React.lazy(() => import('./components/DevTenantSwitcher'));
@@ -41,17 +42,19 @@ createRoot(rootEl).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<OnboardingProgressProvider>
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Oberfläche wird geladen
</div>
)}
>
<RouterProvider router={router} />
</Suspense>
</OnboardingProgressProvider>
<EventProvider>
<OnboardingProgressProvider>
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Oberfläche wird geladen
</div>
)}
>
<RouterProvider router={router} />
</Suspense>
</OnboardingProgressProvider>
</EventProvider>
</AuthProvider>
{enableDevSwitcher ? (
<Suspense fallback={null}>

View File

@@ -12,7 +12,6 @@ import {
QrCode,
ClipboardList,
Package as PackageIcon,
ArrowUpRight,
} from 'lucide-react';
import toast from 'react-hot-toast';
@@ -26,7 +25,6 @@ import {
TenantOnboardingChecklistCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
StatCarousel,
@@ -52,6 +50,7 @@ import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
@@ -212,7 +211,7 @@ export default function DashboardPage() {
meta: primary ? { event_id: primary.id } : undefined,
});
}
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
}, [loading, events, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
const greetingName = user?.name ?? translate('welcome.fallbackName');
const greetingTitle = translate('welcome.greeting', { name: greetingName });
@@ -224,6 +223,9 @@ export default function DashboardPage() {
const publishedEvents = events.filter((event) => event.status === 'published');
const primaryEvent = events[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
const singleEvent = events.length === 1 ? events[0] : null;
const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null;
const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null;
const primaryEventLimits = primaryEvent?.limits ?? null;
const limitTranslate = React.useCallback(
@@ -271,6 +273,31 @@ export default function DashboardPage() {
}, [summary, events]);
const primaryEventSlug = readiness.primaryEventSlug;
const liveEvents = React.useMemo(() => {
const now = Date.now();
const windowLengthMs = 2 * 24 * 60 * 60 * 1000; // event day + following day
return events.filter((event) => {
if (!event.slug) {
return false;
}
const isActivated = Boolean(event.is_active || event.status === 'published');
if (!isActivated) {
return false;
}
if (!event.event_date) {
return true;
}
const eventStart = new Date(event.event_date).getTime();
if (Number.isNaN(eventStart)) {
return true;
}
return now >= eventStart && now <= eventStart + windowLengthMs;
});
}, [events]);
const statItems = React.useMemo(
() => ([
{
@@ -430,46 +457,77 @@ export default function DashboardPage() {
'Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.'
);
const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten');
const heroBadge = translate('overview.title', 'Kurzer Überblick');
const heroDescription = translate(
'overview.description',
'Wichtigste Kennzahlen deines Tenants auf einen Blick.'
);
const marketingDashboardLabel = translate('onboarding.back_to_marketing', 'Marketing-Dashboard ansehen');
const marketingDashboardDescription = translate(
'onboarding.back_to_marketing_description',
'Zur Zusammenfassung im Kundenportal wechseln.'
);
const heroBadge = singleEvent
? translate('overview.eventHero.badge', 'Aktives Event')
: translate('overview.title', 'Kurzer Überblick');
const heroDescription = singleEvent
? translate('overview.eventHero.description', 'Alles richtet sich nach {{event}}. Nächster Termin: {{date}}.', {
event: singleEventName ?? '',
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
})
: translate('overview.description', 'Wichtigste Kennzahlen deines Tenants auf einen Blick.');
const heroSupportingCopy = onboardingCompletion === 100 ? onboardingCompletedCopy : onboardingCardDescription;
const heroPrimaryCtaLabel = readiness.hasEvent
? translate('quickActions.moderatePhotos.label', 'Fotos moderieren')
: translate('actions.newEvent');
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => {
if (readiness.hasEvent) {
navigate(ADMIN_EVENTS_PATH);
} else {
navigate(ADMIN_EVENT_CREATE_PATH);
}
}}
>
{heroPrimaryCtaLabel}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
className={tenantHeroSecondaryButtonClass}
onClick={() => window.location.assign('/dashboard')}
>
{marketingDashboardLabel}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
);
const heroAside = (
const heroSupporting = singleEvent
? [
translate('overview.eventHero.supporting.status', 'Status: {{status}}', {
status: formatEventStatus(singleEvent.status ?? null, tc),
}),
singleEventDateLabel
? translate('overview.eventHero.supporting.date', 'Eventdatum: {{date}}', { date: singleEventDateLabel })
: translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt.'),
].filter(Boolean)
: [heroSupportingCopy];
const heroPrimaryAction = (() => {
if (onboardingCompletion < 100) {
return (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => {
if (readiness.hasEvent) {
navigate(ADMIN_EVENTS_PATH);
} else {
navigate(ADMIN_EVENT_CREATE_PATH);
}
}}
>
{translate('onboarding.hero.cta', 'Setup fortsetzen')}
</Button>
);
}
if (singleEvent?.slug) {
return (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug))}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
);
}
if (readiness.hasEvent) {
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENTS_PATH)}>
{translate('quickActions.moderatePhotos.label', 'Fotos moderieren')}
</Button>
);
}
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}>
{translate('actions.newEvent')}
</Button>
);
})();
const heroAside = onboardingCompletion < 100 ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
<span>{onboardingCardTitle}</span>
@@ -480,9 +538,44 @@ export default function DashboardPage() {
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
</FrostedSurface>
);
) : singleEvent ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="space-y-3 text-sm">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500">
{translate('overview.eventHero.stats.title', 'Momentaufnahme')}
</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{formatEventStatus(singleEvent.status ?? null, tc)}
</p>
</div>
<dl className="space-y-2">
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.date', 'Eventdatum')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Nicht gesetzt')}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.uploads', 'Uploads gesamt')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.photo_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.tasks', 'Offene Aufgaben')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.tasks_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
</dl>
</div>
</FrostedSurface>
) : null;
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const hasEventContext = readiness.hasEvent;
const quickActionItems = React.useMemo(
() => [
{
@@ -498,6 +591,7 @@ export default function DashboardPage() {
description: translate('quickActions.moderatePhotos.description'),
icon: <Camera className="h-5 w-5" />,
onClick: () => navigate(ADMIN_EVENTS_PATH),
disabled: !hasEventContext,
},
{
key: 'tasks',
@@ -505,6 +599,7 @@ export default function DashboardPage() {
description: translate('quickActions.organiseTasks.description'),
icon: <ClipboardList className="h-5 w-5" />,
onClick: () => navigate(buildEngagementTabPath('tasks')),
disabled: !hasEventContext,
},
{
key: 'packages',
@@ -513,18 +608,24 @@ export default function DashboardPage() {
icon: <Sparkles className="h-5 w-5" />,
onClick: () => navigate(ADMIN_BILLING_PATH),
},
{
key: 'marketing',
label: marketingDashboardLabel,
description: marketingDashboardDescription,
icon: <ArrowUpRight className="h-5 w-5" />,
onClick: () => window.location.assign('/dashboard'),
},
],
[translate, navigate, marketingDashboardLabel, marketingDashboardDescription],
[translate, navigate, hasEventContext],
);
const layoutActions = (
const layoutActions = singleEvent ? (
<Button
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => {
if (singleEvent.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug));
} else {
navigate(ADMIN_EVENTS_PATH);
}
}}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
) : (
<Button
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
@@ -533,8 +634,29 @@ export default function DashboardPage() {
</Button>
);
const adminTitle = singleEventName ?? greetingTitle;
const adminSubtitle = singleEvent
? translate('overview.eventHero.subtitle', 'Alle Funktionen konzentrieren sich auf dieses Event.', {
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
})
: subtitle;
const heroTitle = adminTitle;
const liveNowTitle = t('liveNow.title', { defaultValue: 'Während des Events' });
const liveNowDescription = t('liveNow.description', {
defaultValue: 'Direkter Zugriff, solange dein Event läuft.',
count: liveEvents.length,
});
const liveActionLabels = React.useMemo(() => ({
photos: t('liveNow.actions.photos', { defaultValue: 'Uploads' }),
invites: t('liveNow.actions.invites', { defaultValue: 'QR & Einladungen' }),
tasks: t('liveNow.actions.tasks', { defaultValue: 'Aufgaben' }),
}), [t]);
const liveStatusLabel = t('liveNow.status', { defaultValue: 'Live' });
const liveNoDate = t('liveNow.noDate', { defaultValue: 'Kein Datum' });
return (
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={layoutActions}>
<AdminLayout title={adminTitle} subtitle={adminSubtitle} actions={layoutActions}>
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
@@ -548,14 +670,74 @@ export default function DashboardPage() {
<>
<TenantHeroCard
badge={heroBadge}
title={greetingTitle}
title={heroTitle}
description={heroDescription}
supporting={[heroSupportingCopy]}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{liveEvents.length > 0 && (
<Card className="border border-rose-200 bg-rose-50/80 shadow-lg shadow-rose-200/40">
<CardHeader className="space-y-1">
<CardTitle className="text-base font-semibold text-rose-900">{liveNowTitle}</CardTitle>
<CardDescription className="text-sm text-rose-700">{liveNowDescription}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{liveEvents.map((event) => {
const name = resolveEventName(event.name, event.slug);
const dateLabel = event.event_date ? formatDate(event.event_date, dateLocale) : liveNoDate;
return (
<div
key={event.id}
className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm shadow-rose-100/50"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{name}</p>
<p className="text-xs text-slate-500">{dateLabel}</p>
</div>
<Badge className="bg-rose-600/90 text-white">{liveStatusLabel}</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
>
<Camera className="h-4 w-4" />
{liveActionLabels.photos}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
>
<QrCode className="h-4 w-4" />
{liveActionLabels.invites}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
>
<ClipboardList className="h-4 w-4" />
{liveActionLabels.tasks}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
{events.length === 0 && (
<Card className="border-none bg-white/90 shadow-lg shadow-rose-100/50">
<CardHeader className="space-y-2">
@@ -752,6 +934,17 @@ function formatDate(value: string | null, locale: string): string | null {
}
}
function formatEventStatus(status: TenantEvent['status'] | null, translateFn: (key: string, options?: Record<string, unknown>) => string): string {
const map: Record<string, { key: string; fallback: string }> = {
published: { key: 'events.status.published', fallback: 'Veröffentlicht' },
draft: { key: 'events.status.draft', fallback: 'Entwurf' },
archived: { key: 'events.status.archived', fallback: 'Archiviert' },
};
const target = map[status ?? 'draft'] ?? map.draft;
return translateFn(target.key, { defaultValue: target.fallback });
}
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name;
@@ -914,29 +1107,6 @@ function GalleryStatusRow({
);
}
function StatCard({
label,
value,
hint,
icon,
}: {
label: string;
value: string | number;
hint?: string;
icon: React.ReactNode;
}) {
return (
<FrostedSurface className="border-brand-rose-soft/40 p-5 shadow-md shadow-pink-100/30 transition-transform duration-200 ease-out hover:-translate-y-0.5 hover:shadow-lg hover:shadow-rose-300/30">
<div className="flex items-center justify-between">
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
</div>
<div className="mt-4 text-2xl font-semibold text-slate-900 dark:text-slate-100">{value}</div>
{hint && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{hint}</p>}
</FrostedSurface>
);
}
function UpcomingEventRow({
event,
onView,

View File

@@ -6,9 +6,7 @@ import {
ArrowLeft,
Camera,
CheckCircle2,
ChevronRight,
Circle,
Download,
Loader2,
MessageSquare,
Printer,
@@ -23,8 +21,6 @@ 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import {
EventToolkit,
@@ -54,6 +50,7 @@ import {
SectionCard,
SectionHeader,
ActionGrid,
TenantHeroCard,
} from '../components/tenant';
type EventDetailPageProps = {
@@ -175,48 +172,6 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const actions = (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
</Button>
{event && (
<>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Users className="h-4 w-4" /> {t('events.actions.members', 'Team & Rollen')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.tasks', 'Aufgaben verwalten')}
</Button>
<Button variant="outline" onClick={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<QrCode className="h-4 w-4" /> {t('events.actions.invites', 'Einladungen & Layouts')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Camera className="h-4 w-4" /> {t('events.actions.photos', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</>
)}
</div>
);
if (!slug) {
return (
<AdminLayout title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')} actions={actions}>
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</p>
</SectionCard>
</AdminLayout>
);
}
const limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
@@ -240,8 +195,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
});
}, [limitWarnings]);
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.')}
>
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</p>
</SectionCard>
</AdminLayout>
);
}
return (
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
<AdminLayout title={eventName} subtitle={subtitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
@@ -276,6 +246,14 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
<WorkspaceSkeleton />
) : event ? (
<div className="space-y-6">
<EventHeroCardSection
event={event}
stats={stats}
onRefresh={() => { void load(); }}
loading={state.busy}
navigate={navigate}
/>
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
@@ -332,14 +310,82 @@ function resolveName(name: TenantEvent['name']): string {
return 'Event';
}
function EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: {
event: TenantEvent;
stats: EventStats | null;
onRefresh: () => void;
loading: boolean;
navigate: ReturnType<typeof useNavigate>;
}) {
const { t } = useTranslation('management');
const statusLabel = getStatusLabel(event, t);
const supporting = [
t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }),
t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }),
t('events.workspace.hero.metrics', {
defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}',
count: stats?.uploads_total ?? stats?.total ?? 0,
likes: stats?.likes_total ?? stats?.likes ?? 0,
}),
];
const aside = (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
<InfoRow
icon={<Sparkles className="h-4 w-4 text-pink-500" />}
label={t('events.workspace.fields.status', 'Status')}
value={statusLabel}
/>
<InfoRow
icon={<CalendarIcon />}
label={t('events.workspace.fields.date', 'Eventdatum')}
value={formatDate(event.event_date)}
/>
<InfoRow
icon={<Users className="h-4 w-4 text-sky-500" />}
label={t('events.workspace.fields.active', 'Aktiv für Gäste')}
value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
/>
</div>
);
return (
<TenantHeroCard
badge={t('events.workspace.hero.badge', 'Event')}
title={resolveName(event.name)}
description={t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
supporting={supporting}
primaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} 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>
)}
secondaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} 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>
)}
aside={aside}
>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
className="rounded-full border-slate-200"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</div>
</TenantHeroCard>
);
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management');
const statusLabel = event.status === 'published'
? t('events.status.published', 'Veröffentlicht')
: event.status === 'draft'
? t('events.status.draft', 'Entwurf')
: t('events.status.archived', 'Archiviert');
const statusLabel = getStatusLabel(event, t);
return (
<SectionCard className="space-y-4">
@@ -839,6 +885,16 @@ function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string;
);
}
function getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
if (event.status === 'published') {
return t('events.status.published', 'Veröffentlicht');
}
if (event.status === 'archived') {
return t('events.status.archived', 'Archiviert');
}
return t('events.status.draft', 'Entwurf');
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);

View File

@@ -67,7 +67,17 @@ export default function EventTasksPage() {
setEvent(eventData);
const assignedIds = new Set(eventTasksResponse.data.map((task) => task.id));
setAssignedTasks(eventTasksResponse.data);
setAvailableTasks(libraryTasks.data.filter((task) => !assignedIds.has(task.id)));
const eventTypeId = eventData.event_type_id ?? null;
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
if (assignedIds.has(task.id)) {
return false;
}
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
return false;
}
return true;
});
setAvailableTasks(filteredLibraryTasks);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
@@ -104,6 +114,10 @@ export default function EventTasksPage() {
}
}
React.useEffect(() => {
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
}, [availableTasks]);
const isPhotoOnlyMode = event?.engagement_mode === 'photo_only';
async function handleModeChange(checked: boolean) {

View File

@@ -24,6 +24,7 @@ import { getApiErrorMessage } from '../lib/apiError';
import {
adminPath,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
@@ -69,6 +70,25 @@ export default function EventsPage() {
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
[tCommon],
);
const totalEvents = rows.length;
const publishedEvents = React.useMemo(
() => rows.filter((event) => event.status === 'published').length,
[rows],
);
const nextEvent = React.useMemo(() => {
return (
rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null
);
}, [rows]);
const statItems = React.useMemo(
() => [
{
@@ -125,18 +145,6 @@ export default function EventsPage() {
'events.list.subtitle',
'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.'
);
const totalEvents = rows.length;
const publishedEvents = React.useMemo(() => rows.filter((event) => event.status === 'published').length, [rows]);
const nextEvent = React.useMemo(() => {
return rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null;
}, [rows]);
const heroDescription = t(
'events.list.hero.description',
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AdminLayout } from '../components/AdminLayout';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export default function FaqPage() {
const { t } = useTranslation('dashboard');
const entries = [
{
question: t('faq.events.question', 'Wie arbeite ich mit Events?'),
answer: t(
'faq.events.answer',
'Wähle dein aktives Event, passe Aufgaben an und lade Gäste über die Einladungsseite ein. Weitere Dokumentation folgt bald.'
),
},
{
question: t('faq.uploads.question', 'Wie moderiere ich Uploads?'),
answer: t(
'faq.uploads.answer',
'Sobald Fotos eintreffen, findest du sie in der Galerie-Ansicht deines Events. Von dort kannst du sie freigeben oder zurückweisen.'
),
},
{
question: t('faq.support.question', 'Wo erhalte ich Support?'),
answer: t(
'faq.support.answer',
'Dieses FAQ dient als Platzhalter. Bitte nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank veröffentlicht wird.'
),
},
];
return (
<AdminLayout
title={t('faq.title', 'FAQ & Hilfe')}
subtitle={t('faq.subtitle', 'Antworten und Hinweise rund um den Tenant Admin.')}
>
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{t('faq.intro.title', 'Was dich erwartet')}</CardTitle>
<CardDescription>
{t(
'faq.intro.description',
'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.'
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{entries.map((entry) => (
<div key={entry.question} className="rounded-2xl border border-slate-200/80 p-4 dark:border-white/10">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{entry.question}</p>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p>
</div>
))}
<div className="rounded-2xl bg-rose-50/70 p-4 text-sm text-rose-900 dark:bg-rose-200/10 dark:text-rose-100">
<p className="font-semibold">
{t('faq.cta.needHelp', 'Fehlt dir etwas?')}
</p>
<p className="mt-1 text-sm">
{t(
'faq.cta.description',
'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail wir erweitern dieses FAQ mit deinen Themen.'
)}
</p>
<Button
size="sm"
variant="secondary"
className="mt-3 rounded-full"
onClick={() => window.open('mailto:hello@fotospiel.app', '_blank')}
>
{t('faq.cta.contact', 'Support kontaktieren')}
</Button>
</div>
</CardContent>
</Card>
</AdminLayout>
);
}

View File

@@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next';
import {
AlignLeft,
BadgeCheck,
ChevronDown,
Download,
Heading,
Link as LinkIcon,
Loader2,
Megaphone,
Minus,
Plus,
Printer,
QrCode,
@@ -27,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
@@ -241,6 +244,7 @@ export function InviteLayoutCustomizerPanel({
const [zoomScale, setZoomScale] = React.useState(1);
const [fitScale, setFitScale] = React.useState(1);
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
const [isCompact, setIsCompact] = React.useState(false);
const fitScaleRef = React.useRef(1);
const manualZoomRef = React.useRef(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
@@ -252,6 +256,7 @@ export function InviteLayoutCustomizerPanel({
const designerViewportRef = React.useRef<HTMLDivElement | null>(null);
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
const draftSignatureRef = React.useRef<string | null>(null);
const initialElementsRef = React.useRef<LayoutElement[]>([]);
const activeCustomization = React.useMemo(
() => draftCustomization ?? initialCustomization ?? null,
[draftCustomization, initialCustomization],
@@ -264,6 +269,34 @@ export function InviteLayoutCustomizerPanel({
const appliedLayoutRef = React.useRef<string | null>(null);
const appliedInviteRef = React.useRef<number | string | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
setIsCompact(false);
return;
}
const query = window.matchMedia('(max-width: 1023px)');
const update = (event?: MediaQueryListEvent) => {
if (typeof event?.matches === 'boolean') {
setIsCompact(event.matches);
return;
}
setIsCompact(query.matches);
};
update();
if (typeof query.addEventListener === 'function') {
const listener = (event: MediaQueryListEvent) => update(event);
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
}
const legacyListener = (event: MediaQueryListEvent) => update(event);
query.addListener(legacyListener);
return () => query.removeListener(legacyListener);
}, []);
const clampZoom = React.useCallback(
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
[],
@@ -410,7 +443,8 @@ export function InviteLayoutCustomizerPanel({
const commitElements = React.useCallback(
(producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => {
setElements((prev) => {
const base = cloneElements(prev);
const source = prev.length ? prev : initialElementsRef.current;
const base = cloneElements(source.length ? source : []);
const produced = producer(base);
const normalized = normalizeElements(produced);
if (elementsAreEqual(prev, normalized)) {
@@ -514,6 +548,14 @@ export function InviteLayoutCustomizerPanel({
}, [clampZoom, zoomScale, fitScale, previewMode]);
const zoomPercent = Math.round(effectiveScale * 100);
const handleZoomStep = React.useCallback(
(direction: 1 | -1) => {
manualZoomRef.current = true;
setZoomScale((current) => clampZoom(current + direction * ZOOM_STEP));
},
[clampZoom]
);
const updateElement = React.useCallback(
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
commitElements(
@@ -646,6 +688,7 @@ export function InviteLayoutCustomizerPanel({
setInstructions([]);
commitElements(() => [], { silent: true });
resetHistory([]);
initialElementsRef.current = [];
appliedSignatureRef.current = null;
appliedLayoutRef.current = layoutId;
appliedInviteRef.current = inviteKey;
@@ -723,12 +766,15 @@ export function InviteLayoutCustomizerPanel({
if (isCustomizedAdvanced) {
const initialElements = normalizeElements(payloadToElements(newForm.elements));
initialElementsRef.current = initialElements;
commitElements(() => initialElements, { silent: true });
resetHistory(initialElements);
} else {
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
const normalizedDefaults = normalizeElements(defaults);
initialElementsRef.current = normalizedDefaults;
commitElements(() => normalizedDefaults, { silent: true });
resetHistory(normalizedDefaults);
}
appliedSignatureRef.current = incomingSignature ?? null;
@@ -1515,6 +1561,38 @@ export function InviteLayoutCustomizerPanel({
const highlightedElementId = activeElementId ?? inspectorElementId;
const renderResponsiveSection = React.useCallback(
(id: string, title: string, description: string, content: React.ReactNode) => {
const body = <div className="space-y-4">{content}</div>;
if (!isCompact) {
return (
<section key={id} className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</header>
{body}
</section>
);
}
return (
<Collapsible key={id} defaultOpen className="rounded-2xl border border-border bg-[var(--tenant-surface)] p-3 shadow-sm transition-colors">
<CollapsibleTrigger type="button" className="flex w-full items-center justify-between gap-3 text-left">
<div className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">{body}</CollapsibleContent>
</Collapsible>
);
},
[isCompact]
);
return (
<div className="space-y-4">
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
@@ -1525,63 +1603,58 @@ export function InviteLayoutCustomizerPanel({
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
<p className="text-xs text-muted-foreground">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
</header>
<div className="flex flex-col gap-6 xl:grid xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<form ref={formRef} onSubmit={handleSubmit} className={cn('order-2 space-y-6', 'xl:order-1')}>
{renderResponsiveSection(
'layouts',
t('invites.customizer.sections.layouts', 'Layouts'),
t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.'),
<>
<Select
value={activeLayout?.id ?? undefined}
onValueChange={(value) => {
const layout = availableLayouts.find((item) => item.id === value);
if (layout) {
handleLayoutSelect(layout);
}
}}
disabled={!availableLayouts.length}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{availableLayouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
<div className="flex w-full flex-col gap-1 text-left">
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
{layout.formats?.length ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
</span>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={activeLayout?.id ?? undefined}
onValueChange={(value) => {
const layout = availableLayouts.find((item) => item.id === value);
if (layout) {
handleLayoutSelect(layout);
}
}}
disabled={!availableLayouts.length}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{availableLayouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
<div className="flex w-full flex-col gap-1 text-left">
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
{layout.formats?.length ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
</span>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{activeLayout ? (
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
<p className="font-medium text-foreground">{activeLayout.name}</p>
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
</div>
) : null}
</section>
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('invites.customizer.elements.title', 'Elemente & Positionierung')}
</h3>
<p className="text-xs text-muted-foreground">
{t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.')}
</p>
</header>
{activeLayout ? (
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
<p className="font-medium text-foreground">{activeLayout.name}</p>
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
</div>
) : null}
</>
)}
{renderResponsiveSection(
'elements',
t('invites.customizer.elements.title', 'Elemente & Positionierung'),
t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.'),
<>
<div className="space-y-2">
{sortedElements.map((element) => {
const Icon = elementIconFor(element);
@@ -1652,16 +1725,20 @@ export function InviteLayoutCustomizerPanel({
{t('invites.customizer.elements.listHint', 'Wähle ein Element aus, um Einstellungen direkt unter dem Eintrag anzuzeigen.')}
</p>
</div>
</section>
</>
)}
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
{renderResponsiveSection(
'content',
t('invites.customizer.sections.content', 'Texte & Branding'),
t('invites.customizer.sections.contentHint', 'Passe Texte, Anleitungsschritte und Farben deiner Einladung an.'),
<Tabs defaultValue="text" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-3 gap-1 text-xs sm:text-sm">
<TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</TabsTrigger>
<TabsTrigger value="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger>
<TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger>
</TabsList>
<TabsContent value="text" className="space-y-4">
<div className="grid gap-4">
<div className="space-y-2">
@@ -1825,34 +1902,60 @@ export function InviteLayoutCustomizerPanel({
</div>
</TabsContent>
</Tabs>
</section>
)}
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
{renderActionButtons('inline')}
</div>
<div ref={actionsSentinelRef} className="h-1 w-full" />
</form>
<div className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<div className={cn('order-1 flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors', 'xl:order-2')}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">
{t('invites.customizer.controls.zoom', 'Zoom')}
</span>
<input
type="range"
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={effectiveScale}
onChange={(event) => {
manualZoomRef.current = true;
setZoomScale(clampZoom(Number(event.target.value)));
}}
className="h-1 w-36 overflow-hidden rounded-full"
disabled={previewMode === 'full'}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
{!isCompact ? (
<>
<input
type="range"
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={effectiveScale}
onChange={(event) => {
manualZoomRef.current = true;
setZoomScale(clampZoom(Number(event.target.value)));
}}
className="h-1 w-36 overflow-hidden rounded-full"
disabled={previewMode === 'full'}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
</>
) : (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleZoomStep(-1)}
aria-label={t('invites.customizer.controls.zoomOut', 'Verkleinern')}
>
<Minus className="h-4 w-4" />
</Button>
<span className="w-12 text-center text-xs font-medium tabular-nums text-muted-foreground">{zoomPercent}%</span>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleZoomStep(1)}
aria-label={t('invites.customizer.controls.zoomIn', 'Vergrößern')}
>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
<ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex">
<ToggleGroupItem value="fit" className="px-2 text-xs">
Fit
@@ -1861,20 +1964,37 @@ export function InviteLayoutCustomizerPanel({
100%
</ToggleGroupItem>
</ToggleGroup>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
setPreviewMode('fit');
}}
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
>
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
{!isCompact ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
setPreviewMode('fit');
}}
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
>
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
setPreviewMode('fit');
}}
aria-label={t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button

View File

@@ -312,11 +312,29 @@ export function DesignerCanvas({
canvas.on('selection:cleared', handleSelectionCleared);
canvas.on('object:modified', handleObjectModified);
const handleEditingExited = (event: { target?: FabricObjectWithId & { text?: string } }) => {
if (readOnly) {
return;
}
const target = event?.target;
if (!target || typeof target.elementId !== 'string') {
return;
}
const updatedText = typeof (target as fabric.Textbox).text === 'string' ? (target as fabric.Textbox).text : target.text ?? '';
handleObjectModified({ target });
onChange(target.elementId, { content: updatedText });
canvas.requestRenderAll();
};
canvas.on('editing:exited', handleEditingExited);
return () => {
canvas.off('selection:created', handleSelection);
canvas.off('selection:updated', handleSelection);
canvas.off('selection:cleared', handleSelectionCleared);
canvas.off('object:modified', handleObjectModified);
canvas.off('editing:exited', handleEditingExited);
};
}, [onChange, onSelect, readOnly]);

View File

@@ -25,6 +25,7 @@ const BillingPage = React.lazy(() => import('./pages/BillingPage'));
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
const TaskCollectionsPage = React.lazy(() => import('./pages/TaskCollectionsPage'));
const EmotionsPage = React.lazy(() => import('./pages/EmotionsPage'));
const FaqPage = React.lazy(() => import('./pages/FaqPage'));
const AuthCallbackPage = React.lazy(() => import('./pages/AuthCallbackPage'));
const WelcomeTeaserPage = React.lazy(() => import('./pages/WelcomeTeaserPage'));
const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage'));
@@ -101,6 +102,7 @@ export const router = createBrowserRouter([
{ path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'faq', element: <FaqPage /> },
{ path: 'settings/profile', element: <ProfilePage /> },
{ path: 'welcome', element: <WelcomeLandingPage /> },
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },