File size: 12,548 Bytes
42a2598 66ff7a1 0f2f80a 1ef1cda 785cec2 8280d7d 785cec2 803f4c5 0f2f80a 66ff7a1 803f4c5 2c400a5 66ff7a1 2c400a5 0f2f80a 87dcd87 2c400a5 785cec2 42a2598 66ff7a1 42a2598 2c400a5 e286845 2c400a5 42a2598 87dcd87 42a2598 87dcd87 42a2598 87dcd87 42a2598 785cec2 5b8761d 785cec2 5b8761d 785cec2 8280d7d 785cec2 2c400a5 785cec2 0f2f80a 785cec2 42a2598 785cec2 2c400a5 785cec2 2c400a5 785cec2 66ff7a1 2c400a5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | import React, { useState, useEffect, useRef } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/lib/auth';
import { useTenant } from '@/lib/tenant';
import {
BarChart2, TrendingUp, Users, BookOpen, Mic, Building2, Activity,
Lightbulb, Database, Megaphone, LogOut, LayoutTemplate, MessageSquare,
Bot, Menu, X, CreditCard
} from 'lucide-react';
import RoleGuard from '@/components/RoleGuard';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import AdminChat, { type AdminChatPage } from '@/components/AdminChat';
interface MainLayoutProps {
children: React.ReactNode;
isSuperAdmin: boolean;
orgs: any[];
}
function useAdminChatPage(): AdminChatPage | null {
const { pathname } = useLocation();
if (pathname === '/billing') return null; // billing has its own inline chat
if (pathname.startsWith('/settings')) return 'settings';
if (pathname.startsWith('/whatsapp-templates')) return 'templates';
if (pathname.startsWith('/ai-setup') || pathname.startsWith('/kb')) return 'agent';
if (pathname.startsWith('/clients/new') || pathname.startsWith('/onboarding')) return 'onboarding';
return 'general';
}
export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutProps) {
const { t } = useTranslation();
const { logout, user, token } = useAuth();
const { selectedOrgId, setSelectedOrgId, currentOrg } = useTenant();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const chatPage = useAdminChatPage();
const { pathname } = useLocation();
const esRef = useRef<EventSource | null>(null);
const isCrmActive = !!currentOrg?.isCrmActive;
const isEdTechActive = !!currentOrg?.isEdTechActive;
// Reset unread count when user is on the conversations page
useEffect(() => {
if (pathname.startsWith('/conversations') || pathname.startsWith('/crm')) {
setUnreadCount(0);
}
}, [pathname]);
// SSE β track new inbound messages for the notification badge
useEffect(() => {
if (!selectedOrgId || !token) return;
const apiBase = import.meta.env.VITE_API_URL || '';
const es = new EventSource(`${apiBase}/v1/organizations/${selectedOrgId}/stream?token=${encodeURIComponent(token)}`);
esRef.current = es;
es.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'new-message') {
// Only increment if not currently viewing conversations
const isViewingConversations = window.location.pathname.startsWith('/conversations') || window.location.pathname.startsWith('/crm');
if (!isViewingConversations) {
setUnreadCount(n => n + 1);
}
}
} catch { /* ignore */ }
});
es.onerror = () => es.close();
return () => { es.close(); esRef.current = null; };
}, [selectedOrgId, token]);
const navItems = [
{ to: '/', label: t('nav.home'), icon: <BarChart2 className="w-4 h-4" />, end: true },
{ to: '/analytics', label: t('common.analytics'), icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
{ to: '/conversations', label: t('nav.conversations'), icon: <MessageSquare className="w-4 h-4 text-sky-400" /> },
// CRM items
{ to: '/contacts', label: t('common.clients'), icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmActive },
{ to: '/campaign-history', label: t('nav.campaigns'), icon: <Megaphone className="w-4 h-4 text-amber-500" />, show: isCrmActive },
{ to: '/whatsapp-templates',label: t('nav.templates'), icon: <LayoutTemplate className="w-4 h-4 text-indigo-400" />, show: isCrmActive },
// EdTech items
{ to: '/content', label: t('nav.content'), icon: <BookOpen className="w-4 h-4" />, show: isEdTechActive },
{ to: '/live-feed', label: t('nav.moderation'), icon: <Mic className="w-4 h-4 text-emerald-500" />, show: isEdTechActive },
{ to: '/users', label: t('nav.users'), icon: <Users className="w-4 h-4" />, show: isEdTechActive },
// Shared β accessible to all org members
{ to: '/kb', label: t('nav.kb'), icon: <Database className="w-4 h-4 text-violet-400" /> },
{ to: '/ai-setup', label: t('nav.ai_setup'), icon: <Bot className="w-4 h-4 text-pink-400" /> },
// Super admin only
{ to: '/clients', label: t('nav.b2b'), icon: <Building2 className="w-4 h-4 text-indigo-400" />, show: isSuperAdmin },
{ to: '/training', label: t('nav.training'), icon: <Activity className="w-4 h-4 text-purple-400" />, show: isSuperAdmin },
// Always last
{ to: '/billing', label: t('nav.billing'), icon: <CreditCard className="w-4 h-4 text-emerald-400" /> },
{ to: '/settings', label: t('common.settings'), icon: <Lightbulb className="w-4 h-4" /> },
].filter(item => item.show !== false);
const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
isActive
? 'bg-white/15 text-white'
: 'text-slate-400 hover:text-white hover:bg-white/10'
}`;
const avatarInitial = (user?.name?.trim()?.[0] ?? user?.email?.[0] ?? 'U').toUpperCase();
const displayName = user?.name || user?.email || 'β';
const displayRole = (user?.role ?? '').toLowerCase().replace(/_/g, ' ');
const sidebarInner = (
<>
{/* Logo / org name */}
<div className="text-xl font-bold mb-8 flex items-center gap-3 shrink-0">
{currentOrg?.brandingData?.logoUrl ? (
<img src={currentOrg.brandingData.logoUrl} className="h-8 w-8 object-contain rounded" alt="Logo" />
) : (
<span className="text-2xl">π</span>
)}
<span className="truncate text-base">{currentOrg?.name || 'Admin'}</span>
</div>
{/* Super-admin org selector */}
<RoleGuard requireSuperAdmin>
<div className="mb-6 shrink-0">
<label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2">
Multi-Tenant
</label>
<select
value={selectedOrgId || ''}
onChange={e => setSelectedOrgId(e.target.value)}
className="w-full bg-slate-800 text-slate-200 text-xs px-3 py-2.5 rounded-xl outline-none focus:ring-1 focus:ring-slate-600 appearance-none cursor-pointer"
>
<option value="">SΓ©lectionner une Γ©cole...</option>
{orgs.map(o => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
</div>
</RoleGuard>
{/* Nav β scrollable so it never overflows */}
<nav className="space-y-0.5 flex-1 overflow-y-auto min-h-0 -mx-1 px-1">
{navItems.map(n => {
const showBadge = n.to === '/conversations' && unreadCount > 0;
return (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className={linkClass}
onClick={() => { setSidebarOpen(false); if (n.to === '/conversations') setUnreadCount(0); }}
>
{n.icon}
<span className="truncate flex-1">{n.label}</span>
{showBadge && (
<span className="ml-auto bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shrink-0">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</NavLink>
);
})}
</nav>
{/* Footer */}
<div className="pt-4 mt-4 border-t border-slate-800 shrink-0">
<div className="mb-4">
<label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2">
Interface
</label>
<LanguageSwitcher />
</div>
<div className="flex items-center gap-3 px-1 mb-3">
<div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center font-bold text-xs shrink-0">
{avatarInitial}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold truncate">{displayName}</p>
<p className="text-[10px] text-slate-500 truncate capitalize">{displayRole}</p>
</div>
</div>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2 text-xs text-slate-500 hover:text-white transition group rounded-xl hover:bg-white/5"
>
<LogOut className="w-3.5 h-3.5 group-hover:text-red-400 transition" />
{t('common.logout')}
</button>
</div>
</>
);
return (
<div className="min-h-screen bg-gray-50 flex">
{/* ββ Desktop sidebar (always visible β₯ lg) ββ */}
<aside className="hidden lg:flex w-64 bg-slate-900 text-white p-5 flex-col shrink-0 h-screen sticky top-0">
{sidebarInner}
</aside>
{/* ββ Mobile: backdrop overlay ββ */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-30 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* ββ Mobile: sliding sidebar ββ */}
<aside
className={`fixed top-0 left-0 h-full w-64 bg-slate-900 text-white p-5 flex flex-col z-40 transition-transform duration-200 ease-in-out lg:hidden ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<button
onClick={() => setSidebarOpen(false)}
className="absolute top-4 right-4 text-slate-400 hover:text-white transition"
aria-label="Fermer le menu"
>
<X className="w-5 h-5" />
</button>
{sidebarInner}
</aside>
{/* ββ Main content area ββ */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile top bar */}
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-slate-900 text-white sticky top-0 z-20 shrink-0">
<button
onClick={() => setSidebarOpen(true)}
className="text-slate-400 hover:text-white transition"
aria-label="Ouvrir le menu"
>
<Menu className="w-5 h-5" />
</button>
<span className="font-bold text-sm truncate">{currentOrg?.name || 'Admin'}</span>
</header>
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
{/* Global AI assistant β hidden on /billing (has its own inline chat) */}
{chatPage && selectedOrgId && <AdminChat page={chatPage} />}
</div>
);
}
|