Files
Project_Velocity/app/src/App.tsx

390 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useStore } from '@/store/useStore';
import { Sidebar } from '@/components/layout/Sidebar';
import { LoginScreen } from '@/components/layout/LoginScreen';
import { Dashboard } from '@/components/modules/Dashboard';
import { Oracle } from '@/components/modules/Oracle';
import { Sentinel } from '@/components/modules/Sentinel';
import { Inventory } from '@/components/modules/Inventory';
import { Settings } from '@/components/modules/Settings';
import { Catalyst } from '@/components/modules/Catalyst';
import { CRM } from '@/components/modules/CRM';
import { NotificationCenter } from '@/components/layout/NotificationCenter';
import { useCrmBootstrap } from '@/hooks/useCrmBootstrap';
import type { ModuleId } from '@/types';
import AdminPage from '@/app/admin/page';
import {
clearVelocityToken,
getVelocityMe,
getVelocityToken,
isAdminRole,
normalizeVelocityRole,
resolveVelocityFirstName,
} from '@/lib/velocityPlatformClient';
import {
MoreVertical,
LogOut,
Settings2
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// ── Route map ─────────────────────────────────────────────────────────────────
// Single source of truth: module id → URL path → page title → component
export const MODULE_ROUTES: Array<{
id: ModuleId;
path: string;
title: string;
component: React.ComponentType;
adminOnly?: boolean;
}> = [
{ id: 'dashboard', path: '/dashboard', title: 'Dashboard', component: Dashboard },
{ id: 'oracle', path: '/oracle', title: 'The Oracle', component: Oracle },
{ id: 'sentinel', path: '/sentinel', title: 'The Sentinel', component: Sentinel },
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
];
export const PATH_TO_MODULE = Object.fromEntries(
MODULE_ROUTES.map((r) => [r.path, r.id])
) as Record<string, ModuleId>;
// ── Protected Route guard ─────────────────────────────────────────────────────
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useStore();
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}
// ── Sync URL → Zustand activeModule ──────────────────────────────────────────
// This keeps the Zustand store in sync with the URL so existing module
// components that read `activeModule` from the store continue to work.
function RouteModuleSync() {
const { pathname } = useLocation();
const { setActiveModule } = useStore();
useEffect(() => {
const moduleId = PATH_TO_MODULE[pathname];
if (moduleId) setActiveModule(moduleId);
}, [pathname, setActiveModule]);
return null;
}
// ── Main authenticated layout ─────────────────────────────────────────────────
function MainLayout() {
const { activeModule, setActiveModule, sidebarExpanded, logout, user } = useStore();
useCrmBootstrap();
const navigate = useNavigate();
const location = useLocation();
const availableRoutes = MODULE_ROUTES.filter((route) => !route.adminOnly || isAdminRole(user?.role));
// Current route title
const currentRoute = availableRoutes.find((r) => r.path === location.pathname);
const pageTitle = currentRoute?.title ?? 'Velocity';
const roleLabel = formatRoleLabel(user?.role);
const userLabel = user?.name?.trim() || user?.fullName?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
const initials = userLabel
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('') || 'AU';
// Navigate to settings from dropdown (keeps router in sync)
const goToSettings = () => {
setActiveModule('settings');
navigate('/settings');
};
return (
<div className="min-h-screen flex" style={{ background: '#000' }}>
{/* Sync URL → store */}
<RouteModuleSync />
{/* Sidebar */}
<Sidebar />
{/* Main Content Area */}
<motion.main
className="flex-1 h-screen flex flex-col overflow-hidden"
initial={{ marginLeft: 72 }}
animate={{ marginLeft: sidebarExpanded ? 232 : 72 }}
transition={{
type: 'spring',
stiffness: 320,
damping: 32,
mass: 0.8
}}
>
{/* Top Bar */}
<header className="flex-none z-40 px-6 py-4">
<div
className="flex items-center justify-between px-5 py-3 rounded-2xl"
style={{
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
<motion.div
key={activeModule}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.25 }}
>
<h1 className="text-base font-semibold text-white tracking-tight">{pageTitle}</h1>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>Project Velocity · v.1.1</p>
</motion.div>
{/* User Profile */}
<div className="flex items-center gap-2">
{/* Active Notification Center */}
<NotificationCenter />
<div className="w-px h-6 bg-white/10" />
<div className="text-right">
<p className="text-sm font-medium text-white">{userLabel}</p>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>{roleLabel}</p>
</div>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-semibold"
style={{
background: 'hsl(var(--accent))',
color: 'hsl(var(--accent-fg))',
}}
>
{initials}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors outline-none">
<MoreVertical className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-[#0A0B10] border-white/10 text-zinc-200">
<DropdownMenuItem
className="cursor-pointer focus:bg-white/5"
onClick={goToSettings}
>
<Settings2 className="mr-2 h-4 w-4 text-zinc-400" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-white/10" />
<DropdownMenuItem
className="text-red-400 focus:text-red-400 focus:bg-red-500/10 cursor-pointer"
onClick={() => {
clearVelocityToken();
logout();
}}
>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
{/* Module Content — animated on route change */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="px-8 pb-8 min-h-full relative">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
ease: [0.4, 0, 0.2, 1]
}}
>
{/* Nested module routes rendered here */}
<Routes>
{availableRoutes.map(({ path, component: Component, adminOnly }) => (
<Route
key={path}
path={path}
element={adminOnly && !isAdminRole(user?.role) ? <Navigate to="/dashboard" replace /> : <Component />}
/>
))}
{/* Default: redirect / → /dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Catch-all: any unknown path → dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.main>
</div>
);
}
// ── Root App ──────────────────────────────────────────────────────────────────
function App() {
const { isAuthenticated, login, logout, user } = useStore();
const [authBootstrapped, setAuthBootstrapped] = useState(false);
useEffect(() => {
let cancelled = false;
const token = getVelocityToken();
if (!token) {
setAuthBootstrapped(true);
if (isAuthenticated) {
logout();
}
return () => {
cancelled = true;
};
}
void getVelocityMe()
.then((me) => {
if (cancelled) return;
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
login({
id: me.user_id,
name: resolveVelocityFirstName({
...me,
email: resolvedEmail ?? null,
full_name: resolvedFullName ?? null,
}),
fullName: resolvedFullName,
email: resolvedEmail,
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
role: normalizeVelocityRole(me.role),
});
setAuthBootstrapped(true);
})
.catch(() => {
if (cancelled) return;
clearVelocityToken();
logout();
setAuthBootstrapped(true);
});
return () => {
cancelled = true;
};
}, [isAuthenticated, login, logout]);
useEffect(() => {
if (!isAuthenticated || !authBootstrapped) {
return;
}
let cancelled = false;
void getVelocityMe()
.then((me) => {
if (cancelled) return;
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
login({
id: me.user_id,
name: resolveVelocityFirstName({
...me,
email: resolvedEmail ?? null,
full_name: resolvedFullName ?? null,
}),
fullName: resolvedFullName,
email: resolvedEmail,
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
role: normalizeVelocityRole(me.role),
});
})
.catch(() => {
if (cancelled) return;
clearVelocityToken();
logout();
});
return () => {
cancelled = true;
};
}, [authBootstrapped, isAuthenticated, login, logout, user]);
if (!authBootstrapped) {
return (
<div className="min-h-screen flex items-center justify-center bg-black text-zinc-300 text-sm">
Validating live Velocity session...
</div>
);
}
return (
<AnimatePresence mode="wait">
{!isAuthenticated ? (
<motion.div
key="login"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<Routes>
<Route path="/login" element={<LoginScreen />} />
<Route path="*" element={<LoginScreen />} />
</Routes>
</motion.div>
) : (
<motion.div
key="app"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<Routes>
<Route
path="/*"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
/>
</Routes>
</motion.div>
)}
</AnimatePresence>
);
}
export default App;
function formatRoleLabel(role: string | undefined) {
const normalized = normalizeVelocityRole(role);
if (!normalized) {
return 'Authenticated User';
}
return normalized
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}