/* ========================================================================
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.
);
};
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. */}
Sentinel
{user.cabinet_nom}
{user.groupe_nom && · {user.groupe_nom}}
{NotifBell &&
}
{skel && Skel && (
{(route === 'dashboard' || route === 'rapports') &&
}
{(route === 'clients' || route === 'annonces' || route === 'legal' || route === 'administration')
? <>
>
:
}
)}
{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();