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

@@ -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>
);
}