const { useEffect, useState, useRef } = React; function formatNumber(value) { return Number(value).toLocaleString('fr-FR'); } function fetchJson(url) { return fetch(url).then((res) => { if (!res.ok) { throw new Error(`${res.status} ${res.statusText}`); } return res.json(); }); } function useQuartiers() { const [quartiers, setQuartiers] = useState([]); const [loadingQuartiers, setLoadingQuartiers] = useState(true); useEffect(() => { let active = true; fetchJson('/api/data') .then((data) => { if (!active) return; const names = [...new Set(data.map((item) => item.quartier).filter(Boolean))].sort(); setQuartiers(names); }) .catch(() => { if (active) setQuartiers([]); }) .finally(() => { if (active) setLoadingQuartiers(false); }); return () => { active = false; }; }, []); return { quartiers, loadingQuartiers }; } // Tile providers available for the map. Keys are stable identifiers used for // persistence and switching at runtime. const TILE_PROVIDERS = [ { key: 'tileProxy', label: 'OSM proxy local', url: '/tiles/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors' }, { key: 'osm', label: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors' }, { key: 'osmfr', label: 'OSM France (osmfr)', url: 'https://tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors — osmfr' }, { key: 'stamen', label: 'Stamen Toner', url: 'https://stamen-tiles.a.ssl.fastly.net/toner/{z}/{x}/{y}.png', attribution: 'Map tiles by Stamen' }, { key: 'carto', label: 'Carto Voyager', url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/rastertiles/voyager/{z}/{x}/{y}.png', attribution: '© Carto' }, { key: 'embedded', label: 'Embedded (fallback)', url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAALUlEQVQoU2NkYGD4z0AEYBxVSFGBgYGBgYGBg+M8wMDAwMDAwMDAwMDAwMAAA5YQEuWqz8OAAAAAElFTkSuQmCC', attribution: 'Local fallback' } ]; // Custom simple router based on window location hash function useHashRouter() { const normalizeHash = (hash) => { if (!hash) return '/'; const normalized = hash.startsWith('#') ? hash.slice(1) : hash; if (normalized === '') return '/'; if (normalized.endsWith('/') && normalized !== '/') { return normalized.slice(0, -1); } return normalized; }; const [currentHash, setCurrentHash] = useState(normalizeHash(window.location.hash)); useEffect(() => { const handleHashChange = () => { setCurrentHash(normalizeHash(window.location.hash)); }; window.addEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange); }, []); return { go: (path) => { const normalizedPath = path.startsWith('/') ? path : `/${path}`; window.location.hash = normalizedPath; }, path: currentHash, }; } function Sidebar({ router, currentPath }) { const mapPaths = ['/', '/carte']; const isActive = (path) => { if (mapPaths.includes(path)) { return mapPaths.includes(currentPath); } return currentPath === path; }; return ( ); } function PageHeader({ title, subtitle }) { return (

{title}

