189 lines
7.5 KiB
TypeScript
189 lines
7.5 KiB
TypeScript
import React from 'react';
|
|
import { NavLink } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { cn } from '@/lib/utils';
|
|
import toast from 'react-hot-toast';
|
|
import {
|
|
ADMIN_HOME_PATH,
|
|
ADMIN_EVENTS_PATH,
|
|
ADMIN_SETTINGS_PATH,
|
|
ADMIN_BILLING_PATH,
|
|
ADMIN_ENGAGEMENT_PATH,
|
|
} from '../constants';
|
|
import {
|
|
LayoutDashboard,
|
|
CalendarDays,
|
|
Sparkles,
|
|
CreditCard,
|
|
Settings as SettingsIcon,
|
|
} from 'lucide-react';
|
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
import { registerApiErrorListener } from '../lib/apiError';
|
|
import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api';
|
|
|
|
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 },
|
|
];
|
|
|
|
interface AdminLayoutProps {
|
|
title: string;
|
|
subtitle?: string;
|
|
actions?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
|
|
const { t } = useTranslation('common');
|
|
const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
|
|
|
|
const prefetchers = React.useMemo(() => ({
|
|
[ADMIN_HOME_PATH]: () =>
|
|
Promise.all([
|
|
getDashboardSummary(),
|
|
getEvents(),
|
|
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(),
|
|
}), []);
|
|
|
|
const triggerPrefetch = React.useCallback(
|
|
(path: string) => {
|
|
if (prefetchedPathsRef.current.has(path)) {
|
|
return;
|
|
}
|
|
|
|
const runner = prefetchers[path as keyof typeof prefetchers];
|
|
if (!runner) {
|
|
return;
|
|
}
|
|
|
|
prefetchedPathsRef.current.add(path);
|
|
Promise.resolve(runner()).catch(() => {
|
|
prefetchedPathsRef.current.delete(path);
|
|
});
|
|
},
|
|
[prefetchers],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
document.body.classList.add('tenant-admin-theme');
|
|
return () => {
|
|
document.body.classList.remove('tenant-admin-theme');
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const unsubscribe = registerApiErrorListener((detail) => {
|
|
const fallback = t('errors.generic');
|
|
const message = detail?.message?.trim() ? detail.message : fallback;
|
|
toast.error(message, {
|
|
id: detail?.code ? `api-error-${detail.code}` : undefined,
|
|
});
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [t]);
|
|
|
|
return (
|
|
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.28),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_65%)]"
|
|
/>
|
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/60 to-[#1d1130]" />
|
|
<div className="relative z-10 flex min-h-svh flex-col">
|
|
<header className="sticky top-0 z-30 px-4 pt-6 sm:px-6">
|
|
<div className="mx-auto w-full max-w-6xl rounded-3xl border border-white/15 bg-white/85 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl">
|
|
<div className="flex flex-col gap-6 px-6 py-6 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.35em] text-rose-400">{t('app.brand')}</p>
|
|
<h1 className="font-display text-3xl font-semibold text-slate-900">{title}</h1>
|
|
{subtitle ? <p className="mt-1 text-sm text-slate-600">{subtitle}</p> : null}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<LanguageSwitcher />
|
|
{actions}
|
|
</div>
|
|
</div>
|
|
<nav className="hidden items-center gap-2 border-t border-slate-200/70 px-6 py-4 md:flex">
|
|
{navItems.map(({ to, labelKey, icon: Icon, end }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
end={end}
|
|
onPointerEnter={() => triggerPrefetch(to)}
|
|
onFocus={() => triggerPrefetch(to)}
|
|
onTouchStart={() => triggerPrefetch(to)}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950',
|
|
isActive
|
|
? 'bg-rose-600 text-white shadow-md shadow-rose-400/30'
|
|
: 'border border-slate-200/80 bg-white text-slate-700 hover:bg-rose-50/80 hover:text-rose-700'
|
|
)
|
|
}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{t(labelKey)}
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+6.5rem)] pt-6 sm:px-6 md:pb-16">
|
|
<div className="grid gap-6">{children}</div>
|
|
</main>
|
|
|
|
<TenantMobileNav items={navItems} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TenantMobileNav({ items }: { items: typeof navItems }) {
|
|
const { t } = useTranslation('common');
|
|
|
|
return (
|
|
<nav className="md:hidden" aria-label={t('navigation.mobile', { defaultValue: 'Tenant Navigation' })}>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none fixed inset-x-0 bottom-0 z-30 h-16 bg-gradient-to-t from-slate-950/35 via-transparent to-transparent dark:from-black/60"
|
|
/>
|
|
<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 }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
end={end}
|
|
onPointerEnter={() => triggerPrefetch(to)}
|
|
onFocus={() => triggerPrefetch(to)}
|
|
onTouchStart={() => triggerPrefetch(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'
|
|
)
|
|
}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
<span>{t(labelKey)}</span>
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|