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:
@@ -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>
|
||||
|
||||
225
resources/js/admin/components/EventNav.tsx
Normal file
225
resources/js/admin/components/EventNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
306
resources/js/admin/components/NotificationCenter.tsx
Normal file
306
resources/js/admin/components/NotificationCenter.tsx
Normal 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;
|
||||
}
|
||||
161
resources/js/admin/components/UserMenu.tsx
Normal file
161
resources/js/admin/components/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user