401 lines
16 KiB
TypeScript
401 lines
16 KiB
TypeScript
import React from 'react';
|
|
import { Link, NavLink, useLocation } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { LayoutDashboard, CalendarDays, Settings } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
import { cn } from '@/lib/utils';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
} from '@/components/ui/sheet';
|
|
import {
|
|
ADMIN_HOME_PATH,
|
|
ADMIN_EVENTS_PATH,
|
|
ADMIN_EVENT_VIEW_PATH,
|
|
ADMIN_EVENT_PHOTOS_PATH,
|
|
ADMIN_SETTINGS_PATH,
|
|
ADMIN_BILLING_PATH,
|
|
} from '../constants';
|
|
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';
|
|
import { useAuth } from '../auth/context';
|
|
import { CommandShelf } from './CommandShelf';
|
|
|
|
type NavItem = {
|
|
key: string;
|
|
to: string;
|
|
label: string;
|
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
end?: boolean;
|
|
highlight?: boolean;
|
|
prefetchKey?: string;
|
|
};
|
|
|
|
type PageTab = {
|
|
key: string;
|
|
label: string;
|
|
href: string;
|
|
badge?: React.ReactNode;
|
|
};
|
|
|
|
interface AdminLayoutProps {
|
|
title: string;
|
|
subtitle?: string;
|
|
actions?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
disableCommandShelf?: boolean;
|
|
tabs?: PageTab[];
|
|
currentTabKey?: string;
|
|
}
|
|
|
|
export function AdminLayout({ title, subtitle, actions, children, disableCommandShelf, tabs, currentTabKey }: 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 billingLabel = t('navigation.billing', { defaultValue: 'Paket' });
|
|
|
|
const baseNavItems = 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: 'billing',
|
|
to: ADMIN_BILLING_PATH,
|
|
label: billingLabel,
|
|
icon: Settings,
|
|
prefetchKey: ADMIN_BILLING_PATH,
|
|
},
|
|
], [eventsLabel, eventsPath, billingLabel, singleEvent, events.length, t]);
|
|
|
|
const { user } = useAuth();
|
|
const isMember = user?.role === 'member';
|
|
|
|
const navItems = React.useMemo(
|
|
() => baseNavItems.filter((item) => {
|
|
if (!isMember) {
|
|
return true;
|
|
}
|
|
return !['dashboard', 'billing'].includes(item.key);
|
|
}),
|
|
[baseNavItems, isMember],
|
|
);
|
|
|
|
const prefetchers = React.useMemo(() => ({
|
|
[ADMIN_HOME_PATH]: () =>
|
|
Promise.all([
|
|
getDashboardSummary(),
|
|
getEvents(),
|
|
getTenantPackagesOverview(),
|
|
]).then(() => undefined),
|
|
[ADMIN_EVENTS_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-gradient-to-br from-rose-50 via-white to-slate-50 text-slate-900 dark:bg-slate-950 dark:text-white">
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_10%_10%,rgba(255,137,170,0.25),transparent_55%),radial-gradient(circle_at_80%_0%,rgba(96,165,250,0.22),transparent_60%)] opacity-60 dark:opacity-30"
|
|
/>
|
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-b from-white/80 via-white/60 to-transparent dark:from-slate-950 dark:via-slate-950/90 dark:to-slate-950/80" />
|
|
<div className="relative z-10 flex min-h-svh flex-col">
|
|
<header className="sticky top-0 z-40 border-b border-slate-200/70 bg-white/90 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/80">
|
|
<div className="mx-auto grid w-full max-w-6xl grid-cols-[1fr_auto] items-start gap-3 px-4 py-4 sm:px-6">
|
|
<div className="min-w-0 space-y-1">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.4em] text-rose-500 dark:text-rose-200">{t('app.brand')}</p>
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-slate-900 dark:text-white sm:text-2xl">{title}</h1>
|
|
{subtitle ? <p className="text-xs text-slate-600 dark:text-slate-300 sm:text-sm">{subtitle}</p> : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
{disableCommandShelf ? <EventSwitcher compact /> : null}
|
|
{actions}
|
|
<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((item) => (
|
|
<NavLink
|
|
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'
|
|
: 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'
|
|
)
|
|
)
|
|
}
|
|
>
|
|
<item.icon className="h-4 w-4" />
|
|
{item.label}
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
{disableCommandShelf ? <EventMenuBar /> : <CommandShelf />}
|
|
{tabs && tabs.length ? <PageTabsNav tabs={tabs} currentKey={currentTabKey} /> : null}
|
|
</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">
|
|
<div className="space-y-5">{children}</div>
|
|
</main>
|
|
|
|
<TenantMobileNav items={navItems} onPrefetch={triggerPrefetch} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: string }) {
|
|
const location = useLocation();
|
|
const { t } = useTranslation('common');
|
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
|
|
|
const isActive = (tab: PageTab): boolean => {
|
|
if (currentKey) {
|
|
return tab.key === currentKey;
|
|
}
|
|
return location.pathname === tab.href || location.pathname.startsWith(tab.href);
|
|
};
|
|
|
|
const activeTab = React.useMemo(() => tabs.find((tab) => isActive(tab)), [tabs, location.pathname, currentKey]);
|
|
|
|
const handleTabClick = React.useCallback(
|
|
(tab: PageTab) => {
|
|
setMobileOpen(false);
|
|
const [path, hash] = tab.href.split('#');
|
|
if (location.pathname === path && hash) {
|
|
window.location.hash = `#${hash}`;
|
|
}
|
|
},
|
|
[location.pathname],
|
|
);
|
|
|
|
return (
|
|
<div className="border-t border-slate-200/70 bg-white/80 backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-2 px-4 py-2 sm:px-6">
|
|
<div className="hidden gap-2 md:flex">
|
|
{tabs.map((tab) => {
|
|
const active = isActive(tab);
|
|
return (
|
|
<Link
|
|
key={tab.key}
|
|
to={tab.href}
|
|
onClick={() => handleTabClick(tab)}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
|
|
active
|
|
? 'bg-rose-600 text-white shadow shadow-rose-300/40'
|
|
: 'bg-white text-slate-600 hover:text-slate-900 dark:bg-white/5 dark:text-slate-300 dark:hover:text-white'
|
|
)}
|
|
>
|
|
<span>{tab.label}</span>
|
|
{tab.badge !== undefined ? (
|
|
<Badge
|
|
variant={active ? 'secondary' : 'outline'}
|
|
className={cn(
|
|
active ? 'bg-white/20 text-white' : 'text-slate-600 dark:text-slate-300',
|
|
'rounded-full text-[11px]'
|
|
)}
|
|
>
|
|
{tab.badge}
|
|
</Badge>
|
|
) : null}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="md:hidden">
|
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
<SheetTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-2xl border border-slate-200/70 bg-white px-3 py-2 text-left text-sm font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-white/10 dark:text-slate-200"
|
|
>
|
|
<span>{activeTab?.label ?? t('navigation.tabs.active')}</span>
|
|
<span className="text-xs uppercase tracking-[0.3em] text-rose-500">{t('navigation.tabs.open')}</span>
|
|
</button>
|
|
</SheetTrigger>
|
|
<SheetContent
|
|
side="bottom"
|
|
className="rounded-t-3xl border-t border-slate-200/70 bg-white/95 pb-6 pt-6 dark:border-white/10 dark:bg-slate-950/95"
|
|
>
|
|
<SheetHeader className="px-4 pt-0 text-left">
|
|
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
|
|
{t('navigation.tabs.title')}
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
{t('navigation.tabs.subtitle')}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="mt-4 grid gap-2 px-4">
|
|
{tabs.map((tab) => {
|
|
const active = isActive(tab);
|
|
return (
|
|
<Link
|
|
key={`sheet-${tab.key}`}
|
|
to={tab.href}
|
|
onClick={() => {
|
|
handleTabClick(tab);
|
|
setMobileOpen(false);
|
|
}}
|
|
className={cn(
|
|
'flex items-center justify-between rounded-2xl border px-4 py-3 text-sm font-medium shadow-sm transition',
|
|
active
|
|
? 'border-rose-200 bg-rose-50 text-rose-700'
|
|
: 'border-slate-200 bg-white text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200'
|
|
)}
|
|
>
|
|
<span>{tab.label}</span>
|
|
{tab.badge !== undefined ? (
|
|
<Badge
|
|
variant={active ? 'secondary' : 'outline'}
|
|
className={cn(active ? 'bg-white/30 text-rose-700' : 'text-slate-600 dark:text-slate-200', 'rounded-full text-[11px]')}
|
|
>
|
|
{tab.badge}
|
|
</Badge>
|
|
) : null}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TenantMobileNav({
|
|
items,
|
|
onPrefetch,
|
|
}: {
|
|
items: NavItem[];
|
|
onPrefetch: (path: string) => void;
|
|
}) {
|
|
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-12 bg-gradient-to-t from-white via-white/40 to-transparent dark:from-slate-950 dark:via-slate-950/60 dark:to-transparent"
|
|
/>
|
|
<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((item) => (
|
|
<NavLink
|
|
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'
|
|
: 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'
|
|
)
|
|
)
|
|
}
|
|
>
|
|
<item.icon className="h-5 w-5" />
|
|
<span>{item.label}</span>
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|