2026-06-03 00:59:04 +02:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-06-08 14:33:23 +02:00
|
|
|
|
import { useState, useMemo, useEffect, useRef } from "react";
|
2026-06-03 01:04:07 +02:00
|
|
|
|
import {
|
|
|
|
|
|
ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus,
|
2026-06-27 22:28:39 +02:00
|
|
|
|
Loader2, Radio, AlertTriangle, Landmark, Globe, BarChart2, Zap, ChevronDown, ChevronUp,
|
2026-06-03 01:04:07 +02:00
|
|
|
|
} from "lucide-react";
|
2026-06-03 00:59:04 +02:00
|
|
|
|
import type { NewsItem } from "@/app/api/news/route";
|
|
|
|
|
|
import type { Currency } from "@/lib/types";
|
|
|
|
|
|
|
2026-06-03 01:04:07 +02:00
|
|
|
|
// ── Constantes ────────────────────────────────────────────────────────────────
|
2026-06-03 00:59:04 +02:00
|
|
|
|
|
|
|
|
|
|
const CCY_FLAGS: Record<Currency, string> = {
|
|
|
|
|
|
USD: "🇺🇸", EUR: "🇪🇺", GBP: "🇬🇧", JPY: "🇯🇵",
|
|
|
|
|
|
CHF: "🇨🇭", CAD: "🇨🇦", AUD: "🇦🇺", NZD: "🇳🇿",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-03 01:04:07 +02:00
|
|
|
|
const CATEGORY_META: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
|
2026-06-27 22:22:14 +02:00
|
|
|
|
"Discours BC": { icon: <Landmark size={11} />, color: "bg-blue-500/20 text-blue-400 border-blue-500/30", label: "Discours BC" },
|
|
|
|
|
|
"Chef d'État": { icon: <Globe size={11} />, color: "bg-violet-500/20 text-violet-400 border-violet-500/30", label: "Chef d'État" },
|
|
|
|
|
|
"Décision Taux": { icon: <Radio size={11} />, color: "bg-amber-500/20 text-amber-400 border-amber-500/30", label: "Décision Taux" },
|
|
|
|
|
|
"Probabilités Taux": { icon: <BarChart2 size={11} />, color: "bg-sky-500/20 text-sky-400 border-sky-500/30", label: "OIS / Proba Taux" },
|
|
|
|
|
|
"Données Clés": { icon: <BarChart2 size={11} />, color: "bg-slate-400/20 text-slate-300 border-slate-500/30", label: "Données Clés" },
|
|
|
|
|
|
"Emploi": { icon: <BarChart2 size={11} />, color: "bg-slate-400/20 text-slate-300 border-slate-500/30", label: "Emploi" },
|
|
|
|
|
|
"Inflation": { icon: <Zap size={11} />, color: "bg-orange-500/20 text-orange-400 border-orange-500/30", label: "Inflation" },
|
|
|
|
|
|
"Crise": { icon: <AlertTriangle size={11} />, color: "bg-red-600/20 text-red-400 border-red-500/30", label: "Crise" },
|
|
|
|
|
|
"Guerre": { icon: <AlertTriangle size={11} />, color: "bg-red-700/20 text-red-400 border-red-600/30", label: "Guerre" },
|
|
|
|
|
|
"Géopolitique": { icon: <Globe size={11} />, color: "bg-purple-500/20 text-purple-400 border-purple-500/30", label: "Géopolitique" },
|
|
|
|
|
|
"Risk-Off": { icon: <TrendingDown size={11} />, color: "bg-red-500/20 text-red-400 border-red-500/30", label: "Risk-Off" },
|
|
|
|
|
|
"Risk-On": { icon: <TrendingUp size={11} />, color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", label: "Risk-On" },
|
|
|
|
|
|
"Énergie": { icon: <Zap size={11} />, color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", label: "Énergie" },
|
|
|
|
|
|
"Banque Centrale": { icon: <Landmark size={11} />, color: "bg-blue-500/20 text-blue-400 border-blue-500/30", label: "Banque Centrale" },
|
|
|
|
|
|
"Commodités": { icon: <BarChart2 size={11} />, color: "bg-orange-500/20 text-orange-400 border-orange-500/30", label: "Commodités" },
|
|
|
|
|
|
"Chine": { icon: <Globe size={11} />, color: "bg-red-600/20 text-red-400 border-red-600/30", label: "Chine" },
|
2026-06-03 00:59:04 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
// Catégories affichées comme boutons de filtre (ordre d'apparition)
|
2026-06-03 01:04:07 +02:00
|
|
|
|
const PRIORITY_CATS = [
|
|
|
|
|
|
"Discours BC", "Décision Taux", "Probabilités Taux",
|
|
|
|
|
|
"Chef d'État", "Données Clés", "Emploi", "Inflation",
|
|
|
|
|
|
"Crise", "Guerre", "Géopolitique", "Risk-Off", "Risk-On",
|
|
|
|
|
|
"Énergie", "Commodités", "Chine",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
// Catégories toujours visibles dans la barre même sans articles correspondants
|
|
|
|
|
|
const ALWAYS_VISIBLE = new Set(["Inflation", "Géopolitique", "Emploi", "Énergie"]);
|
|
|
|
|
|
|
|
|
|
|
|
// Set pour la séparation top-news / autres
|
|
|
|
|
|
const PRIORITY_CATS_SET = new Set(PRIORITY_CATS);
|
|
|
|
|
|
|
2026-06-27 22:22:14 +02:00
|
|
|
|
// Catégories "haute priorité" : reçoivent un boost de tri
|
|
|
|
|
|
const HIGH_PRIO = new Set([
|
|
|
|
|
|
"Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État", "Probabilités Taux",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-06-03 01:04:07 +02:00
|
|
|
|
const SOURCE_COLORS: Record<string, string> = {
|
|
|
|
|
|
"InvestingLive": "bg-amber-500/20 text-amber-400",
|
2026-07-08 18:49:16 +02:00
|
|
|
|
"CNBC": "bg-orange-500/20 text-orange-400",
|
2026-06-03 01:04:07 +02:00
|
|
|
|
"Bloomberg Economics": "bg-sky-500/20 text-sky-400",
|
|
|
|
|
|
"Bloomberg CB": "bg-sky-500/20 text-sky-400",
|
|
|
|
|
|
"Bloomberg FX": "bg-sky-500/20 text-sky-400",
|
2026-06-03 00:59:04 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function formatRelativeTime(isoDate: string): string {
|
|
|
|
|
|
const diff = Date.now() - new Date(isoDate).getTime();
|
|
|
|
|
|
const mins = Math.floor(diff / 60000);
|
2026-06-27 22:22:14 +02:00
|
|
|
|
if (mins < 1) return "à l'instant";
|
|
|
|
|
|
if (mins < 60) return `il y a ${mins}min`;
|
2026-06-03 00:59:04 +02:00
|
|
|
|
const hrs = Math.floor(mins / 60);
|
2026-06-27 22:22:14 +02:00
|
|
|
|
if (hrs < 24) return `il y a ${hrs}h`;
|
2026-06-03 01:04:07 +02:00
|
|
|
|
return `il y a ${Math.floor(hrs / 24)}j`;
|
2026-06-03 00:59:04 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 22:22:14 +02:00
|
|
|
|
// ── Tri ───────────────────────────────────────────────────────────────────────
|
2026-07-08 18:49:16 +02:00
|
|
|
|
// Strictement chronologique (score = timestamp). Un bonus "+3h prioritaire /
|
|
|
|
|
|
// +1h catégorisé" existait ici, mais en période de crise soutenue la quasi-
|
|
|
|
|
|
// totalité des articles matche une catégorie prioritaire (Guerre/Géopolitique/
|
|
|
|
|
|
// Chef d'État) : le bonus s'applique alors presque partout et casse l'ordre
|
|
|
|
|
|
// chronologique au lieu de l'affiner (des articles plus vieux mais
|
|
|
|
|
|
// "prioritaires" passent devant des articles réellement plus récents). Le
|
|
|
|
|
|
// badge visuel "⚡ Prioritaire" (NewsCard) signale déjà l'importance sans
|
|
|
|
|
|
// trahir la fraîcheur réelle du flux.
|
2026-06-27 22:22:14 +02:00
|
|
|
|
function scoreItem(item: NewsItem): number {
|
2026-07-08 18:49:16 +02:00
|
|
|
|
return new Date(item.publishedAt).getTime();
|
2026-06-27 22:22:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 00:59:04 +02:00
|
|
|
|
// ── Composant principal ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
2026-06-03 01:04:07 +02:00
|
|
|
|
items: NewsItem[];
|
|
|
|
|
|
loading: boolean;
|
2026-06-03 00:59:04 +02:00
|
|
|
|
onRefresh: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function NewsTab({ items, loading, onRefresh }: Props) {
|
2026-06-27 22:53:46 +02:00
|
|
|
|
const [selectedCats, setSelectedCats] = useState<string[]>([]);
|
2026-06-27 22:22:14 +02:00
|
|
|
|
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
|
|
|
|
|
|
const [priorityOnly, setPriorityOnly] = useState(false);
|
|
|
|
|
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
2026-06-27 17:17:44 +02:00
|
|
|
|
const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null);
|
2026-06-27 22:28:39 +02:00
|
|
|
|
const [othersOpen, setOthersOpen] = useState(false);
|
2026-06-08 14:33:23 +02:00
|
|
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!autoRefresh) { if (intervalRef.current) clearInterval(intervalRef.current); return; }
|
2026-06-27 17:17:44 +02:00
|
|
|
|
intervalRef.current = setInterval(() => { onRefresh(); setLastRefreshAt(new Date()); }, 60_000);
|
2026-06-08 14:33:23 +02:00
|
|
|
|
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
|
|
|
|
|
}, [autoRefresh, onRefresh]);
|
|
|
|
|
|
|
2026-06-27 22:22:14 +02:00
|
|
|
|
const toggleCat = (cat: string) => {
|
2026-06-27 22:53:46 +02:00
|
|
|
|
setSelectedCats(prev =>
|
|
|
|
|
|
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
|
|
|
|
|
);
|
2026-06-27 22:22:14 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
// Filtre + tri — articles avec catégories macro et articles sans
|
|
|
|
|
|
const { topFiltered, otherItems } = useMemo(() => {
|
2026-06-27 22:53:46 +02:00
|
|
|
|
const hasActiveFilter = selectedCats.length > 0 || filterDir !== "all" || priorityOnly;
|
2026-06-27 22:28:39 +02:00
|
|
|
|
|
|
|
|
|
|
// Articles filtrés (tous)
|
|
|
|
|
|
const allFiltered = items
|
|
|
|
|
|
.filter(item => {
|
|
|
|
|
|
if (priorityOnly && !item.categories.some(c => HIGH_PRIO.has(c))) return false;
|
2026-06-27 22:53:46 +02:00
|
|
|
|
if (selectedCats.length > 0 && !item.categories.some(c => selectedCats.includes(c))) return false;
|
2026-06-27 22:28:39 +02:00
|
|
|
|
if (filterDir !== "all" && !item.impacts.some(i => i.direction === filterDir)) return false;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
})
|
|
|
|
|
|
.sort((a, b) => scoreItem(b) - scoreItem(a));
|
|
|
|
|
|
|
|
|
|
|
|
// TOP NEWS : articles reconnus (≥1 catégorie macro)
|
|
|
|
|
|
const top = allFiltered.filter(item => item.categories.some(c => PRIORITY_CATS_SET.has(c)));
|
|
|
|
|
|
|
|
|
|
|
|
// AUTRES : articles sans étiquette macro — uniquement quand aucun filtre actif
|
|
|
|
|
|
const others = hasActiveFilter
|
|
|
|
|
|
? []
|
|
|
|
|
|
: items
|
|
|
|
|
|
.filter(item => !item.categories.some(c => PRIORITY_CATS_SET.has(c)))
|
|
|
|
|
|
.sort((a, b) => scoreItem(b) - scoreItem(a));
|
|
|
|
|
|
|
|
|
|
|
|
return { topFiltered: top, otherItems: others };
|
2026-06-27 22:22:14 +02:00
|
|
|
|
}, [items, selectedCats, filterDir, priorityOnly]);
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
// Boutons de catégories : dynamiques + catégories "toujours visibles"
|
2026-06-03 01:04:07 +02:00
|
|
|
|
const activeCats = useMemo(() => {
|
2026-06-27 22:53:46 +02:00
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
for (const item of items) item.categories.forEach(c => seen.add(c));
|
|
|
|
|
|
ALWAYS_VISIBLE.forEach(c => seen.add(c));
|
|
|
|
|
|
return PRIORITY_CATS.filter(c => seen.has(c));
|
2026-06-03 01:04:07 +02:00
|
|
|
|
}, [items]);
|
2026-06-03 00:59:04 +02:00
|
|
|
|
|
2026-06-27 22:53:46 +02:00
|
|
|
|
const anyFilter = selectedCats.length > 0 || filterDir !== "all" || priorityOnly;
|
|
|
|
|
|
const clearFilters = () => { setSelectedCats([]); setFilterDir("all"); setPriorityOnly(false); };
|
2026-06-03 01:04:07 +02:00
|
|
|
|
|
2026-06-03 00:59:04 +02:00
|
|
|
|
return (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div className="space-y-3">
|
2026-06-27 17:17:44 +02:00
|
|
|
|
{/* ── Barre filtres ──────────────────────────────────────────────────── */}
|
|
|
|
|
|
<div className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-2.5 space-y-2">
|
|
|
|
|
|
|
|
|
|
|
|
{/* Ligne 1 : contrôles + direction + prioritaire */}
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<button onClick={() => setAutoRefresh(a => !a)}
|
|
|
|
|
|
className={`flex items-center gap-1.5 text-[9px] px-2 py-1 rounded-full border transition-colors shrink-0 ${
|
|
|
|
|
|
autoRefresh
|
|
|
|
|
|
? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10"
|
|
|
|
|
|
: "text-slate-600 border-slate-700/30 hover:text-slate-400"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
<span className={`w-1.5 h-1.5 rounded-full ${autoRefresh ? "bg-emerald-500 animate-pulse" : "bg-slate-600"}`} />
|
|
|
|
|
|
{autoRefresh ? "Live 1min" : "Pause"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button onClick={() => { onRefresh(); setLastRefreshAt(new Date()); }} disabled={loading}
|
|
|
|
|
|
className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 disabled:opacity-40 shrink-0 transition-colors">
|
|
|
|
|
|
<RefreshCw size={9} className={loading ? "animate-spin" : ""} />
|
|
|
|
|
|
{lastRefreshAt
|
|
|
|
|
|
? lastRefreshAt.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })
|
|
|
|
|
|
: "Actualiser"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-3 bg-slate-700/60 shrink-0" />
|
|
|
|
|
|
|
|
|
|
|
|
<span className="text-[9px] text-amber-400 uppercase tracking-wider font-semibold shrink-0">
|
|
|
|
|
|
Direction
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{(["all", "bullish", "bearish"] as const).map(d => (
|
|
|
|
|
|
<button key={d} onClick={() => setFilterDir(d)}
|
|
|
|
|
|
className={`text-[10px] px-2 py-0.5 rounded-full font-semibold transition-colors border shrink-0 ${
|
|
|
|
|
|
filterDir === d
|
|
|
|
|
|
? d === "bullish" ? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
|
|
|
|
|
: d === "bearish" ? "bg-red-500/20 text-red-400 border-red-500/30"
|
|
|
|
|
|
: "bg-slate-700 text-slate-300 border-slate-600"
|
|
|
|
|
|
: "text-slate-500 border-slate-700/40 hover:text-slate-300"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{d === "all" ? "Tous" : d === "bullish" ? "↑ Haussier" : "↓ Baissier"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-3 bg-slate-700/60 shrink-0" />
|
|
|
|
|
|
|
|
|
|
|
|
<button onClick={() => setPriorityOnly(p => !p)}
|
|
|
|
|
|
className={`flex items-center gap-1 text-[9px] px-2 py-0.5 rounded-full font-semibold border transition-colors shrink-0 ${
|
|
|
|
|
|
priorityOnly
|
|
|
|
|
|
? "bg-amber-500/20 text-amber-400 border-amber-500/30"
|
|
|
|
|
|
: "text-slate-500 border-slate-700/40 hover:text-slate-300"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
<Zap size={9} /> Prioritaire
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
|
|
|
|
|
<span className="text-[10px] text-slate-600">
|
2026-06-27 22:28:39 +02:00
|
|
|
|
{loading ? "…" : `${topFiltered.length} article${topFiltered.length !== 1 ? "s" : ""}`}
|
2026-06-03 01:04:07 +02:00
|
|
|
|
</span>
|
2026-06-27 17:17:44 +02:00
|
|
|
|
{anyFilter && (
|
|
|
|
|
|
<button onClick={clearFilters}
|
|
|
|
|
|
className="text-[9px] text-slate-600 hover:text-slate-400 transition-colors">
|
|
|
|
|
|
× Effacer
|
2026-06-08 14:33:23 +02:00
|
|
|
|
</button>
|
2026-06-27 17:17:44 +02:00
|
|
|
|
)}
|
2026-06-03 01:04:07 +02:00
|
|
|
|
</div>
|
2026-06-27 17:17:44 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-27 22:22:14 +02:00
|
|
|
|
{/* Ligne 2 : catégories multi-select */}
|
2026-06-27 17:17:44 +02:00
|
|
|
|
{activeCats.length > 0 && (
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span className="text-[9px] text-amber-400 uppercase tracking-wider font-semibold shrink-0">
|
|
|
|
|
|
Catégorie
|
|
|
|
|
|
</span>
|
2026-06-27 22:53:46 +02:00
|
|
|
|
<button onClick={() => setSelectedCats([])}
|
2026-06-27 17:17:44 +02:00
|
|
|
|
className={`text-[10px] px-2 py-0.5 rounded-full font-semibold border transition-colors shrink-0 ${
|
2026-06-27 22:53:46 +02:00
|
|
|
|
selectedCats.length === 0
|
2026-06-27 17:17:44 +02:00
|
|
|
|
? "bg-slate-700 text-slate-300 border-slate-600"
|
|
|
|
|
|
: "text-slate-500 border-slate-700/40 hover:text-slate-300"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
Toutes
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{activeCats.map(cat => {
|
2026-06-27 22:22:14 +02:00
|
|
|
|
const meta = CATEGORY_META[cat];
|
2026-06-27 22:53:46 +02:00
|
|
|
|
const isActive = selectedCats.includes(cat);
|
2026-06-03 01:04:07 +02:00
|
|
|
|
return (
|
2026-06-27 22:22:14 +02:00
|
|
|
|
<button key={cat} onClick={() => toggleCat(cat)}
|
2026-06-27 17:17:44 +02:00
|
|
|
|
className={`flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full font-semibold border transition-colors shrink-0 ${
|
|
|
|
|
|
isActive
|
|
|
|
|
|
? (meta?.color ?? "bg-slate-700 text-slate-300 border-slate-600")
|
|
|
|
|
|
: "text-slate-500 border-slate-700/40 hover:text-slate-300"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{meta?.icon}
|
|
|
|
|
|
{meta?.label ?? cat}
|
2026-06-03 01:04:07 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-03 00:59:04 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
{/* ── TOP NEWS ────────────────────────────────────────────────────────── */}
|
2026-06-03 00:59:04 +02:00
|
|
|
|
{loading && items.length === 0 ? (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div className="flex flex-col items-center justify-center py-16 text-slate-600 gap-3">
|
|
|
|
|
|
<Loader2 size={24} className="animate-spin" />
|
2026-06-03 00:59:04 +02:00
|
|
|
|
<span className="text-sm">Chargement des actualités…</span>
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<span className="text-[11px] text-slate-700">InvestingLive · Reuters · Bloomberg</span>
|
2026-06-03 00:59:04 +02:00
|
|
|
|
</div>
|
2026-06-27 22:28:39 +02:00
|
|
|
|
) : topFiltered.length === 0 && otherItems.length === 0 ? (
|
2026-06-03 00:59:04 +02:00
|
|
|
|
<div className="text-center py-12 text-slate-600 text-sm">
|
|
|
|
|
|
Aucune actualité pour ce filtre.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-06-27 22:28:39 +02:00
|
|
|
|
<>
|
|
|
|
|
|
{/* Articles macro / top news */}
|
|
|
|
|
|
{topFiltered.length > 0 && (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{topFiltered.map(item => (
|
|
|
|
|
|
<NewsCard key={item.id} item={item} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Section "Autres actualités" — articles sans étiquette macro */}
|
|
|
|
|
|
{otherItems.length > 0 && (
|
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setOthersOpen(o => !o)}
|
|
|
|
|
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-800 bg-slate-900/50 text-slate-600 hover:text-slate-400 transition-colors text-[10px] font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
{othersOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
|
|
|
|
<span>Autres actualités</span>
|
|
|
|
|
|
<span className="ml-auto text-slate-700">{otherItems.length} article{otherItems.length !== 1 ? "s" : ""} non reconnus</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{othersOpen && (
|
|
|
|
|
|
<div className="mt-2 space-y-2">
|
|
|
|
|
|
{otherItems.map(item => (
|
|
|
|
|
|
<NewsCard key={item.id} item={item} secondary />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-06-03 00:59:04 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── NewsCard ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boolean }) {
|
2026-06-03 00:59:04 +02:00
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
|
|
|
2026-06-03 01:04:07 +02:00
|
|
|
|
const overallDir =
|
2026-06-27 17:17:44 +02:00
|
|
|
|
item.impacts.some(i => i.direction === "bullish") && item.impacts.some(i => i.direction === "bearish") ? "mixed"
|
|
|
|
|
|
: item.impacts.some(i => i.direction === "bullish") ? "bullish"
|
|
|
|
|
|
: item.impacts.some(i => i.direction === "bearish") ? "bearish"
|
2026-06-03 00:59:04 +02:00
|
|
|
|
: "neutral";
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
const isPriority = !secondary && item.categories.some(c =>
|
2026-06-03 01:04:07 +02:00
|
|
|
|
["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État"].includes(c)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
const borderCls = secondary
|
|
|
|
|
|
? "border-slate-800/50"
|
|
|
|
|
|
: overallDir === "bullish" ? "border-emerald-500/25"
|
|
|
|
|
|
: overallDir === "bearish" ? "border-red-500/25"
|
|
|
|
|
|
: overallDir === "mixed" ? "border-amber-500/20"
|
|
|
|
|
|
: "border-slate-700/30";
|
2026-06-03 00:59:04 +02:00
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
const bgCls = secondary
|
|
|
|
|
|
? "bg-slate-900/30"
|
|
|
|
|
|
: overallDir === "bullish" ? "bg-emerald-500/5"
|
|
|
|
|
|
: overallDir === "bearish" ? "bg-red-500/5"
|
|
|
|
|
|
: "bg-slate-800/30";
|
2026-06-03 00:59:04 +02:00
|
|
|
|
|
2026-06-27 17:17:44 +02:00
|
|
|
|
const topCat = PRIORITY_CATS.find(p => item.categories.includes(p));
|
2026-06-03 01:04:07 +02:00
|
|
|
|
const topMeta = topCat ? CATEGORY_META[topCat] : null;
|
|
|
|
|
|
|
2026-06-03 00:59:04 +02:00
|
|
|
|
return (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div className={`rounded-xl border ${borderCls} ${bgCls} overflow-hidden ${isPriority ? "ring-1 ring-offset-0 ring-amber-500/20" : ""}`}>
|
2026-06-03 00:59:04 +02:00
|
|
|
|
<div className="p-3">
|
2026-06-29 12:17:11 +02:00
|
|
|
|
{/* Heure · Analyse · Source · Catégorie · Prioritaire */}
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
2026-06-28 15:01:44 +02:00
|
|
|
|
<span className="text-[9px] text-white whitespace-nowrap shrink-0">{formatRelativeTime(item.publishedAt)}</span>
|
2026-06-29 12:17:11 +02:00
|
|
|
|
{!secondary && (
|
|
|
|
|
|
<button onClick={() => setExpanded(e => !e)}
|
|
|
|
|
|
className="text-[9px] text-slate-400 hover:text-white px-1.5 py-0.5 rounded-full border border-slate-600/50 transition-colors whitespace-nowrap shrink-0">
|
|
|
|
|
|
{expanded ? "▲" : "▼ Analyse"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400"}`}>
|
|
|
|
|
|
{item.source}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{topMeta && topCat && (
|
|
|
|
|
|
<span className={`flex items-center gap-1 text-[9px] font-semibold px-2 py-0.5 rounded-full border ${topMeta.color}`}>
|
|
|
|
|
|
{topMeta.icon}
|
|
|
|
|
|
{topMeta.label}
|
2026-06-03 00:59:04 +02:00
|
|
|
|
</span>
|
2026-06-03 01:04:07 +02:00
|
|
|
|
)}
|
|
|
|
|
|
{isPriority && (
|
|
|
|
|
|
<span className="text-[9px] text-amber-500 font-bold">⚡ Prioritaire</span>
|
|
|
|
|
|
)}
|
2026-06-03 00:59:04 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-29 12:17:11 +02:00
|
|
|
|
{/* Titre seul */}
|
|
|
|
|
|
<div className="flex items-start gap-1 mb-1.5">
|
2026-06-28 14:13:17 +02:00
|
|
|
|
<a href={item.url} target="_blank" rel="noopener noreferrer"
|
|
|
|
|
|
className="group flex items-start gap-1 flex-1 min-w-0">
|
|
|
|
|
|
<span className={`text-[12px] leading-snug font-medium group-hover:text-white transition-colors ${secondary ? "text-slate-400" : "text-slate-200"}`}>
|
|
|
|
|
|
{item.title}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" />
|
|
|
|
|
|
</a>
|
2026-06-27 22:46:48 +02:00
|
|
|
|
</div>
|
2026-06-03 00:59:04 +02:00
|
|
|
|
|
2026-06-27 22:28:39 +02:00
|
|
|
|
{item.summary && !secondary && (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<p className="text-[11px] text-slate-500 mb-1.5 leading-relaxed line-clamp-2">
|
2026-06-03 00:59:04 +02:00
|
|
|
|
{item.summary}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-27 17:17:44 +02:00
|
|
|
|
{item.impacts.length > 0 && (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
2026-06-27 17:17:44 +02:00
|
|
|
|
{item.impacts.map(imp => (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div key={imp.ccy} title={imp.reason}
|
2026-06-03 00:59:04 +02:00
|
|
|
|
className={`flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full font-semibold ${
|
|
|
|
|
|
imp.direction === "bullish" ? "bg-emerald-500/15 text-emerald-400" :
|
|
|
|
|
|
imp.direction === "bearish" ? "bg-red-500/15 text-red-400" :
|
|
|
|
|
|
"bg-slate-700/40 text-slate-400"
|
2026-06-03 01:04:07 +02:00
|
|
|
|
}`}>
|
2026-06-03 00:59:04 +02:00
|
|
|
|
{CCY_FLAGS[imp.ccy]} {imp.ccy}
|
|
|
|
|
|
{imp.direction === "bullish" ? <TrendingUp size={9} /> :
|
|
|
|
|
|
imp.direction === "bearish" ? <TrendingDown size={9} /> :
|
|
|
|
|
|
<Minus size={9} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-27 17:17:44 +02:00
|
|
|
|
{expanded && item.impacts.length > 0 && (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div className="mt-2 space-y-1.5 border-t border-slate-700/30 pt-2">
|
2026-06-27 17:17:44 +02:00
|
|
|
|
{item.impacts.map(imp => (
|
2026-06-03 01:04:07 +02:00
|
|
|
|
<div key={imp.ccy} className="flex items-start gap-2 text-[10px]">
|
|
|
|
|
|
<span className={`shrink-0 font-bold whitespace-nowrap ${
|
2026-06-03 00:59:04 +02:00
|
|
|
|
imp.direction === "bullish" ? "text-emerald-400" :
|
|
|
|
|
|
imp.direction === "bearish" ? "text-red-400" : "text-slate-500"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{CCY_FLAGS[imp.ccy]} {imp.ccy} {imp.direction === "bullish" ? "↑" : imp.direction === "bearish" ? "↓" : "→"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-slate-500 leading-relaxed">{imp.reason}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|