{subtitle ?

{subtitle}

: null}
); } function CartePage() { const [data, setData] = useState([]); const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [tileProvider, setTileProvider] = useState(localStorage.getItem('tile_provider') || 'tileProxy'); const mapRef = useRef(null); const leafletMap = useRef(null); const markers = useRef(null); useEffect(() => { async function loadPageData() { try { const [dataResult, statsResult] = await Promise.all([fetchJson('/api/data'), fetchJson('/api/stats')]); // Extract array from { data: [...] } response format setData(Array.isArray(dataResult) ? dataResult : (dataResult?.data || [])); setStats(statsResult); if (window.enableLegacyDataAccess) { window.enableLegacyDataAccess(); } } catch (err) { setError(err.message); } } loadPageData(); }, []); useEffect(() => { if (!mapRef.current || leafletMap.current) return; leafletMap.current = L.map(mapRef.current, { center: [16.0326, -16.4818], zoom: 13, zoomControl: true, }); // Use TILE_PROVIDERS list and expose a runtime switch to change provider. let providerIndex = 0; let currentLayer = null; let providerOk = false; function applyProviderByKey(key) { const p = TILE_PROVIDERS.find(t => t.key === key) || TILE_PROVIDERS[0]; if (!leafletMap.current) return; try { if (currentLayer) leafletMap.current.removeLayer(currentLayer); } catch (e) {} providerOk = false; // Use tileSize small for embedded data URI provider const opts = p.key === 'embedded' ? { tileSize: 8, attribution: p.attribution } : { attribution: p.attribution }; // Add a cache-busting query param for the local proxy provider to avoid stale/blocked tiles const url = p.key === 'tileProxy' ? `${p.url}?v=${Date.now()}` : p.url; currentLayer = L.tileLayer(url, opts); const onTileLoad = () => { providerOk = true; currentLayer.off('tileload', onTileLoad); currentLayer.off('tileerror', onTileError); }; const onTileError = () => { setTimeout(() => { if (!providerOk && p.key !== 'embedded') { applyProviderByKey('embedded'); } }, 800); }; currentLayer.on('tileload', onTileLoad); currentLayer.on('tileerror', onTileError); currentLayer.addTo(leafletMap.current); } // Expose global switch for other code and for runtime changes window.setTileProvider = function (key) { try { applyProviderByKey(key); } catch (e) { applyProviderByKey('embedded'); } }; // apply initial provider (from persisted selection) try { window.setTileProvider(localStorage.getItem('tile_provider') || tileProvider || 'osm'); } catch (e) { window.setTileProvider('embedded'); } markers.current = L.markerClusterGroup(); leafletMap.current.addLayer(markers.current); // Force-add proxy TileLayer shortly after initialization to ensure tiles // are attached to the existing Leaflet instance even if provider logic failed. setTimeout(() => { try { if (leafletMap.current) { const forced = L.tileLayer('/tiles/{z}/{x}/{y}.png?v=' + Date.now(), { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(leafletMap.current); try { // Ask Leaflet to redraw the forced layer and invalidate size if (typeof forced.redraw === 'function') forced.redraw(); leafletMap.current.invalidateSize(true); // Update any already-created tile elements to include a cache-busting param const now = Date.now(); document.querySelectorAll('.leaflet-tile').forEach((img) => { try { if (img && img.src) { const hasV = /[?&]v=/.test(img.src); if (!hasV) { img.src = img.src + (img.src.includes('?') ? '&' : '?') + 'v=' + now; } } } catch (e) {} }); } catch (e) {} // If tiles remain blocked, try switching to a different public provider try { if (window.setTileProvider) window.setTileProvider('carto'); } catch (e) {} } } catch (e) { console.warn('Force tileLayer add failed', e); } }, 500); return () => { if (leafletMap.current) { leafletMap.current.remove(); leafletMap.current = null; } }; }, []); useEffect(() => { if (!markers.current || !leafletMap.current) return; if (!Array.isArray(data) || data.length === 0) return; markers.current.clearLayers(); const markerItems = []; data.forEach((item) => { const lat = Number(item.latitude); const lng = Number(item.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; const marker = L.marker([lat, lng]); marker.bindPopup(`

${item.quartier || 'Quartier inconnu'}

Population : ${formatNumber(item.population)}

Cartes vendues : ${formatNumber(item.cartes_vendues)}

Montant : ${formatNumber(item.montant)} FCFA

`); markers.current.addLayer(marker); markerItems.push(marker); }); console.log('CartePage: data items=', data.length, 'markerItems=', markerItems.length); if (leafletMap.current) { try { // ensure map redraw and marker clusters are visible leafletMap.current.invalidateSize(true); } catch (e) {} } if (markerItems.length > 0) { // Keep the map centered and zoomed on Saint-Louis. Do not use automatic bounds fitting. } }, [data]); useEffect(() => { if (mapRef.current && leafletMap.current) { leafletMap.current.setView([16.0326, -16.4818], 13); } }, []); // When the React state `tileProvider` changes, instruct the map to switch provider. useEffect(() => { try { if (window.setTileProvider) { window.setTileProvider(tileProvider); } localStorage.setItem('tile_provider', tileProvider); } catch (e) {} }, [tileProvider]); return (
{error ?
Erreur : {error}
: null}

Carte

Saint-Louis

● Live

Statistiques géographiques

{stats ? (

Quartiers analysés

{formatNumber(stats.quartiers_count)}

Population totale

{formatNumber(stats.population_totale)}

Montant total

{formatNumber(stats.ventes_total)} FCFA

Taux de couverture

{stats.couverture_pct ?? 0}%

) : (

Chargement des statistiques...

)}

Filtres associés

); } function DashboardPage() { const [stats, setStats] = useState(null); const [data, setData] = useState([]); const [error, setError] = useState(null); const chartRef = useRef(null); const chartInstance = useRef(null); useEffect(() => { async function loadStats() { try { const [statsResult, dataResult] = await Promise.all([fetchJson('/api/stats'), fetchJson('/api/data')]); setStats(statsResult); setData(Array.isArray(dataResult) ? dataResult : (dataResult?.data || [])); } catch (err) { setError(err.message); } } loadStats(); }, []); useEffect(() => { if (!chartRef.current || !data.length) return; const phases = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin']; const values = [12, 18, 26, 23, 29, data.length]; if (chartInstance.current) { chartInstance.current.destroy(); } chartInstance.current = new Chart(chartRef.current, { type: 'line', data: { labels: phases, datasets: [{ label: 'Evolution des quartiers', data: values, borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)', fill: true, tension: 0.3, }], }, options: { responsive: true, plugins: { legend: { display: false }, }, scales: { x: { grid: { display: false } }, y: { beginAtZero: true }, }, }, }); }, [data]); return (
{error ?
Erreur : {error}
: null}

Population totale

{stats ? formatNumber(stats.population_totale) : '...'}

Quartiers suivis

{stats ? formatNumber(stats.quartiers_count) : '...'}

Total ventes

{stats ? formatNumber(stats.ventes_total) + ' FCFA' : '...'}

Couverture

{stats ? `${stats.couverture_pct ?? 0}%` : '...'}

Statistiques globales

Taux de couverture {stats ? `${stats.couverture_pct ?? 0}%` : '...'}
Quartiers nouveaux {stats ? formatNumber(stats.quartiers_count) : '...'}
Valeur moyenne par quartier {stats ? formatNumber(Math.round(stats.ventes_total / Math.max(stats.quartiers_count, 1))) : '...'}

Tendance

); } function VisitesPage() { const [visites, setVisites] = useState([]); const [error, setError] = useState(null); const { quartiers, loadingQuartiers } = useQuartiers(); useEffect(() => { fetchJson('/api/visites') .then((res) => { // Extract array from response if needed setVisites(Array.isArray(res) ? res : (res?.visites || [])); }) .catch((err) => setError(err.message)); }, []); return (
{error ?
Erreur : {error}
: null}

Liste des visites

{visites.length === 0 ? (

Aucune visite enregistrée.

) : ( visites.map((visite) => (
{visite.quartier || 'Quartier inconnu'} {visite.date_visite || 'Date indisponible'}

Statut : {visite.statut || 'N/A'}

Notes : {visite.notes || 'Aucune note'}

)) )}

Statistiques visites

Nombre total de visites enregistrées

{visites.length}

Nouvelle visite

); } function VentesPage() { const [ventes, setVentes] = useState([]); const [error, setError] = useState(null); const { quartiers, loadingQuartiers } = useQuartiers(); useEffect(() => { fetchJson('/api/ventes') .then((res) => { // Extract array from response if needed setVentes(Array.isArray(res) ? res : (res?.ventes || [])); }) .catch((err) => setError(err.message)); }, []); const totalMontant = ventes.reduce((sum, vente) => sum + Number(vente.montant || 0), 0); const totalCartes = ventes.reduce((sum, vente) => sum + Number(vente.cartes || 0), 0); return (
{error ?
Erreur : {error}
: null}

Transactions

{ventes.length}

Total cartes

{formatNumber(totalCartes)}

Montant total

{formatNumber(totalMontant)} FCFA

Historique des ventes

{ventes.length === 0 ? (

Aucune vente enregistrée.

) : ( ventes.map((vente) => (
{vente.quartier || 'Quartier inconnu'} {vente.cartes || 0} cartes

Montant : {formatNumber(vente.montant || 0)} FCFA

Date : {vente.date || 'N/A'}

)) )}

Nouvelle vente

); } function ActivitesPage() { const [activites, setActivites] = useState([]); const [error, setError] = useState(null); const { quartiers, loadingQuartiers } = useQuartiers(); useEffect(() => { fetchJson('/api/activites') .then((res) => { // Extract array from response if needed setActivites(Array.isArray(res) ? res : (res?.activites || [])); }) .catch((err) => setError(err.message)); }, []); return (
{error ?
Erreur : {error}
: null}

Liste des activités

{activites.length === 0 ? (

Aucune activité enregistrée.

) : ( activites.map((activite) => (
{activite.quartier || 'Quartier inconnu'} {activite.type_activite || activite.type || 'Type inconnu'}

Participants : {activite.participants || 'N/A'}

Date : {activite.date || 'N/A'}

)) )}

Statistiques activités

Total activités

{activites.length}

Participants totaux

{activites.reduce((sum, item) => sum + Number(item.participants || 0), 0)}

Nouvelle activité

); } function App() { const router = useHashRouter(); const path = router.path.split('?')[0] || '/'; const renderPage = () => { switch (path) { case '/': case '/carte': return ; case '/dashboard': return ; case '/visites': return ; case '/ventes': return ; case '/activites': return ; default: return ; } }; return (
{renderPage()}
); } const root = ReactDOM.createRoot(document.getElementById('react-root')); root.render();