mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-15 21:48:05 +00:00
auto: sync 2026-06-03 01:04
This commit is contained in:
@@ -779,6 +779,59 @@ export default function CurrencyCard({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* STIR 3M — Taux interbancaire court terme */}
|
||||
{inds?.stir3m?.value != null && (() => {
|
||||
const stir = inds.stir3m!.value!;
|
||||
const stirPrev = inds.stir3m!.prev;
|
||||
const stirTrend = inds.stir3m!.trend;
|
||||
// Spread STIR - Taux directeur
|
||||
const stirSpread = policyRateValue !== null
|
||||
? parseFloat((stir - policyRateValue).toFixed(2)) : null;
|
||||
// Signal : spread positif = marché price des hausses (hawkish)
|
||||
const stirSig: SignalDir =
|
||||
stirTrend === "up" ? "bearish" // hausse STIR = crédit plus cher/risqué
|
||||
: stirTrend === "down" ? "bullish" // baisse STIR = crédit plus sûr/souple
|
||||
: "neutral";
|
||||
const spreadSig: SignalDir =
|
||||
stirSpread !== null && stirSpread > 0.1 ? "bullish" // STIR > CT = hawkish bias
|
||||
: stirSpread !== null && stirSpread < -0.1 ? "bearish" // STIR < CT = dovish bias
|
||||
: "neutral";
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-[12px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-600">→</span>
|
||||
<span className="text-slate-400">STIR 3M</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`font-semibold tabular-nums ${sigColor(stirSig)}`}>
|
||||
{stir.toFixed(2)}%
|
||||
</span>
|
||||
{stirTrend && (
|
||||
<span className={`text-[10px] ${sigColor(stirSig)}`}>
|
||||
{stirTrend === "up" ? "↑" : stirTrend === "down" ? "↓" : "→"}
|
||||
{stirPrev !== null ? ` ${stirPrev.toFixed(2)}%` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{stirSpread !== null && (
|
||||
<div className="flex items-center justify-between text-[12px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-600">→</span>
|
||||
<span className="text-slate-400">Spread STIR−CT</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${sigColor(spreadSig)}`}
|
||||
title={stirSpread > 0 ? "STIR > taux directeur → marché price des hausses" : stirSpread < 0 ? "STIR < taux directeur → marché price des baisses" : "STIR ≈ taux directeur"}
|
||||
>
|
||||
{stirSpread > 0 ? "+" : ""}{Math.round(stirSpread * 100)}bps
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
// Taux réel = Taux directeur − CPI YoY headline (= Inflation Rate YoY)
|
||||
// On n'utilise PAS Core CPI comme fallback : Core < Headline → gonflerait le taux réel
|
||||
|
||||
+227
-148
@@ -1,40 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus, Loader2, Zap } from "lucide-react";
|
||||
import {
|
||||
ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus,
|
||||
Loader2, Radio, AlertTriangle, Landmark, Globe, BarChart2, Zap,
|
||||
} from "lucide-react";
|
||||
import type { NewsItem } from "@/app/api/news/route";
|
||||
import type { Currency } from "@/lib/types";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CCY_FLAGS: Record<Currency, string> = {
|
||||
USD: "🇺🇸", EUR: "🇪🇺", GBP: "🇬🇧", JPY: "🇯🇵",
|
||||
CHF: "🇨🇭", CAD: "🇨🇦", AUD: "🇦🇺", NZD: "🇳🇿",
|
||||
};
|
||||
|
||||
const CCY_LIST: Currency[] = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
"InvestingLive": "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||
"Reuters": "bg-orange-500/20 text-orange-400 border-orange-500/30",
|
||||
"Bloomberg Economics": "bg-sky-500/20 text-sky-400 border-sky-500/30",
|
||||
"Bloomberg CB": "bg-sky-500/20 text-sky-400 border-sky-500/30",
|
||||
"Bloomberg FX": "bg-sky-500/20 text-sky-400 border-sky-500/30",
|
||||
// Catégories prioritaires avec icône et couleur
|
||||
const CATEGORY_META: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
|
||||
"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" },
|
||||
};
|
||||
|
||||
const CAT_COLORS: Record<string, string> = {
|
||||
"Risk-Off": "bg-red-500/15 text-red-400",
|
||||
"Risk-On": "bg-emerald-500/15 text-emerald-400",
|
||||
"Géopolitique": "bg-purple-500/15 text-purple-400",
|
||||
"Énergie": "bg-yellow-500/15 text-yellow-400",
|
||||
"Banque Centrale": "bg-blue-500/15 text-blue-400",
|
||||
"Commodités": "bg-orange-500/15 text-orange-400",
|
||||
"Chine": "bg-red-600/15 text-red-400",
|
||||
"Données Macro": "bg-slate-500/15 text-slate-400",
|
||||
"Commerce": "bg-indigo-500/15 text-indigo-400",
|
||||
"Agriculture": "bg-green-500/15 text-green-400",
|
||||
"Or": "bg-yellow-600/15 text-yellow-400",
|
||||
"Métaux": "bg-gray-400/15 text-gray-300",
|
||||
// Ordre d'affichage des filtres catégorie (par priorité signal)
|
||||
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",
|
||||
];
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
"InvestingLive": "bg-amber-500/20 text-amber-400",
|
||||
"Reuters": "bg-orange-500/20 text-orange-400",
|
||||
"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",
|
||||
};
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
@@ -44,41 +59,48 @@ function formatRelativeTime(isoDate: string): string {
|
||||
if (mins < 60) return `il y a ${mins}min`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `il y a ${hrs}h`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `il y a ${days}j`;
|
||||
return `il y a ${Math.floor(hrs / 24)}j`;
|
||||
}
|
||||
|
||||
// ── Composant principal ───────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
items: NewsItem[];
|
||||
loading: boolean;
|
||||
items: NewsItem[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function NewsTab({ items, loading, onRefresh }: Props) {
|
||||
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
|
||||
const [filterCat, setFilterCat] = useState<string | "ALL">("ALL");
|
||||
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return items.filter(item => {
|
||||
if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false;
|
||||
if (filterDir !== "all") {
|
||||
const hasDir = filterCcy === "ALL"
|
||||
? item.impacts.some(i => i.direction === filterDir)
|
||||
: item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir);
|
||||
if (!hasDir) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [items, filterCcy, filterDir]);
|
||||
const filtered = useMemo(() => items.filter(item => {
|
||||
if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false;
|
||||
if (filterCat !== "ALL" && !item.categories.includes(filterCat)) return false;
|
||||
if (filterDir !== "all") {
|
||||
const hasDir = filterCcy === "ALL"
|
||||
? item.impacts.some(i => i.direction === filterDir)
|
||||
: item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir);
|
||||
if (!hasDir) return false;
|
||||
}
|
||||
return true;
|
||||
}), [items, filterCcy, filterCat, filterDir]);
|
||||
|
||||
// Compter les impacts par devise pour les badges de filtre
|
||||
// Catégories présentes dans le feed actuel
|
||||
const activeCats = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const item of items) item.categories.forEach(c => set.add(c));
|
||||
return PRIORITY_CATS.filter(c => set.has(c));
|
||||
}, [items]);
|
||||
|
||||
// Comptage par devise
|
||||
const ccyCount = useMemo(() => {
|
||||
const counts: Partial<Record<Currency, { bull: number; bear: number }>> = {};
|
||||
const counts: Partial<Record<Currency, { bull: number; bear: number; total: number }>> = {};
|
||||
for (const item of items) {
|
||||
for (const imp of item.impacts) {
|
||||
if (!counts[imp.ccy]) counts[imp.ccy] = { bull: 0, bear: 0 };
|
||||
if (!counts[imp.ccy]) counts[imp.ccy] = { bull: 0, bear: 0, total: 0 };
|
||||
counts[imp.ccy]!.total++;
|
||||
if (imp.direction === "bullish") counts[imp.ccy]!.bull++;
|
||||
if (imp.direction === "bearish") counts[imp.ccy]!.bear++;
|
||||
}
|
||||
@@ -86,79 +108,133 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
|
||||
return counts;
|
||||
}, [items]);
|
||||
|
||||
// Résumé par devise (pour headline)
|
||||
const ccySummary = useMemo(() => {
|
||||
const summary: Partial<Record<Currency, "bullish" | "bearish" | "mixed" | "neutral">> = {};
|
||||
for (const [ccy, cnt] of Object.entries(ccyCount) as [Currency, { bull: number; bear: number }][]) {
|
||||
if (cnt.bull > cnt.bear * 1.5) summary[ccy] = "bullish";
|
||||
else if (cnt.bear > cnt.bull * 1.5) summary[ccy] = "bearish";
|
||||
else if (cnt.bull > 0 && cnt.bear > 0) summary[ccy] = "mixed";
|
||||
else summary[ccy] = "neutral";
|
||||
}
|
||||
return summary;
|
||||
}, [ccyCount]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de filtres */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">
|
||||
Filtrer par devise
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-3">
|
||||
{/* ── Headline résumé par devise ──────────────────────────────────────── */}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">
|
||||
Biais actualités par devise
|
||||
</span>
|
||||
<button onClick={onRefresh} disabled={loading}
|
||||
className="flex items-center gap-1 text-[9px] text-slate-600 hover:text-slate-400 disabled:opacity-50">
|
||||
<RefreshCw size={9} className={loading ? "animate-spin" : ""} />
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
||||
{CCY_LIST.map(ccy => {
|
||||
const cnt = ccyCount[ccy];
|
||||
const summary = ccySummary[ccy] ?? "neutral";
|
||||
const bgCls = summary === "bullish" ? "bg-emerald-500/10 border-emerald-500/20"
|
||||
: summary === "bearish" ? "bg-red-500/10 border-red-500/20"
|
||||
: summary === "mixed" ? "bg-amber-500/10 border-amber-500/20"
|
||||
: "bg-slate-800/40 border-slate-700/30";
|
||||
const arrow = summary === "bullish" ? "↑" : summary === "bearish" ? "↓"
|
||||
: summary === "mixed" ? "↕" : "→";
|
||||
const arrowCls = summary === "bullish" ? "text-emerald-400" : summary === "bearish" ? "text-red-400"
|
||||
: summary === "mixed" ? "text-amber-400" : "text-slate-600";
|
||||
return (
|
||||
<button key={ccy} onClick={() => setFilterCcy(filterCcy === ccy ? "ALL" : ccy)}
|
||||
className={`flex flex-col items-center gap-0.5 p-2 rounded-lg border transition-all ${bgCls} ${filterCcy === ccy ? "ring-1 ring-amber-500/50" : ""}`}>
|
||||
<span className="text-base">{CCY_FLAGS[ccy]}</span>
|
||||
<span className="text-[10px] font-bold text-slate-300">{ccy}</span>
|
||||
<span className={`text-[11px] font-bold ${arrowCls}`}>{arrow}</span>
|
||||
{cnt && (
|
||||
<span className="text-[8px] text-slate-600">{cnt.bull}↑ {cnt.bear}↓</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filtres ─────────────────────────────────────────────────────────── */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3 space-y-2">
|
||||
{/* Direction */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] text-slate-600 uppercase tracking-wider w-16 shrink-0">Direction</span>
|
||||
<div className="flex gap-1.5">
|
||||
{(["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 ${
|
||||
<button key={d} onClick={() => setFilterDir(d)}
|
||||
className={`text-[10px] px-2.5 py-1 rounded-full font-semibold transition-colors border ${
|
||||
filterDir === d
|
||||
? d === "bullish" ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||
: d === "bearish" ? "bg-red-500/20 text-red-400 border border-red-500/30"
|
||||
: "bg-slate-700 text-slate-300"
|
||||
: "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
? 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>
|
||||
))}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-slate-300 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={10} className={loading ? "animate-spin" : ""} />
|
||||
{loading ? "..." : "Actualiser"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilterCcy("ALL")}
|
||||
className={`text-[11px] px-3 py-1 rounded-full font-semibold transition-colors ${
|
||||
filterCcy === "ALL" ? "bg-slate-700 text-white" : "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
Toutes ({items.length})
|
||||
</button>
|
||||
{CCY_LIST.map(ccy => {
|
||||
const cnt = ccyCount[ccy];
|
||||
if (!cnt) return null;
|
||||
const isActive = filterCcy === ccy;
|
||||
return (
|
||||
<button
|
||||
key={ccy}
|
||||
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
|
||||
className={`flex items-center gap-1.5 text-[11px] px-3 py-1 rounded-full font-semibold transition-colors ${
|
||||
isActive ? "bg-amber-500/20 text-amber-400 border border-amber-500/30" : "text-slate-500 hover:text-slate-300 border border-slate-700/50"
|
||||
}`}
|
||||
>
|
||||
<span>{CCY_FLAGS[ccy]}</span>
|
||||
<span>{ccy}</span>
|
||||
<span className="flex items-center gap-0.5 text-[9px]">
|
||||
{cnt.bull > 0 && <span className="text-emerald-400">↑{cnt.bull}</span>}
|
||||
{cnt.bear > 0 && <span className="text-red-400">↓{cnt.bear}</span>}
|
||||
</span>
|
||||
{/* Catégories */}
|
||||
{activeCats.length > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[9px] text-slate-600 uppercase tracking-wider w-16 shrink-0 pt-1">Catégorie</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button onClick={() => setFilterCat("ALL")}
|
||||
className={`text-[10px] px-2.5 py-1 rounded-full font-semibold border transition-colors ${
|
||||
filterCat === "ALL" ? "bg-slate-700 text-slate-300 border-slate-600" : "text-slate-600 border-slate-700/40 hover:text-slate-400"
|
||||
}`}>
|
||||
Toutes
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{activeCats.map(cat => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
const isActive = filterCat === cat;
|
||||
return (
|
||||
<button key={cat} onClick={() => setFilterCat(filterCat === cat ? "ALL" : cat)}
|
||||
className={`flex items-center gap-1 text-[10px] px-2.5 py-1 rounded-full font-semibold border transition-colors ${
|
||||
isActive ? (meta?.color ?? "bg-slate-700 text-slate-300 border-slate-600")
|
||||
: "text-slate-600 border-slate-700/40 hover:text-slate-400"
|
||||
}`}>
|
||||
{meta?.icon}
|
||||
{meta?.label ?? cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Liste de news */}
|
||||
{/* ── Compteur résultats ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[11px] text-slate-600">
|
||||
{loading ? "Chargement…" : `${filtered.length} article${filtered.length > 1 ? "s" : ""}`}
|
||||
{filterCcy !== "ALL" && ` · ${CCY_FLAGS[filterCcy]} ${filterCcy}`}
|
||||
{filterCat !== "ALL" && ` · ${filterCat}`}
|
||||
</span>
|
||||
{(filterCcy !== "ALL" || filterCat !== "ALL" || filterDir !== "all") && (
|
||||
<button onClick={() => { setFilterCcy("ALL"); setFilterCat("ALL"); setFilterDir("all"); }}
|
||||
className="text-[10px] text-slate-600 hover:text-slate-400">
|
||||
Effacer filtres ×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Liste ───────────────────────────────────────────────────────────── */}
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-slate-600">
|
||||
<Loader2 size={20} className="animate-spin mr-2" />
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-600 gap-3">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
<span className="text-sm">Chargement des actualités…</span>
|
||||
<span className="text-[11px] text-slate-700">InvestingLive · Reuters · Bloomberg</span>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-600 text-sm">
|
||||
@@ -167,7 +243,11 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map(item => (
|
||||
<NewsCard key={item.id} item={item} activeCcy={filterCcy === "ALL" ? null : filterCcy} />
|
||||
<NewsCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeCcy={filterCcy === "ALL" ? null : filterCcy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -184,98 +264,97 @@ function NewsCard({ item, activeCcy }: { item: NewsItem; activeCcy: Currency | n
|
||||
? item.impacts.filter(i => i.ccy === activeCcy)
|
||||
: item.impacts;
|
||||
|
||||
const overallDir = visibleImpacts.some(i => i.direction === "bullish") && visibleImpacts.some(i => i.direction === "bearish")
|
||||
? "mixed"
|
||||
const overallDir =
|
||||
visibleImpacts.some(i => i.direction === "bullish") && visibleImpacts.some(i => i.direction === "bearish") ? "mixed"
|
||||
: visibleImpacts.some(i => i.direction === "bullish") ? "bullish"
|
||||
: visibleImpacts.some(i => i.direction === "bearish") ? "bearish"
|
||||
: "neutral";
|
||||
|
||||
const borderColor =
|
||||
overallDir === "bullish" ? "border-emerald-500/20" :
|
||||
overallDir === "bearish" ? "border-red-500/20" :
|
||||
// Détecter les catégories prioritaires
|
||||
const isPriority = item.categories.some(c =>
|
||||
["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État"].includes(c)
|
||||
);
|
||||
|
||||
const borderCls =
|
||||
overallDir === "bullish" ? "border-emerald-500/25" :
|
||||
overallDir === "bearish" ? "border-red-500/25" :
|
||||
overallDir === "mixed" ? "border-amber-500/20" :
|
||||
"border-slate-700/30";
|
||||
|
||||
const bgColor =
|
||||
const bgCls =
|
||||
overallDir === "bullish" ? "bg-emerald-500/5" :
|
||||
overallDir === "bearish" ? "bg-red-500/5" :
|
||||
"bg-slate-800/30";
|
||||
|
||||
// Catégorie la plus prioritaire
|
||||
const topCat = PRIORITY_CATS.find(p => item.categories.includes(p));
|
||||
const topMeta = topCat ? CATEGORY_META[topCat] : null;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${borderColor} ${bgColor} overflow-hidden`}>
|
||||
<div className={`rounded-xl border ${borderCls} ${bgCls} overflow-hidden ${isPriority ? "ring-1 ring-offset-0 ring-amber-500/20" : ""}`}>
|
||||
<div className="p-3">
|
||||
{/* Header : source + date */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full border ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400 border-slate-700/30"}`}>
|
||||
{item.source}
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<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}
|
||||
</span>
|
||||
{item.categories.slice(0, 2).map(cat => (
|
||||
<span key={cat} className={`text-[9px] px-1.5 py-0.5 rounded-full ${CAT_COLORS[cat] ?? "bg-slate-700/20 text-slate-500"}`}>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-600 shrink-0">{formatRelativeTime(item.publishedAt)}</span>
|
||||
)}
|
||||
{isPriority && (
|
||||
<span className="text-[9px] text-amber-500 font-bold">⚡ Prioritaire</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-slate-600 shrink-0">{formatRelativeTime(item.publishedAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-start gap-1.5 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-[12px] text-slate-200 leading-snug font-medium group-hover:text-white">
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-start gap-1.5 mb-1.5">
|
||||
<span className="text-[12px] text-slate-200 leading-snug font-medium group-hover:text-white transition-colors">
|
||||
{item.title}
|
||||
</span>
|
||||
<ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" />
|
||||
</a>
|
||||
|
||||
{/* Summary si dispo */}
|
||||
{/* Résumé */}
|
||||
{item.summary && (
|
||||
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed line-clamp-2">
|
||||
<p className="text-[11px] text-slate-500 mb-1.5 leading-relaxed line-clamp-2">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Impact badges */}
|
||||
{/* Impact badges + bouton détail */}
|
||||
{visibleImpacts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{visibleImpacts.map(imp => (
|
||||
<div
|
||||
key={imp.ccy}
|
||||
<div key={imp.ccy} title={imp.reason}
|
||||
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"
|
||||
}`}
|
||||
title={imp.reason}
|
||||
>
|
||||
}`}>
|
||||
{CCY_FLAGS[imp.ccy]} {imp.ccy}
|
||||
{imp.direction === "bullish" ? <TrendingUp size={9} /> :
|
||||
imp.direction === "bearish" ? <TrendingDown size={9} /> :
|
||||
<Minus size={9} />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visibleImpacts.length > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="text-[9px] text-slate-600 hover:text-slate-400 px-1.5 py-0.5 rounded-full border border-slate-700/30"
|
||||
>
|
||||
{expanded ? "Masquer" : "Détail"}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setExpanded(e => !e)}
|
||||
className="text-[9px] text-slate-600 hover:text-slate-400 px-1.5 py-0.5 rounded-full border border-slate-700/30 ml-auto">
|
||||
{expanded ? "▲" : "▼ Analyse"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Détail des raisons */}
|
||||
{expanded && visibleImpacts.length > 0 && (
|
||||
<div className="mt-2 space-y-1 border-t border-slate-700/30 pt-2">
|
||||
<div className="mt-2 space-y-1.5 border-t border-slate-700/30 pt-2">
|
||||
{visibleImpacts.map(imp => (
|
||||
<div key={imp.ccy} className="flex items-start gap-1.5 text-[10px]">
|
||||
<span className={`shrink-0 font-bold ${
|
||||
<div key={imp.ccy} className="flex items-start gap-2 text-[10px]">
|
||||
<span className={`shrink-0 font-bold whitespace-nowrap ${
|
||||
imp.direction === "bullish" ? "text-emerald-400" :
|
||||
imp.direction === "bearish" ? "text-red-400" : "text-slate-500"
|
||||
}`}>
|
||||
|
||||
Reference in New Issue
Block a user