Files
g1flux/src/components/StatsPanel.tsx
syoul d99ad3707d fix: show full city name with postal code and country in top villes
Restore full Cesium+ city field (including postal code), restructure
the city card so name wraps on two lines with country badge + tx count
+ volume all readable without truncation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:55:37 +01:00

153 lines
6.5 KiB
TypeScript

import { useRef } from 'react';
import type { PeriodStats } from '../services/DataService';
interface StatsPanelProps {
stats: PeriodStats | null;
loading: boolean;
periodDays: number;
source: 'live' | 'mock';
}
const MEDALS = ['🥇', '🥈', '🥉'];
function StatCard({ label, value, sub, delta }: { label: string; value: string; sub?: string; delta?: 'up' | 'down' | null }) {
return (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 space-y-1">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">{label}</p>
<p className="text-[#d4a843] text-2xl font-bold tabular-nums">
{value}
{delta === 'up' && <span className="text-emerald-400 text-sm ml-1.5"></span>}
{delta === 'down' && <span className="text-red-400 text-sm ml-1.5"></span>}
</p>
{sub && <p className="text-[#6b7280] text-xs">{sub}</p>}
</div>
);
}
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
// Calcule le delta d'une valeur par rapport au refresh précédent
function delta(current: number, prevMap: Map<string, number>, key: string) {
const prev = prevMap.get(key);
if (prev === undefined) return null;
if (current > prev) return <span className="text-emerald-400 text-xs ml-1"></span>;
if (current < prev) return <span className="text-red-400 text-xs ml-1"></span>;
return null;
}
// Construit une map volume précédent par ville
const prevCityVolume = new Map(
(prevStats.current?.topCities ?? []).map((c) => [c.name, c.volume])
);
const prevVolume = prevStats.current?.totalVolume ?? null;
const prevTxCount = prevStats.current?.transactionCount ?? null;
// Mémorise les stats après le rendu
if (stats && !loading) prevStats.current = stats;
const geoPct = stats && stats.transactionCount > 0
? Math.round((stats.geoCount / stats.transactionCount) * 100)
: null;
return (
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
{/* Header */}
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
Ğ
</div>
<div>
<h1 className="text-white font-bold text-lg leading-none">Ğ1Flux</h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
</div>
</div>
{/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
Période : <span className="text-[#6b7280]">{periodLabel}</span>
</p>
{/* Stats */}
{loading ? (
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
))}
</div>
) : stats ? (
<div className="space-y-3">
<StatCard
label="Volume total"
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
/>
<StatCard
label="Transactions"
value={stats.transactionCount.toLocaleString('fr-FR')}
sub={`${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`}
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
/>
{/* Couverture géo — uniquement en mode live */}
{source === 'live' && geoPct !== null && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
<div className="flex justify-between items-center mb-1.5">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
<p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
<div
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${geoPct}%` }}
/>
</div>
<p className="text-[#4b5563] text-xs mt-1 text-right">{geoPct}% via Cesium+</p>
</div>
)}
</div>
) : null}
{/* Top cities */}
{!loading && stats && stats.topCities.length > 0 && (
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
Top villes
</p>
{stats.topCities.map((city, i) => (
<div
key={city.name}
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
>
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
<div className="flex-1 min-w-0">
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
<div className="flex items-center justify-between mt-1">
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
{city.countryCode && (
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
{city.countryCode}
</span>
)}
{city.count} tx
</span>
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
{delta(city.volume, prevCityVolume, city.name)}
</span>
</div>
</div>
</div>
))}
</div>
)}
{/* Footer */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a]">
<p className="text-[#2e2f3a] text-xs text-center">
{source === 'live' ? 'Ğ1v2 · Subsquid + Cesium+' : 'Données simulées · mock'}
</p>
</div>
</aside>
);
}