/* ======================================================================== SENTINEL — Root app, router, toast/cmdk providers ======================================================================== */ const { useState, useEffect, useMemo, useCallback, AppCtx, Sidebar, Toasts, CommandPalette, Skel, useMedia, TabBar, MobileMore, Fab, DashboardPage, ClientsPage, AnnoncesPage, CartePage, OutilsPage, PhotoLocPage, EstimationPage, LegalPage, RapportsPage, ParametresPage, AdministrationPage, PhotoLocDevPage, NotFoundPage, I, NotifBell, Cheatsheet, Tour, LiveTicker, KonamiEgg, useKonami, useSounds, } = window; /* ============================================================ */ /* ROUTER */ /* ============================================================ */ const ROUTES = { dashboard: DashboardPage, clients: ClientsPage, annonces: AnnoncesPage, carte: CartePage, outils: OutilsPage, photoloc: PhotoLocPage, estimation: EstimationPage, legal: LegalPage, rapports: RapportsPage, parametres: ParametresPage, administration: AdministrationPage, photolocdev: PhotoLocDevPage, }; /* ============================================================ */ /* GATING PACK (cote client) */ /* ============================================================ */ /* Mapping route V2 -> feature du pack (cf. modules/packs.py). Une route dont la feature n'est pas incluse dans le pack de l'utilisateur affiche l'ecran d'upgrade au lieu de la page. Doit rester aligne avec NAV_FEATURE (sentinel-shell.jsx) et la table PACKS (packs.py). Le super-admin bypass : user.features est calcule tout-a-true pour lui dans App. */ const ROUTE_FEATURE = { carte: 'carte_interactive', photoloc: 'geoloc_photo', estimation: 'donnees_dvf', legal: 'module_legal', }; const FEATURE_MIN_PACK = { carte_interactive: 'Pro', donnees_dvf: 'Pro', module_legal: 'Pro', geoloc_photo: 'Expert', exclusivite_zone: 'Expert', }; const FEATURE_LABEL = { carte_interactive: 'Carte interactive', donnees_dvf: 'Donnees DVF & Estimation', module_legal: 'Module Juridique', geoloc_photo: 'Geolocalisation photo (IA)', exclusivite_zone: 'Exclusivite de zone', }; /* Ecran affiche quand l'utilisateur ouvre une option non incluse dans son pack. Presentational (props), pas de contexte requis. */ const FeatureLockedView = ({ feature, user, goto }) => { const featLabel = FEATURE_LABEL[feature] || 'Cette option'; const needPack = FEATURE_MIN_PACK[feature] || 'superieur'; return (

{featLabel} — verrouille

Votre pack {user.pack} n'inclut pas cette option. Passez au pack {needPack} pour la debloquer.

Voir les packs
); }; const useHashRoute = () => { const parse = () => { const raw = (window.location.hash || '#/dashboard').replace(/^#\//, '').toLowerCase(); return raw || 'dashboard'; }; const [route, setRoute] = useState(parse()); useEffect(() => { const onHash = () => setRoute(parse()); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); const goto = useCallback((r) => { window.location.hash = '#/' + r; // Auto-scroll content area to top on nav document.querySelector('.app__main')?.scrollTo({top: 0, behavior:'instant'}); }, []); return [route, goto]; }; /* ============================================================ */ /* APP */ /* ============================================================ */ /* Pages larges ou l'espace compte : le menu s'y replie automatiquement (et se rouvre selon la preference utilisateur ailleurs). */ const WIDE_ROUTES = new Set(['carte', 'rapports']); const App = ({ initialUser, pack }) => { const [route, goto] = useHashRoute(); // Theme memorise : avant, useState('dark') sec -> le mode clair sautait a // chaque rechargement, et sur mobile (toggle du footer sidebar masque) // il etait carrement inaccessible. // Chantier 08 : preference persistante dark | light | system (defaut // system -> prefers-color-scheme). `theme` = valeur effective appliquee. const systemTheme = () => (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); const [themePref, setThemePrefState] = useState(() => { try { const v = localStorage.getItem('sentinel-theme'); return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'; } catch (_) { return 'system'; } }); const [theme, setTheme] = useState(() => (themePref === 'system' ? systemTheme() : themePref)); const [toasts, setToasts] = useState([]); const [cmdkOpen, setCmdkOpen] = useState(false); const [cheatOpen, setCheatOpen] = useState(false); // Tour guide : jamais auto-ouvert sur mobile (il cible la sidebar desktop // et bloquerait l'ecran tactile) ; reste accessible via la palette. const [tourOpen, setTourOpen] = useState(() => !localStorage.getItem('sentinel-tour-done') && window.innerWidth > 640); const [focusMode, setFocusMode] = useState(false); const [navPref, setNavPref] = useState(() => { try { return localStorage.getItem('sentinel-nav-collapsed') === '1'; } catch (_) { return false; } }); const [navCollapsed, setNavCollapsed] = useState(navPref); const [wireframe, setWireframe] = useState(false); const [sound, setSound] = useState(false); const [compact, setCompact] = useState(() => { try { return localStorage.getItem('sentinel-compact') === '1'; } catch (_) { return false; } }); /* Theme : la preference pilote la valeur effective ; en mode systeme on suit les changements de l'OS en direct. */ useEffect(() => { try { localStorage.setItem('sentinel-theme', themePref); } catch (_) {} if (themePref !== 'system') { setTheme(themePref); return; } setTheme(systemTheme()); const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null; if (!mq) return; const onChange = () => setTheme(mq.matches ? 'light' : 'dark'); if (mq.addEventListener) mq.addEventListener('change', onChange); else mq.addListener(onChange); return () => { if (mq.removeEventListener) mq.removeEventListener('change', onChange); else mq.removeListener(onChange); }; }, [themePref]); useEffect(() => { document.body.classList.toggle('light', theme === 'light'); }, [theme]); const setThemePref = useCallback((v) => setThemePrefState(v === 'light' || v === 'system' ? v : 'dark'), []); useEffect(() => { document.body.classList.toggle('focus', focusMode); }, [focusMode]); useEffect(() => { document.body.classList.toggle('nav-collapsed', navCollapsed); }, [navCollapsed]); // Repli auto sur les pages larges ; ailleurs on applique la preference memorisee. useEffect(() => { setNavCollapsed(WIDE_ROUTES.has(route) ? true : navPref); }, [route, navPref]); // Toggle : sur une page normale, met a jour (et memorise) la preference ; // sur une page large, override temporaire jusqu'au changement de page. const toggleNav = useCallback(() => { setNavCollapsed(prev => { const next = !prev; if (!WIDE_ROUTES.has(route)) { setNavPref(next); try { localStorage.setItem('sentinel-nav-collapsed', next ? '1' : '0'); } catch (_) {} } return next; }); }, [route]); useEffect(() => { document.body.classList.toggle('wireframe', wireframe); }, [wireframe]); useEffect(() => { document.body.classList.toggle('compact', compact); try { localStorage.setItem('sentinel-compact', compact ? '1' : '0'); } catch (_) {} }, [compact]); /* Mobile : remet le scroll en haut a chaque changement de page (sinon on atterrit au milieu de la page suivante, le conteneur .app__main garde sa position). rAF : passe apres la restauration de scroll du navigateur au premier chargement. */ useEffect(() => { try { history.scrollRestoration = 'manual'; } catch (_) {} const main = document.querySelector('.app__main'); if (main) requestAnimationFrame(() => main.scrollTo({ top: 0 })); }, [route]); const toggleCompact = useCallback(() => setCompact(c => !c), []); /* Chantier 07 : coquille mobile (< 768 px) : tab bar, feuille « Plus », FAB « Nouveau client ». */ const isMobile = useMedia('(max-width: 767px)'); const [moreOpen, setMoreOpen] = useState(false); useEffect(() => { setMoreOpen(false); }, [route]); const newClient = useCallback(() => { if (route === 'clients') { window.dispatchEvent(new CustomEvent('sentinel:new-client')); return; } try { sessionStorage.setItem('sentinel.clients.new', '1'); } catch (_) {} goto('clients'); }, [route, goto]); /* Chantier 06 : 250 ms de squelette au changement de page (les formes de la page cible), a la place du flash. La page se monte dessous. */ const [skel, setSkel] = useState(false); useEffect(() => { setSkel(true); const t = setTimeout(() => setSkel(false), 250); return () => clearTimeout(t); }, [route]); // Bascule rapide (⌘/, palette) : fige la preference sur l'oppose du theme courant const toggleTheme = useCallback(() => setThemePrefState(theme === 'dark' ? 'light' : 'dark'), [theme]); const playSound = useSounds(sound); /* Konami → wireframe */ useKonami(useCallback(() => { setWireframe(w => !w); setToasts(ts => [...ts, { id: Date.now(), msg: '⚡ Mode wireframe activé (Konami code)', tone: 'success' }]); }, [])); /* Toasts */ const toast = useCallback((msg, tone = 'success') => { const id = Date.now() + Math.random(); setToasts(ts => [...ts, { id, msg, tone }]); playSound(tone === 'error' ? 220 : 880, 0.05, 0.04); setTimeout(() => setToasts(ts => ts.filter(t => t.id !== id)), 5000); }, [playSound]); const closeToast = useCallback((id) => setToasts(ts => ts.filter(t => t.id !== id)), []); /* CmdK */ const openCmdK = useCallback(() => setCmdkOpen(true), []); const closeCmdK = useCallback(() => setCmdkOpen(false), []); /* Recherche globale (cmdk) : clients + annonces REELS du cabinet, charges en lazy a la premiere ouverture de la palette (evite un fetch inutile si elle n'est jamais ouverte). null = pas encore charge. */ const [searchData, setSearchData] = useState(null); useEffect(() => { if (!cmdkOpen || searchData !== null) return; const api = window.SentinelAPI; if (!api) { setSearchData({ clients: [], annonces: [] }); return; } Promise.all([ api.fetchAuth('/api/clients?limit=200').catch(() => null), api.fetchAuth('/api/annonces?limit=200').catch(() => null), ]).then(([cl, an]) => { setSearchData({ clients: (cl && cl.data) || [], annonces: (an && an.data) || [], }); }); }, [cmdkOpen, searchData]); /* Global shortcuts */ useEffect(() => { let lastG = 0; const onKey = (e) => { // ignore when typing in inputs const tag = (e.target.tagName || '').toLowerCase(); const inField = tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable; if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setCmdkOpen(o => !o); return; } if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); toggleTheme(); return; } if (inField) return; if (e.key === '?') { e.preventDefault(); setCheatOpen(o => !o); return; } if (e.key === 'f' || e.key === 'F') { setFocusMode(f => !f); return; } if (e.key === '[' || e.key === ']') { e.preventDefault(); toggleNav(); return; } if (e.key === 'n' || e.key === 'N') { goto('clients'); toast('Astuce : tape "+ Nouveau client" pour créer.','success'); return; } // g-prefix navigation if (e.key.toLowerCase() === 'g') { lastG = Date.now(); return; } if (Date.now() - lastG < 1200) { const map = { d:'dashboard', c:'clients', a:'annonces', m:'carte', l:'legal', r:'rapports', p:'parametres' }; const t = map[e.key.toLowerCase()]; if (t) { e.preventDefault(); goto(t); lastG = 0; } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [toggleTheme, goto, toast, toggleNav]); /* Tour persist */ const closeTour = useCallback(() => { setTourOpen(false); localStorage.setItem('sentinel-tour-done', '1'); }, []); /* User reel injecte par Bootstrap (fetch /api/auth/me). Fallback minimal si jamais initialUser est absent (cas dev). Le pack n'est pas dans /api/auth/me en l'etat : on garde 'Expert' par defaut pour le badge. */ const user = useMemo(() => { const u = initialUser || {}; const fullName = [u.prenom, u.nom].filter(Boolean).join(' ').trim(); const role = u.is_superadmin ? 'super-admin' : u.is_groupe_admin ? 'groupe-admin' : u.is_cabinet_admin ? 'cabinet-admin' : 'agent'; // Features reelles depuis /api/me/pack (source de verite packs.py). // Super-admin : bypass total. Pack indisponible (super-admin sans cabinet, // ou endpoint en erreur) : on n'enferme pas un client payant a tort -> // tout ouvert, le backend (require_feature) reste le vrai garde-fou. const FEATURE_KEYS = ['carte_interactive', 'donnees_dvf', 'geoloc_photo', 'module_legal', 'exclusivite_zone']; const features = {}; FEATURE_KEYS.forEach((k) => { features[k] = u.is_superadmin ? true : (pack ? !!pack[k] : true); }); return { id: u.id, email: u.email || 'inconnu', nom: fullName || u.email || 'Utilisateur', pack: pack ? (pack.label || pack.name) : (u.pack || '—'), pack_name: pack ? pack.name : null, features: features, role: role, cabinet_id: u.cabinet_id, cabinet_nom: u.cabinet_nom || 'Cabinet', groupe_id: u.groupe_id, groupe_nom: u.groupe_nom, is_superadmin: !!u.is_superadmin, is_cabinet_admin: !!u.is_cabinet_admin, is_groupe_admin: !!u.is_groupe_admin, }; }, [initialUser, pack]); /* CmdK items */ const cmdkItems = useMemo(() => { const pages = [ { id:'r-dashboard', label:'Aller au Tableau de bord', glyph:'dashboard', kind:'Page', action:() => goto('dashboard') }, { id:'r-clients', label:'Aller aux Clients', glyph:'clients', kind:'Page', action:() => goto('clients') }, { id:'r-annonces', label:'Aller aux Annonces', glyph:'annonces', kind:'Page', action:() => goto('annonces') }, { id:'r-carte', label:'Ouvrir la Carte', glyph:'carte', kind:'Page', action:() => goto('carte') }, { id:'r-outils', label:'Outils géo', glyph:'outils', kind:'Page', action:() => goto('outils') }, { id:'r-photoloc', label:'PhotoLoc', glyph:'photoloc', kind:'Page', action:() => goto('photoloc') }, { id:'r-estim', label:'Estimation auto', glyph:'estimation', kind:'Page', action:() => goto('estimation') }, { id:'r-legal', label:'Juridique', glyph:'legal', kind:'Page', action:() => goto('legal') }, { id:'r-rapports', label:'Rapports', glyph:'rapports', kind:'Page', action:() => goto('rapports') }, { id:'r-params', label:'Paramètres', glyph:'parametres', kind:'Page', action:() => goto('parametres') }, ]; const actions = [ { id:'a-newclient', label:'Créer un nouveau client', glyph:'plus', kind:'Action', action:() => { goto('clients'); toast('Ouvre le formulaire « Nouveau client ».','warning'); } }, { id:'a-collect', label:'Lancer une collecte d\'annonces', glyph:'refresh', kind:'Action', action:() => toast('Collecte démarrée.','success') }, { id:'a-theme', label:'Basculer mode clair / sombre', glyph:'light', kind:'Action', action:toggleTheme }, { id:'a-focus', label:'Activer le mode focus', glyph:'eye', kind:'Action', action:() => setFocusMode(f => !f) }, { id:'a-cheat', label:'Afficher les raccourcis clavier', glyph:'cmd', kind:'Action', action:() => setCheatOpen(true) }, { id:'a-tour', label:'Relancer la visite guidée', glyph:'arrow', kind:'Action', action:() => setTourOpen(true) }, { id:'a-sound', label:`Sons UI : ${sound ? 'désactiver' : 'activer'}`, glyph:'cmd', kind:'Action', action:() => setSound(s => !s) }, { id:'a-density', label:'Basculer la densité (compacte / confort)', glyph:'cmd', kind:'Action', action:() => setCompact(c => !c) }, ]; /* Donnees reelles du cabinet (lazy) ; liste vide tant que non charge. */ const sd = searchData || { clients: [], annonces: [] }; const clients = (sd.clients || []).slice(0, 50).map(c => { const nom = `${c.prenom || ''} ${c.nom || ''}`.trim() || c.email || ('Client #' + c.id); const cr = c.criteres || {}; const villes = (Array.isArray(cr.villes) && cr.villes.length) ? cr.villes.join(', ') : ''; return { id: 'c-' + c.id, label: nom, meta: [c.email, villes].filter(Boolean).join(' · ') || '—', glyph: 'clients', kind: 'Client', action: () => { goto('clients'); toast('Fiche : ' + nom, 'success'); }, }; }); const annonces = (sd.annonces || []).slice(0, 50).map(a => { const meta = [ a.ville, a.prix != null ? a.prix.toLocaleString('fr-FR') + ' €' : null, a.surface != null ? a.surface + ' m²' : null, ].filter(Boolean).join(' · '); return { id: 'a-' + a.id, label: a.titre || a.ville || ('Annonce #' + a.id), meta: meta || '—', glyph: 'annonces', kind: 'Annonce', action: () => { goto('annonces'); toast('Ouverture de l\'annonce.', 'success'); }, }; }); return [...pages, ...actions, ...clients, ...annonces]; }, [goto, toast, toggleTheme, sound, searchData]); const onCmdK = useCallback((item) => { closeCmdK(); setTimeout(() => item.action?.(), 50); }, [closeCmdK]); /* Selected page */ const Page = ROUTES[route] || NotFoundPage; /* Gate role : les pages super-admin (Administration, PhotoLoc DEV) restent inaccessibles aux autres roles, meme via #hash direct (la Sidebar ne les affiche deja pas, ceci ferme la porte de l'URL). */ const roleBlocked = (route === 'administration' || route === 'photolocdev') && !user.is_superadmin; /* Gate pack : si la route demande une feature absente du pack, on rend l'ecran d'upgrade au lieu de la page (super-admin bypass via features). */ const lockedFeature = ROUTE_FEATURE[route]; const routeLocked = !roleBlocked && !!lockedFeature && !user.features[lockedFeature]; /* Context value */ const ctx = useMemo(() => ({ route, goto, theme, toggleTheme, themePref, setThemePref, isMobile, toast, openCmdK, user, focusMode, setFocusMode, navCollapsed, toggleNav, sound, setSound, compact, toggleCompact, cheatOpen, setCheatOpen, tourOpen, setTourOpen, }), [route, goto, theme, toggleTheme, themePref, setThemePref, isMobile, toast, openCmdK, user, focusMode, navCollapsed, toggleNav, sound, compact, toggleCompact, cheatOpen, tourOpen]); return (
{/* Chantier 01 : topbar 48 px = contexte (cabinet) · recherche ⌘K · notifications · aide. Masquee sur les pages plein ecran. */}
{user.cabinet_nom} {user.groupe_nom && · {user.groupe_nom}}
{NotifBell && }
{skel && Skel && ( )} {roleBlocked ? : routeLocked ? : }
{isMobile && TabBar && } {isMobile && MobileMore && setMoreOpen(false)} />} {isMobile && Fab && route !== 'carte' && route !== 'photoloc' && !moreOpen && } {Cheatsheet && setCheatOpen(false)} />} {Tour && } {KonamiEgg && }
); }; /* ============================================================ */ /* AUTH GATE — tout utilisateur authentifie (Phase 1 client) */ /* ============================================================ */ /* La V2 est accessible a tout user connecte. Le contenu s'adapte */ /* au role (Sidebar : sections Compte / Super-admin) et au pack */ /* (verrouillage des options via user.features, cf. ROUTE_FEATURE */ /* et /api/me/pack). Le super-admin bypass tout. */ const TOKEN_KEY = 'sentinel.token'; const Bootstrap = () => { const [state, setState] = useState('loading'); // loading | denied | ok const [user, setUser] = useState(null); const [pack, setPack] = useState(null); useEffect(() => { const token = localStorage.getItem(TOKEN_KEY); const goLogin = (reason) => { // Lot 0 audit : purge du token avant la redirection, sinon // login.html (qui redirige vers /v2 des qu'un token existe) // renvoie ici -> boucle infinie a l'expiration du JWT. try { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem('sentinel.user'); } catch (e) { /* storage indisponible */ } const q = ['next=' + encodeURIComponent(window.location.pathname + window.location.hash)]; if (reason) q.unshift('reason=' + encodeURIComponent(reason)); window.location.href = '/login.html?' + q.join('&'); }; if (!token) { goLogin(); return; } const auth = { headers: { Authorization: 'Bearer ' + token } }; // Phase 1 client : tout utilisateur authentifie accede a la V2 (fini le // gate super-admin only). Le contenu s'adapte au role (Sidebar) et au pack // (verrouillage des options via user.features). Le pack est recupere en // best-effort : une erreur ne bloque pas l'acces. fetch('/api/auth/me', auth) .then(r => { if (r.status === 401) { r.json().then(d => goLogin(d && typeof d.error === 'string' ? d.error : '')).catch(() => goLogin()); return null; } if (!r.ok) throw new Error('auth check failed'); return r.json(); }) .then(d => { if (!d) return null; const u = (d.data && d.data.user) || d.data || {}; setUser(u); return fetch('/api/me/pack', auth) .then(pr => (pr.ok ? pr.json() : null)) .catch(() => null) .then(pd => { setPack((pd && pd.data && pd.data.pack) || null); setState('ok'); }); }) .catch(() => setState('denied')); }, []); if (state === 'loading') { return (
Chargement…
); } if (state === 'denied') { return (
Connexion impossible
Impossible de verifier votre session. Reessayez ou reconnectez-vous.
); } return ; }; ReactDOM.createRoot(document.getElementById('app-root')).render();