mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-13 12:38:04 +00:00
feat: dashboard v8 — COT, FX Weekly, Report, TvChart, Sentiment, News
Ajout des onglets COT, Weekly Report, TvChart. Refonte Sentiment, News, Calendar, Drivers. Nouvelles API cot-history et fx-weekly. Intégration FinancialJuice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e11d61eb4d
commit
1a2ea02b22
+138
-132
@@ -9,7 +9,7 @@ import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||
interface Props {
|
||||
events: CalendarEvent[];
|
||||
loading: boolean;
|
||||
nextWeekAvail: boolean; // nextweek.json disponible sur le CDN FF
|
||||
nextWeekAvail: boolean;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -23,12 +23,6 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
trade_balance: "Balance comm.",
|
||||
};
|
||||
|
||||
const IMPACT_COLOR: Record<string, string> = {
|
||||
high: "bg-red-500",
|
||||
medium: "bg-amber-400",
|
||||
low: "bg-gray-300",
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function isoToLocalDate(iso: string): string {
|
||||
@@ -37,9 +31,10 @@ function isoToLocalDate(iso: string): string {
|
||||
|
||||
function fmtDate(iso: string): { day: string; time: string } {
|
||||
const d = new Date(iso);
|
||||
const day = d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "short" });
|
||||
const time = d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||
return { day, time };
|
||||
return {
|
||||
day: d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "short" }),
|
||||
time: d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }),
|
||||
};
|
||||
}
|
||||
|
||||
function fmtDayLabel(dateStr: string): string {
|
||||
@@ -54,31 +49,19 @@ function todayIso(): string {
|
||||
|
||||
function nextMonday(): Date {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + (day === 0 ? 1 : 8 - day));
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + (now.getDay() === 0 ? 1 : 8 - now.getDay()));
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ── Week bounds ───────────────────────────────────────────────────────────────
|
||||
|
||||
function getWeekBounds() {
|
||||
const nm = nextMonday();
|
||||
|
||||
const currentStart = new Date(nm);
|
||||
currentStart.setDate(nm.getDate() - 7);
|
||||
const currentEnd = new Date(nm);
|
||||
currentEnd.setDate(nm.getDate() - 1);
|
||||
|
||||
const nextEnd = new Date(nm);
|
||||
nextEnd.setDate(nm.getDate() + 6);
|
||||
|
||||
const next2Start = new Date(nm);
|
||||
next2Start.setDate(nm.getDate() + 7);
|
||||
const next2End = new Date(nm);
|
||||
next2End.setDate(nm.getDate() + 13);
|
||||
|
||||
const currentStart = new Date(nm); currentStart.setDate(nm.getDate() - 7);
|
||||
const currentEnd = new Date(nm); currentEnd.setDate(nm.getDate() - 1);
|
||||
const nextEnd = new Date(nm); nextEnd.setDate(nm.getDate() + 6);
|
||||
const next2Start = new Date(nm); next2Start.setDate(nm.getDate() + 7);
|
||||
const next2End = new Date(nm); next2End.setDate(nm.getDate() + 13);
|
||||
const fmt = (d: Date) => d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
|
||||
return {
|
||||
currentWeekLabel: `${fmt(currentStart)} – ${fmt(currentEnd)}`,
|
||||
@@ -89,60 +72,76 @@ function getWeekBounds() {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
// ── Impact dot ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ImpactDot({ impact }: { impact: string }) {
|
||||
return <span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${IMPACT_COLOR[impact] ?? "bg-gray-300"}`} />;
|
||||
const cls = impact === "high" ? "bg-red-500"
|
||||
: impact === "medium" ? "bg-amber-400"
|
||||
: "bg-slate-600";
|
||||
return <span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${cls}`} />;
|
||||
}
|
||||
|
||||
// ── Event row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function EventRow({ ev, isChild, expanded, onToggle }: {
|
||||
ev: CalendarEvent; isChild: boolean; expanded: boolean; onToggle: () => void;
|
||||
}) {
|
||||
const { day, time } = fmtDate(ev.date);
|
||||
const meta = CURRENCY_META[ev.currency];
|
||||
|
||||
const rowCls = [
|
||||
"border-b border-gray-100 hover:bg-gray-50 transition-colors",
|
||||
isChild ? "bg-gray-50/70" : "",
|
||||
!ev.isPublished && ev.impact === "high" ? "border-l-2 border-l-red-400" : "",
|
||||
!ev.isPublished && ev.impact === "medium" ? "border-l-2 border-l-amber-400" : "",
|
||||
ev.isPublished ? "opacity-70" : "",
|
||||
].join(" ");
|
||||
const borderCls = !ev.isPublished && ev.impact === "high" ? "border-l-2 border-l-red-500"
|
||||
: !ev.isPublished && ev.impact === "medium" ? "border-l-2 border-l-amber-400"
|
||||
: "border-l-2 border-l-transparent";
|
||||
|
||||
return (
|
||||
<tr className={rowCls}>
|
||||
<tr className={`border-b border-slate-800/60 hover:bg-slate-800/30 transition-colors ${borderCls} ${ev.isPublished ? "opacity-60" : ""} ${isChild ? "bg-slate-900/40" : ""}`}>
|
||||
|
||||
<td className="py-2 px-3 whitespace-nowrap">
|
||||
<div className="text-xs font-medium text-gray-700">{day}</div>
|
||||
<div className="text-[10px] text-gray-400">{time}</div>
|
||||
<div className="text-xs font-medium text-slate-300">{day}</div>
|
||||
<div className="text-[10px] text-slate-600">{time}</div>
|
||||
</td>
|
||||
|
||||
<td className="py-2 px-2 whitespace-nowrap">
|
||||
<span className="text-sm">{meta?.flag}</span>
|
||||
<span className="ml-1 text-xs font-semibold text-gray-700">{ev.currency}</span>
|
||||
<span className="ml-1 text-xs font-semibold text-slate-300">{ev.currency}</span>
|
||||
</td>
|
||||
|
||||
<td className="py-2 px-3">
|
||||
<button
|
||||
onClick={ev.isGroupParent ? onToggle : undefined}
|
||||
className={`flex items-center gap-1 text-left text-sm ${ev.isGroupParent ? "cursor-pointer font-medium text-gray-800 hover:text-blue-600" : "text-gray-600"} ${isChild ? "pl-4 text-[11px]" : ""}`}
|
||||
className={`flex items-center gap-1 text-left text-[12px] ${
|
||||
ev.isGroupParent
|
||||
? "cursor-pointer font-medium text-slate-200 hover:text-amber-400"
|
||||
: "text-slate-400"
|
||||
} ${isChild ? "pl-4 text-[11px]" : ""}`}
|
||||
>
|
||||
{ev.isGroupParent && (expanded ? <ChevronDown size={12} className="text-gray-400 flex-shrink-0" /> : <ChevronRight size={12} className="text-gray-400 flex-shrink-0" />)}
|
||||
{isChild && <span className="text-gray-300 mr-1">↳</span>}
|
||||
{ev.isGroupParent && (
|
||||
expanded
|
||||
? <ChevronDown size={12} className="text-slate-500 flex-shrink-0" />
|
||||
: <ChevronRight size={12} className="text-slate-500 flex-shrink-0" />
|
||||
)}
|
||||
{isChild && <span className="text-slate-600 mr-1">↳</span>}
|
||||
{ev.title}
|
||||
</button>
|
||||
<div className="text-[9px] text-gray-400 mt-0.5 pl-4">{CATEGORY_LABELS[ev.category]}</div>
|
||||
<div className="text-[9px] text-slate-600 mt-0.5 pl-4">{CATEGORY_LABELS[ev.category]}</div>
|
||||
</td>
|
||||
|
||||
<td className="py-2 px-3 text-right">
|
||||
<span className="text-xs text-gray-500 tabular-nums">{ev.previous ?? "—"}</span>
|
||||
<span className="text-xs text-slate-500 tabular-nums">{ev.previous ?? "—"}</span>
|
||||
</td>
|
||||
|
||||
<td className="py-2 px-3 text-right">
|
||||
{ev.forecast
|
||||
? <span className="text-xs font-medium text-blue-600 tabular-nums">{ev.forecast}</span>
|
||||
: <span className="text-xs text-gray-300">—</span>}
|
||||
? <span className="text-xs font-medium text-amber-400 tabular-nums">{ev.forecast}</span>
|
||||
: <span className="text-xs text-slate-700">—</span>}
|
||||
</td>
|
||||
|
||||
<td className="py-2 px-3 text-right">
|
||||
{ev.actual
|
||||
? <span className={`text-xs font-semibold tabular-nums ${ev.isPublished ? "text-gray-800" : "text-gray-400"}`}>{ev.actual}</span>
|
||||
: <span className="text-xs text-gray-200">—</span>}
|
||||
? <span className={`text-xs font-semibold tabular-nums ${ev.isPublished ? "text-slate-200" : "text-slate-500"}`}>{ev.actual}</span>
|
||||
: <span className="text-xs text-slate-700">—</span>}
|
||||
</td>
|
||||
|
||||
<td className="py-2 px-3 text-center">
|
||||
<ImpactDot impact={ev.impact} />
|
||||
</td>
|
||||
@@ -150,44 +149,38 @@ function EventRow({ ev, isChild, expanded, onToggle }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type WeekTab = "current" | "next" | "next2" | "all";
|
||||
|
||||
export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [showLow, setShowLow] = useState(false);
|
||||
const [weekTab, setWeekTab] = useState<WeekTab>("all");
|
||||
const [fromDate, setFromDate] = useState<string>(todayIso());
|
||||
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [showLow, setShowLow] = useState(false);
|
||||
const [weekTab, setWeekTab] = useState<WeekTab>("all");
|
||||
const [fromDate, setFromDate] = useState<string>(todayIso());
|
||||
|
||||
const { currentWeekLabel, nextWeekLabel, next2WeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
|
||||
const { currentWeekLabel, nextWeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
|
||||
void nextMondayIso;
|
||||
|
||||
const toggle = (groupKey: string) =>
|
||||
setExpanded((prev) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupKey)) next.delete(groupKey); else next.add(groupKey);
|
||||
next.has(groupKey) ? next.delete(groupKey) : next.add(groupKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Filtrage
|
||||
const filtered = useMemo(() => {
|
||||
return events.filter((ev) => {
|
||||
if (filterCcy !== "ALL" && ev.currency !== filterCcy) return false;
|
||||
if (!showLow && ev.impact === "low") return false;
|
||||
if (ev.isGroupChild && ev.groupKey && !expanded.has(ev.groupKey)) return false;
|
||||
// Filtre semaine
|
||||
if (weekTab === "current" && ev.week !== "current") return false;
|
||||
if (weekTab === "next" && ev.week !== "next") return false;
|
||||
if (weekTab === "next2" && ev.week !== "next2") return false;
|
||||
// Filtre date depuis
|
||||
const evDate = isoToLocalDate(ev.date);
|
||||
if (evDate < fromDate) return false;
|
||||
return true;
|
||||
});
|
||||
}, [events, filterCcy, showLow, expanded, weekTab, fromDate]);
|
||||
const filtered = useMemo(() => events.filter(ev => {
|
||||
if (filterCcy !== "ALL" && ev.currency !== filterCcy) return false;
|
||||
if (!showLow && ev.impact === "low") return false;
|
||||
if (ev.isGroupChild && ev.groupKey && !expanded.has(ev.groupKey)) return false;
|
||||
if (weekTab === "current" && ev.week !== "current") return false;
|
||||
if (weekTab === "next" && ev.week !== "next") return false;
|
||||
if (weekTab === "next2" && ev.week !== "next2") return false;
|
||||
if (isoToLocalDate(ev.date) < fromDate) return false;
|
||||
return true;
|
||||
}), [events, filterCcy, showLow, expanded, weekTab, fromDate]);
|
||||
|
||||
// Grouper par jour
|
||||
const days: string[] = [];
|
||||
const dayMap: Record<string, CalendarEvent[]> = {};
|
||||
for (const ev of filtered) {
|
||||
@@ -197,63 +190,66 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
}
|
||||
days.sort();
|
||||
|
||||
// Compteurs par semaine pour les onglets
|
||||
const countCurrent = events.filter(e => e.week === "current").length;
|
||||
const countNext = events.filter(e => e.week === "next").length;
|
||||
const countNext2 = events.filter(e => e.week === "next2").length;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="px-4 py-3 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">Calendrier économique</h2>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">Sources : ForexFactory · FRED · Banques centrales</p>
|
||||
<h2 className="text-sm font-semibold text-slate-200">Calendrier économique</h2>
|
||||
<p className="text-[10px] text-slate-600 mt-0.5">Sources : ForexFactory · FRED · Banques centrales</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-gray-500 cursor-pointer">
|
||||
<input type="checkbox" checked={showLow} onChange={(e) => setShowLow(e.target.checked)} className="w-3 h-3" />
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLow}
|
||||
onChange={e => setShowLow(e.target.checked)}
|
||||
className="w-3 h-3 accent-amber-500"
|
||||
/>
|
||||
Impact faible
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Onglets semaine ──────────────────────────────────────────────────── */}
|
||||
<div className="flex gap-0 border-b border-gray-200 bg-gray-50/50">
|
||||
{/* Onglets semaine */}
|
||||
<div className="flex gap-0 border-b border-slate-800 bg-slate-900/40">
|
||||
{([
|
||||
["all", "Tout", null, null],
|
||||
["current","Sem. en cours", currentWeekLabel, countCurrent],
|
||||
["next", "Sem. prochaine", nextWeekLabel, countNext],
|
||||
["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2],
|
||||
["all", "Tout", null, null],
|
||||
["current", "Sem. en cours", currentWeekLabel, countCurrent],
|
||||
["next", "Sem. prochaine", nextWeekLabel, countNext],
|
||||
["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2],
|
||||
] as [WeekTab, string, string | null, number | null][]).map(([tab, label, sub, count]) => {
|
||||
const isActive = weekTab === tab;
|
||||
const disabled = tab === "next" && !nextWeekAvail && countNext === 0;
|
||||
const noData = typeof count === "number" && count === 0 && tab !== "all";
|
||||
const isActive = weekTab === tab;
|
||||
const disabled = tab === "next" && !nextWeekAvail && countNext === 0;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => !disabled && setWeekTab(tab)}
|
||||
disabled={disabled}
|
||||
className={`px-3 py-2.5 text-xs font-medium border-b-2 transition-colors text-left ${
|
||||
isActive ? "border-blue-500 text-blue-600 bg-white" :
|
||||
disabled ? "border-transparent text-gray-300 cursor-not-allowed" :
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:bg-white"
|
||||
isActive ? "border-amber-500 text-amber-400 bg-slate-900/60" :
|
||||
disabled ? "border-transparent text-slate-700 cursor-not-allowed" :
|
||||
"border-transparent text-slate-500 hover:text-slate-300 hover:bg-slate-800/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
{tab === "next2" && countNext2 > 0 && (
|
||||
<span className="text-[8px] bg-amber-100 text-amber-700 px-1 rounded">FRED</span>
|
||||
<span className="text-[8px] bg-amber-500/20 text-amber-400 border border-amber-500/30 px-1 rounded">FRED</span>
|
||||
)}
|
||||
</div>
|
||||
{sub && (
|
||||
<div className={`text-[9px] mt-0.5 ${isActive ? "text-blue-400" : disabled ? "text-gray-300" : "text-gray-400"}`}>
|
||||
<div className={`text-[9px] mt-0.5 ${isActive ? "text-amber-500/70" : disabled ? "text-slate-700" : "text-slate-600"}`}>
|
||||
{disabled ? "Dispo lundi (retry auto)" : sub}
|
||||
</div>
|
||||
)}
|
||||
{tab !== "all" && typeof count === "number" && (
|
||||
<div className={`text-[9px] ${noData ? "text-gray-300" : "text-gray-400"}`}>
|
||||
<div className={`text-[9px] ${count === 0 ? "text-slate-700" : "text-slate-500"}`}>
|
||||
{count} événement{count !== 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
@@ -262,41 +258,47 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Filtre devise + date ──────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-gray-100">
|
||||
{/* Date depuis */}
|
||||
{/* Filtre devise + date */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-slate-800 bg-slate-900/20">
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Calendar size={11} className="text-gray-400" />
|
||||
<span className="text-[10px] text-gray-500">Depuis</span>
|
||||
<Calendar size={11} className="text-slate-600" />
|
||||
<span className="text-[10px] text-slate-500">Depuis</span>
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
className="text-[10px] border border-gray-200 rounded px-1.5 py-0.5 text-gray-700 focus:outline-none focus:border-blue-400"
|
||||
onChange={e => setFromDate(e.target.value)}
|
||||
className="text-[10px] bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-slate-300 focus:outline-none focus:border-amber-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFromDate(todayIso())}
|
||||
className="text-[9px] text-blue-500 hover:text-blue-700 underline"
|
||||
className="text-[9px] text-amber-500 hover:text-amber-400 underline"
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||
<div className="w-px h-4 bg-slate-700 shrink-0" />
|
||||
|
||||
{/* Filtre devise */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => setFilterCcy("ALL")}
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${filterCcy === "ALL" ? "bg-gray-800 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors ${
|
||||
filterCcy === "ALL"
|
||||
? "bg-slate-200 text-slate-900"
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
Tout
|
||||
</button>
|
||||
{CURRENCIES.map((ccy) => (
|
||||
{CURRENCIES.map(ccy => (
|
||||
<button
|
||||
key={ccy}
|
||||
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
|
||||
className={`flex items-center gap-0.5 px-2 py-0.5 rounded-full text-[10px] font-medium ${filterCcy === ccy ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
|
||||
className={`flex items-center gap-0.5 px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors ${
|
||||
filterCcy === ccy
|
||||
? "bg-amber-500 text-slate-900"
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{CURRENCY_META[ccy].flag} {ccy}
|
||||
</button>
|
||||
@@ -304,22 +306,22 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Table ────────────────────────────────────────────────────────────── */}
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin text-gray-300" />
|
||||
<Loader2 size={20} className="animate-spin text-slate-600" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-sm text-gray-400">Aucun événement pour cette sélection</p>
|
||||
<p className="text-sm text-slate-500">Aucun événement pour cette sélection</p>
|
||||
{weekTab === "next" && !nextWeekAvail && (
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
<p className="text-[10px] text-slate-600 mt-1">
|
||||
ForexFactory ne publie la semaine prochaine que du lundi au vendredi.<br />
|
||||
Données disponibles dans quelques heures.
|
||||
</p>
|
||||
)}
|
||||
{fromDate > todayIso() && (
|
||||
<button onClick={() => setFromDate(todayIso())} className="mt-2 text-[10px] text-blue-500 underline">
|
||||
<button onClick={() => setFromDate(todayIso())} className="mt-2 text-[10px] text-amber-500 underline">
|
||||
Revenir à aujourd'hui
|
||||
</button>
|
||||
)}
|
||||
@@ -328,7 +330,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
|
||||
<tr className="bg-slate-900/60 text-[10px] font-semibold text-slate-500 uppercase tracking-wider border-b border-slate-800">
|
||||
<th className="py-2 px-3 text-left">Date / Heure</th>
|
||||
<th className="py-2 px-2 text-left">Devise</th>
|
||||
<th className="py-2 px-3 text-left">Événement</th>
|
||||
@@ -341,35 +343,39 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
<tbody>
|
||||
{(() => {
|
||||
const rows: React.ReactNode[] = [];
|
||||
let lastWeek: "current" | "next" | "next2" | null = null;
|
||||
let lastWeek: string | null = null;
|
||||
for (const day of days) {
|
||||
const dayEvents = dayMap[day];
|
||||
if (!dayEvents?.length) continue;
|
||||
const w = dayEvents[0].week;
|
||||
// Séparateur de semaine
|
||||
|
||||
// Séparateur semaine
|
||||
if (weekTab === "all" && w !== lastWeek) {
|
||||
lastWeek = w;
|
||||
const weekBanners: Record<string, string> = {
|
||||
current: `📅 Semaine en cours — ${currentWeekLabel}`,
|
||||
next: `📅 Semaine prochaine — ${nextWeekLabel}`,
|
||||
next2: `📅 À partir du ${next2StartLabel} — réunions CB + données économiques`,
|
||||
current: `Semaine en cours — ${currentWeekLabel}`,
|
||||
next: `Semaine prochaine — ${nextWeekLabel}`,
|
||||
next2: `À partir du ${next2StartLabel} — réunions BC + données`,
|
||||
};
|
||||
const isNext2 = w === "next2";
|
||||
rows.push(
|
||||
<tr key={`wsep_${w}`} className={w === "next2" ? "bg-amber-600" : "bg-indigo-600"}>
|
||||
<td colSpan={7} className="px-4 py-1.5 text-[10px] font-bold text-white uppercase tracking-widest">
|
||||
{weekBanners[w] ?? w}
|
||||
<tr key={`wsep_${w}`} className={isNext2 ? "bg-amber-500/15" : "bg-indigo-500/15"}>
|
||||
<td colSpan={7} className={`px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest ${isNext2 ? "text-amber-400" : "text-indigo-400"}`}>
|
||||
📅 {weekBanners[w] ?? w}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
// Séparateur de jour
|
||||
|
||||
// Séparateur jour
|
||||
rows.push(
|
||||
<tr key={`dsep_${day}`} className="bg-blue-50">
|
||||
<td colSpan={7} className="px-3 py-1.5 text-[10px] font-semibold text-blue-700 capitalize">
|
||||
<tr key={`dsep_${day}`} className="bg-slate-800/50">
|
||||
<td colSpan={7} className="px-3 py-1.5 text-[10px] font-semibold text-slate-400 capitalize">
|
||||
{fmtDayLabel(day)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
for (const ev of dayEvents) {
|
||||
rows.push(
|
||||
<EventRow
|
||||
@@ -390,11 +396,11 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-500">
|
||||
<div className="flex items-center gap-4 px-4 py-2.5 border-t border-slate-800 text-[10px] text-slate-600">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500 inline-block" /> Impact élevé</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400 inline-block" /> Impact moyen</span>
|
||||
<span>· Cliquer sur une ligne groupée pour voir les sous-indicateurs</span>
|
||||
<span>· Prévision = consensus marché avant publication</span>
|
||||
<span className="hidden sm:inline">· Cliquer sur une ligne groupée pour voir les sous-indicateurs</span>
|
||||
<span className="hidden sm:inline">· Prévision = consensus marché avant publication</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ComposedChart, Bar, Cell, Line, XAxis, YAxis,
|
||||
CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
||||
} from "recharts";
|
||||
import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { Currency } from "@/lib/types";
|
||||
import type { CotWeek, CotHistory } from "@/app/api/cot-history/route";
|
||||
import { CURRENCY_META } from "@/lib/constants";
|
||||
|
||||
interface Props {
|
||||
history: CotHistory | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const CURRENCIES: Currency[] = ["EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF", "USD"];
|
||||
|
||||
function formatNet(n: number): string {
|
||||
const abs = Math.abs(n);
|
||||
const sign = n >= 0 ? "+" : "-";
|
||||
return abs >= 1000 ? `${sign}${(abs / 1000).toFixed(1)}k` : `${sign}${abs}`;
|
||||
}
|
||||
|
||||
// ── Sparkline SVG ─────────────────────────────────────────────────────────────
|
||||
function Sparkline({ weeks }: { weeks: CotWeek[] }) {
|
||||
const pts = [...weeks].reverse().slice(-8);
|
||||
if (pts.length < 2) return <div className="w-16 h-6 text-slate-700 text-[10px] flex items-center">—</div>;
|
||||
|
||||
const vals = pts.map(w => w.net);
|
||||
const min = Math.min(...vals);
|
||||
const max = Math.max(...vals);
|
||||
const range = max - min || 1;
|
||||
const W = 64, H = 24, PAD = 2;
|
||||
|
||||
const points = vals.map((v, i) => {
|
||||
const x = PAD + (i / (vals.length - 1)) * (W - PAD * 2);
|
||||
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
|
||||
const latest = vals[vals.length - 1];
|
||||
const color = latest >= 0 ? "#10b981" : "#ef4444";
|
||||
const lastX = PAD + (W - PAD * 2);
|
||||
const lastY = H - PAD - ((latest - min) / range) * (H - PAD * 2);
|
||||
|
||||
return (
|
||||
<svg width={W} height={H} className="overflow-visible">
|
||||
<polyline points={points} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" />
|
||||
<circle cx={lastX} cy={lastY} r={2.5} fill={color} />
|
||||
{min < 0 && max > 0 && (
|
||||
<line
|
||||
x1={PAD} x2={W - PAD}
|
||||
y1={H - PAD - ((0 - min) / range) * (H - PAD * 2)}
|
||||
y2={H - PAD - ((0 - min) / range) * (H - PAD * 2)}
|
||||
stroke="#475569" strokeWidth={0.5} strokeDasharray="2 2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip ───────────────────────────────────────────────────────────────────
|
||||
function ChartTooltip({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name: string }>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const net = payload.find(p => p.name === "net")?.value ?? 0;
|
||||
const longPct = payload.find(p => p.name === "longPct")?.value ?? 0;
|
||||
const deltaNet = payload.find(p => p.name === "deltaNet")?.value;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-xs shadow-xl space-y-0.5">
|
||||
<p className="text-slate-400 font-medium">{label}</p>
|
||||
<p className={`font-bold ${net >= 0 ? "text-emerald-400" : "text-red-400"}`}>Net : {formatNet(net)}</p>
|
||||
<p className="text-slate-300">{longPct}% L / {100 - longPct}% S</p>
|
||||
{deltaNet !== undefined && deltaNet !== null && (
|
||||
<p className={`text-[11px] ${deltaNet > 0 ? "text-emerald-400" : deltaNet < 0 ? "text-red-400" : "text-slate-500"}`}>
|
||||
Δ semaine : {formatNet(deltaNet)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Carte devise ──────────────────────────────────────────────────────────────
|
||||
function CurrencyCard({ ccy, weeks, selected, onClick }: {
|
||||
ccy: Currency; weeks: CotWeek[]; selected: boolean; onClick: () => void;
|
||||
}) {
|
||||
const latest = weeks[0];
|
||||
const d = latest?.deltaNet ?? null;
|
||||
const meta = CURRENCY_META[ccy];
|
||||
const bias = latest ? (latest.longPct > 60 ? "bull" : latest.shortPct > 60 ? "bear" : "neu") : "neu";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex flex-col gap-1.5 p-3 rounded-xl border transition-all text-left w-full ${
|
||||
selected
|
||||
? "bg-slate-800/80 border-amber-500/50"
|
||||
: "bg-slate-900/50 border-slate-800 hover:border-slate-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-white">{meta?.flag} {ccy}</span>
|
||||
{d !== null ? (
|
||||
<span className={`flex items-center gap-0.5 text-[10px] font-semibold ${
|
||||
d > 0 ? "text-emerald-400" : d < 0 ? "text-red-400" : "text-slate-500"
|
||||
}`}>
|
||||
{d > 0 ? <TrendingUp size={10} /> : d < 0 ? <TrendingDown size={10} /> : <Minus size={10} />}
|
||||
{formatNet(d)}
|
||||
</span>
|
||||
) : <span className="text-[10px] text-slate-600">—</span>}
|
||||
</div>
|
||||
|
||||
<Sparkline weeks={weeks} />
|
||||
|
||||
{latest && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-[11px] font-semibold ${
|
||||
bias === "bull" ? "text-emerald-400" : bias === "bear" ? "text-red-400" : "text-slate-400"
|
||||
}`}>
|
||||
{formatNet(latest.net)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">{latest.longPct}%L</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
{selected
|
||||
? <ChevronUp size={12} className="text-amber-400" />
|
||||
: <ChevronDown size={12} className="text-slate-700" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Composant principal ───────────────────────────────────────────────────────
|
||||
export default function CotTab({ history, loading }: Props) {
|
||||
const [selected, setSelected] = useState<Currency | null>(null);
|
||||
const [mode, setMode] = useState<"tff" | "legacy">("tff");
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-40 text-slate-500 text-sm">Chargement historique COT…</div>;
|
||||
}
|
||||
if (!history || (!Object.keys(history.tff ?? {}).length && !Object.keys(history.legacy ?? {}).length)) {
|
||||
return <div className="flex items-center justify-center h-40 text-slate-500 text-sm">Données COT indisponibles</div>;
|
||||
}
|
||||
|
||||
const dataset = history[mode] ?? {};
|
||||
const latestDate = (dataset.EUR ?? dataset.GBP ?? [])[0]?.weekDate ?? "";
|
||||
|
||||
const handleSelect = (ccy: Currency) => setSelected(prev => prev === ccy ? null : ccy);
|
||||
|
||||
const selWeeks = selected ? (dataset[selected] ?? []) : [];
|
||||
const chartData = [...selWeeks].reverse().map(w => ({
|
||||
label: w.weekDate.slice(5),
|
||||
net: w.net,
|
||||
longPct: w.longPct,
|
||||
deltaNet: w.deltaNet,
|
||||
fill: w.net >= 0 ? "#10b981" : "#ef4444",
|
||||
}));
|
||||
|
||||
const w0 = selWeeks[0];
|
||||
const d = w0?.deltaNet ?? null;
|
||||
|
||||
const MODE_LABELS = {
|
||||
tff: { label: "Hedge Funds (TFF)", desc: "Leveraged Money — gestionnaires spéculatifs, fonds macro" },
|
||||
legacy: { label: "Non-Commercial (Legacy)", desc: "Tous spéculateurs — traders non-commerciaux (méthode classique depuis 1986)" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* En-tête + toggle */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">COT · CFTC</h2>
|
||||
{latestDate && <span className="text-[11px] text-amber-400/80">Semaine du {latestDate}</span>}
|
||||
</div>
|
||||
|
||||
{/* Toggle TFF / Legacy */}
|
||||
<div className="flex items-center gap-1 bg-slate-900 border border-slate-800 rounded-lg p-0.5">
|
||||
{(["tff", "legacy"] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-all ${
|
||||
mode === m
|
||||
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||||
: "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{MODE_LABELS[m].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description du mode */}
|
||||
<p className="text-[11px] text-slate-600">{MODE_LABELS[mode].desc}</p>
|
||||
|
||||
{/* Grille 8 cartes */}
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
||||
{CURRENCIES.map(ccy => (
|
||||
<CurrencyCard
|
||||
key={ccy}
|
||||
ccy={ccy}
|
||||
weeks={dataset[ccy] ?? []}
|
||||
selected={selected === ccy}
|
||||
onClick={() => handleSelect(ccy)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Panneau détail */}
|
||||
{selected && chartData.length > 0 && (
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4 space-y-3">
|
||||
{/* Résumé */}
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
<span className="text-sm font-bold text-white">
|
||||
{CURRENCY_META[selected]?.flag} {selected}
|
||||
</span>
|
||||
{w0 && (
|
||||
<span className={`text-xs font-semibold ${w0.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{formatNet(w0.net)} net · {w0.longPct}%L / {w0.shortPct}%S
|
||||
</span>
|
||||
)}
|
||||
{d !== null && (
|
||||
<span className={`text-xs ${d > 0 ? "text-emerald-400" : d < 0 ? "text-red-400" : "text-slate-500"}`}>
|
||||
{d > 0 ? "▲" : "▼"} {formatNet(Math.abs(d))} Δ sem.
|
||||
</span>
|
||||
)}
|
||||
{w0?.deltaLong !== null && w0?.deltaLong !== undefined && (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
+L {formatNet(w0.deltaLong)} / +S {formatNet(w0.deltaShort ?? 0)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-600">{selWeeks.length} semaines</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} interval="preserveStartEnd" />
|
||||
<YAxis yAxisId="net" orientation="left" tick={{ fontSize: 9, fill: "#64748b" }} tickLine={false} axisLine={false} tickFormatter={v => `${(v/1000).toFixed(0)}k`} width={36} />
|
||||
<YAxis yAxisId="pct" orientation="right" domain={[0,100]} tick={{ fontSize: 9, fill: "#64748b" }} tickLine={false} axisLine={false} tickFormatter={v=>`${v}%`} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<ReferenceLine yAxisId="net" y={0} stroke="#475569" strokeWidth={1} />
|
||||
<Bar yAxisId="net" dataKey="net" name="net" radius={[2,2,0,0]} maxBarSize={24} isAnimationActive={false}>
|
||||
{chartData.map((e, i) => <Cell key={i} fill={e.fill} />)}
|
||||
</Bar>
|
||||
<Line yAxisId="pct" type="monotone" dataKey="longPct" name="longPct" stroke="#f59e0b" strokeWidth={1.5} dot={false} strokeDasharray="4 2" isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Tableau 6 dernières semaines avec deltas */}
|
||||
<table className="w-full text-[11px] text-slate-400">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 text-slate-600">
|
||||
<th className="text-left pb-1">Semaine</th>
|
||||
<th className="text-right pb-1">Net</th>
|
||||
<th className="text-right pb-1">Δ Net</th>
|
||||
<th className="text-right pb-1">Δ Longs</th>
|
||||
<th className="text-right pb-1">Δ Shorts</th>
|
||||
<th className="text-right pb-1">%L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selWeeks.slice(0, 6).map((w, i) => (
|
||||
<tr key={w.weekDate} className={`border-b border-slate-800/40 ${i === 0 ? "text-white" : ""}`}>
|
||||
<td className="py-1">{w.weekDate}</td>
|
||||
<td className={`text-right font-medium ${w.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{formatNet(w.net)}
|
||||
</td>
|
||||
<td className={`text-right text-[10px] ${
|
||||
w.deltaNet === null ? "text-slate-600"
|
||||
: w.deltaNet > 0 ? "text-emerald-400"
|
||||
: w.deltaNet < 0 ? "text-red-400"
|
||||
: "text-slate-500"
|
||||
}`}>
|
||||
{w.deltaNet !== null ? formatNet(w.deltaNet) : "—"}
|
||||
</td>
|
||||
<td className={`text-right text-[10px] ${
|
||||
!w.deltaLong ? "text-slate-600" : w.deltaLong > 0 ? "text-emerald-400" : "text-red-400"
|
||||
}`}>
|
||||
{w.deltaLong !== null && w.deltaLong !== undefined ? formatNet(w.deltaLong) : "—"}
|
||||
</td>
|
||||
<td className={`text-right text-[10px] ${
|
||||
!w.deltaShort ? "text-slate-600" : w.deltaShort > 0 ? "text-red-400" : "text-emerald-400"
|
||||
}`}>
|
||||
{w.deltaShort !== null && w.deltaShort !== undefined ? formatNet(w.deltaShort) : "—"}
|
||||
</td>
|
||||
<td className="text-right text-emerald-400">{w.longPct}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+78
-63
@@ -14,7 +14,7 @@ function fmt(v: number | null, dec: number, unit = "") {
|
||||
|
||||
interface TooltipState { x: number; y: number }
|
||||
|
||||
function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, tooltip }: {
|
||||
function Tile({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, tooltip, accent }: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
dec?: number;
|
||||
@@ -23,6 +23,7 @@ function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, toolti
|
||||
deltaPct?: boolean;
|
||||
deltaDec?: number;
|
||||
tooltip?: string;
|
||||
accent?: "red" | "green" | "amber";
|
||||
}) {
|
||||
const [tip, setTip] = useState<TooltipState | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -42,28 +43,30 @@ function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, toolti
|
||||
? `${Math.abs(delta).toFixed(deltaDec ?? dec)}${deltaPct ? "%" : ""}`
|
||||
: null;
|
||||
|
||||
const borderCls = accent === "red" ? "border-red-500/30 bg-red-500/5"
|
||||
: accent === "green" ? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: accent === "amber" ? "border-amber-500/30 bg-amber-500/5"
|
||||
: "border-slate-800/60 bg-slate-900/40";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseEnter={tooltip ? show : undefined}
|
||||
onMouseLeave={tooltip ? hide : undefined}
|
||||
className={`flex items-center gap-1.5 shrink-0 ${tooltip ? "cursor-help" : ""}`}
|
||||
className={`flex flex-col gap-0.5 px-3 py-2 rounded-lg border ${borderCls} ${tooltip ? "cursor-help" : ""} min-w-[72px]`}
|
||||
>
|
||||
<span className="text-slate-500 text-[11px]">{label}</span>
|
||||
<span className="text-slate-100 font-semibold tabular-nums text-[11px]">
|
||||
{fmt(value, dec, unit)}
|
||||
</span>
|
||||
{dFmt && (
|
||||
<span className={`text-[10px] font-medium tabular-nums ${dColor}`}>
|
||||
{dArrow}{dFmt}
|
||||
<span className="text-slate-500 text-[10px] font-medium leading-none">{label}</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-slate-100 font-bold tabular-nums text-[13px] leading-none">
|
||||
{fmt(value, dec, unit)}
|
||||
</span>
|
||||
)}
|
||||
{tooltip && (
|
||||
<span className="w-3 h-3 rounded-full border border-slate-700 text-slate-600 text-[7px] flex items-center justify-center leading-none select-none shrink-0">
|
||||
i
|
||||
</span>
|
||||
)}
|
||||
{dFmt && (
|
||||
<span className={`text-[10px] font-medium tabular-nums leading-none ${dColor}`}>
|
||||
{dArrow}{dFmt}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tip && typeof document !== "undefined" && createPortal(
|
||||
@@ -80,8 +83,12 @@ function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, toolti
|
||||
);
|
||||
}
|
||||
|
||||
function VSep() {
|
||||
return <div className="w-px h-3.5 bg-slate-700/60 shrink-0 mx-0.5" />;
|
||||
function GroupLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="text-slate-600 text-[9px] font-semibold uppercase tracking-widest self-center shrink-0 hidden sm:block">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriversBar({ drivers }: Props) {
|
||||
@@ -102,59 +109,67 @@ export default function DriversBar({ drivers }: Props) {
|
||||
const riskOff = (vix ?? 0) > 25 || (hySpread ?? 0) > 500;
|
||||
|
||||
return (
|
||||
<div className="mb-4 bg-slate-900 border border-slate-800 rounded-xl px-4 py-2.5 flex items-center gap-4 overflow-x-auto scrollbar-hide">
|
||||
<div className="mb-4 bg-slate-950/60 border border-slate-800 rounded-xl p-3 space-y-2">
|
||||
|
||||
<span className="text-slate-500 font-semibold uppercase tracking-widest text-[10px] shrink-0">
|
||||
DRIVERS GLOBAUX
|
||||
</span>
|
||||
{/* Ligne titre + alerte */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 font-semibold uppercase tracking-widest text-[10px]">
|
||||
Drivers Globaux
|
||||
</span>
|
||||
{riskOff && (
|
||||
<div className="flex items-center gap-1 bg-red-500/10 border border-red-500/20 rounded-full px-2 py-0.5">
|
||||
<AlertTriangle size={10} className="text-red-400" />
|
||||
<span className="text-[9px] font-semibold text-red-400">Risk-Off</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{riskOff && (
|
||||
<div className="flex items-center gap-1 shrink-0 bg-red-500/10 border border-red-500/20 rounded-full px-2 py-0.5">
|
||||
<AlertTriangle size={10} className="text-red-400" />
|
||||
<span className="text-[9px] font-semibold text-red-400">Risk-Off</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Grille responsive — 2 lignes sur desktop, s'adapte sur mobile */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
<VSep />
|
||||
{/* ── Sentiment ─────────────────────────────────────────── */}
|
||||
<GroupLabel label="Sentiment" />
|
||||
<Tile label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1}
|
||||
accent={(vix ?? 0) > 25 ? "red" : undefined}
|
||||
tooltip="Clôture actuelle − clôture précédente (Yahoo Finance)." />
|
||||
<Tile label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2}
|
||||
tooltip="% vs clôture précédente (Yahoo Finance)." />
|
||||
<Tile label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2}
|
||||
tooltip="Variation 24h (Binance / CoinGecko)." />
|
||||
|
||||
{/* Sentiment / Risk-On */}
|
||||
<D label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1}
|
||||
tooltip="Clôture actuelle − clôture précédente (Yahoo Finance)." />
|
||||
<D label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2}
|
||||
tooltip="% vs clôture précédente (Yahoo Finance)." />
|
||||
<D label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2}
|
||||
tooltip="Variation 24h (Binance / CoinGecko)." />
|
||||
{/* ── Crédit ────────────────────────────────────────────── */}
|
||||
<div className="w-px bg-slate-800 self-stretch mx-0.5 hidden sm:block" />
|
||||
<GroupLabel label="Crédit" />
|
||||
<Tile label="HY Spread" value={hySpread} dec={0} unit=" bps"
|
||||
accent={(hySpread ?? 0) > 500 ? "red" : (hySpread ?? 0) > 400 ? "amber" : undefined}
|
||||
tooltip="High Yield spread vs Treasuries US. >500 bps = risk-off fort." />
|
||||
<Tile label="IG Spread" value={igSpread} dec={0} unit=" bps"
|
||||
tooltip="Investment Grade spread vs Treasuries US." />
|
||||
|
||||
<VSep />
|
||||
{/* ── FX / Taux ─────────────────────────────────────────── */}
|
||||
<div className="w-px bg-slate-800 self-stretch mx-0.5 hidden sm:block" />
|
||||
<GroupLabel label="FX / Taux" />
|
||||
<Tile label="DXY" value={dxy} dec={2} delta={dxyDelta} deltaDec={2}
|
||||
tooltip="ICE Dollar Index Futures (DX=F) — Yahoo Finance, cache 5 min." />
|
||||
<Tile
|
||||
label="Crb 2-10" value={curveSlope} dec={0} unit=" bps"
|
||||
accent={(curveSlope ?? 0) < -50 ? "amber" : undefined}
|
||||
tooltip={`Spread US 10Y − US 2Y. Négatif = courbe inversée.\nUS 10Y: ${us10y != null ? us10y.toFixed(2) + "%" : "N/A"} | US 2Y: ${us2y != null ? us2y.toFixed(2) + "%" : "N/A"}`}
|
||||
/>
|
||||
|
||||
{/* Crédit */}
|
||||
<D label="HY Spread" value={hySpread} dec={0} unit=" bps"
|
||||
tooltip="High Yield spread vs Treasuries US. >500 bps = risk-off fort." />
|
||||
<D label="IG Spread" value={igSpread} dec={0} unit=" bps"
|
||||
tooltip="Investment Grade spread vs Treasuries US." />
|
||||
|
||||
<VSep />
|
||||
|
||||
{/* Taux & FX */}
|
||||
<D label="DXY" value={dxy} dec={2} delta={dxyDelta} deltaDec={2}
|
||||
tooltip="ICE Dollar Index Futures (DX=F) — Yahoo Finance, cache 5 min." />
|
||||
<D
|
||||
label="Crb 2-10" value={curveSlope} dec={0} unit=" bps"
|
||||
tooltip={`Spread US 10Y − US 2Y. Négatif = courbe inversée.\nUS 10Y: ${us10y != null ? us10y.toFixed(2) + "%" : "N/A"} | US 2Y: ${us2y != null ? us2y.toFixed(2) + "%" : "N/A"}`}
|
||||
/>
|
||||
|
||||
<VSep />
|
||||
|
||||
{/* Commodités */}
|
||||
<D label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<D label="Argent $/oz" value={silver} dec={2} delta={silverDelta}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<D label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<D label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
{/* ── Commodités ────────────────────────────────────────── */}
|
||||
<div className="w-px bg-slate-800 self-stretch mx-0.5 hidden sm:block" />
|
||||
<GroupLabel label="Commodités" />
|
||||
<Tile label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<Tile label="Argent $/oz" value={silver} dec={2} delta={silverDelta}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<Tile label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<Tile label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+46
-16
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import {
|
||||
ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus,
|
||||
Loader2, Radio, AlertTriangle, Landmark, Globe, BarChart2, Zap,
|
||||
@@ -71,21 +71,34 @@ interface Props {
|
||||
}
|
||||
|
||||
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 [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
|
||||
const [filterCat, setFilterCat] = useState<string | "ALL">("ALL");
|
||||
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
|
||||
const [priorityOnly, setPriorityOnly] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Auto-refresh toutes les 5 minutes
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) { if (intervalRef.current) clearInterval(intervalRef.current); return; }
|
||||
intervalRef.current = setInterval(() => { onRefresh(); setLastRefreshAt(new Date()); }, 5 * 60_000);
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, [autoRefresh, onRefresh]);
|
||||
|
||||
const isPriorityItem = (item: NewsItem) =>
|
||||
item.categories.some(c => ["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État", "Probabilités Taux"].includes(c));
|
||||
|
||||
const filtered = useMemo(() => items.filter(item => {
|
||||
if (priorityOnly && !isPriorityItem(item)) return false;
|
||||
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;
|
||||
if (filterDir !== "all" && filterCcy !== "ALL") {
|
||||
if (!item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir)) return false;
|
||||
}
|
||||
return true;
|
||||
}), [items, filterCcy, filterCat, filterDir]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [items, filterCcy, filterCat, filterDir, priorityOnly]);
|
||||
|
||||
// Catégories présentes dans le feed actuel
|
||||
const activeCats = useMemo(() => {
|
||||
@@ -125,15 +138,32 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
|
||||
{/* ── 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">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-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 className="flex items-center gap-2">
|
||||
{/* Bouton Prioritaires */}
|
||||
<button onClick={() => setPriorityOnly(p => !p)}
|
||||
className={`flex items-center gap-1 text-[9px] px-2.5 py-1 rounded-full font-semibold border transition-colors ${
|
||||
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} /> ⚡ Prioritaires
|
||||
</button>
|
||||
{/* Auto-refresh */}
|
||||
<button onClick={() => setAutoRefresh(a => !a)}
|
||||
className={`flex items-center gap-1 text-[9px] px-2 py-1 rounded-full border transition-colors ${
|
||||
autoRefresh ? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10" : "text-slate-600 border-slate-700/30"
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${autoRefresh ? "bg-emerald-500 animate-pulse" : "bg-slate-600"}`} />
|
||||
{autoRefresh ? "Live 5min" : "Pause"}
|
||||
</button>
|
||||
<button onClick={() => { onRefresh(); setLastRefreshAt(new Date()); }} 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" : ""} />
|
||||
{lastRefreshAt ? lastRefreshAt.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }) : "Actualiser"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
||||
{CCY_LIST.map(ccy => {
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Printer, RefreshCw, Save, RotateCcw, Plus, Trash2, Sparkles, Loader2, Check } from "lucide-react";
|
||||
import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||
import type { DriverData } from "@/lib/types";
|
||||
import type { FxWeeklyEntry } from "@/app/api/fx-weekly/route";
|
||||
import type { CotHistory } from "@/app/api/cot-history/route";
|
||||
import { CURRENCY_META } from "@/lib/constants";
|
||||
import { TvMiniChart, TvAdvancedChart } from "@/components/TvChart";
|
||||
|
||||
interface Props {
|
||||
calEvents: CalendarEvent[];
|
||||
drivers: DriverData | null;
|
||||
cotHistory: CotHistory | null;
|
||||
}
|
||||
|
||||
interface Theme { title: string; body: string }
|
||||
|
||||
interface ReportState {
|
||||
weekLabel: string;
|
||||
weekFrom: string;
|
||||
weekTo: string;
|
||||
author: string;
|
||||
subtitle: string;
|
||||
themes: Theme[];
|
||||
currencies: Record<string, { pct: string; analysis: string; level: string }>;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "forex-report-v2";
|
||||
const G10 = ["USD","EUR","GBP","JPY","CHF","CAD","AUD","NZD"];
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
if (!iso) return "";
|
||||
return new Date(iso + "T12:00:00").toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
function fmtShort(iso: string) {
|
||||
if (!iso) return "";
|
||||
return new Date(iso + "T12:00:00").toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
function defaultState(weekFrom = "", weekTo = ""): ReportState {
|
||||
return {
|
||||
weekLabel: weekFrom && weekTo ? `${fmtShort(weekFrom)} — ${fmtDate(weekTo)}` : "Semaine du … au …",
|
||||
weekFrom, weekTo,
|
||||
author: "Capucine · Forex Dashboard",
|
||||
subtitle: "Analyse macro-fondamentale G10 · Marchés globaux",
|
||||
themes: [{ title: "", body: "" }, { title: "", body: "" }, { title: "", body: "" }],
|
||||
currencies: Object.fromEntries(G10.map(c => [c, { pct: "—", analysis: "", level: "" }])),
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function save(state: ReportState) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { /**/ }
|
||||
}
|
||||
function load(): ReportState | null {
|
||||
try { const r = localStorage.getItem(STORAGE_KEY); return r ? JSON.parse(r) : null; } catch { return null; }
|
||||
}
|
||||
|
||||
// ── Composants UI ─────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({ value, onChange, placeholder, multiline, className }: {
|
||||
value: string; onChange: (v: string) => void; placeholder?: string;
|
||||
multiline?: boolean; className?: string;
|
||||
}) {
|
||||
if (multiline) return (
|
||||
<textarea value={value} onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder} rows={5}
|
||||
className={`w-full bg-transparent resize-none outline-none placeholder-slate-700 ${className}`} />
|
||||
);
|
||||
return (
|
||||
<input type="text" value={value} onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`bg-transparent outline-none placeholder-slate-700 w-full ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
function Pct({ val }: { val: string }) {
|
||||
const n = parseFloat(val);
|
||||
if (isNaN(n) || val === "—") return <span className="text-slate-500 font-mono text-sm">—</span>;
|
||||
const c = n > 0 ? "text-emerald-400" : n < 0 ? "text-red-400" : "text-slate-400";
|
||||
return <span className={`font-mono font-bold text-sm ${c}`}>{n > 0 ? "+" : ""}{n.toFixed(1)}%</span>;
|
||||
}
|
||||
|
||||
// ── Bouton Groq par devise ────────────────────────────────────────────────────
|
||||
function AiButton({ ccy, weekFrom, weekTo, pct, cotHistory, onResult }: {
|
||||
ccy: string; weekFrom: string; weekTo: string; pct: string;
|
||||
cotHistory: CotHistory | null; onResult: (text: string) => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true); setErr(""); setDone(false);
|
||||
try {
|
||||
const tffWeeks = cotHistory?.tff?.[ccy as keyof typeof cotHistory.tff] ?? [];
|
||||
const legacyWeeks = cotHistory?.legacy?.[ccy as keyof typeof cotHistory.legacy] ?? [];
|
||||
const tff0 = tffWeeks[0];
|
||||
const legacy0 = legacyWeeks[0];
|
||||
|
||||
const res = await fetch("/api/narrative", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "report_ccy",
|
||||
currency: ccy,
|
||||
data: {
|
||||
weeklyPct: pct,
|
||||
weekFrom, weekTo,
|
||||
cotNetTff: tff0?.net,
|
||||
cotDeltaTff: tff0?.deltaNet,
|
||||
cotLongPctTff: tff0?.longPct,
|
||||
cotNetLegacy: legacy0?.net,
|
||||
cotDeltaLegacy: legacy0?.deltaNet,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
onResult(json.analysis ?? "");
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 3000);
|
||||
} catch (e) {
|
||||
setErr(String(e).replace(/^Error:\s*/i, "").slice(0, 60));
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={run} disabled={loading}
|
||||
className={`no-print flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-semibold transition-all ${
|
||||
done ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" :
|
||||
loading ? "bg-sky-500/10 text-sky-400 border border-sky-500/20 cursor-wait" :
|
||||
"bg-sky-500/15 text-sky-400 border border-sky-500/25 hover:bg-sky-500/25"
|
||||
}`}>
|
||||
{loading ? <Loader2 size={10} className="animate-spin" />
|
||||
: done ? <Check size={10} />
|
||||
: <Sparkles size={10} />}
|
||||
{loading ? "Génération…" : done ? "Injecté !" : "Générer avec IA"}
|
||||
</button>
|
||||
{err && <span className="text-[9px] text-red-400 truncate max-w-[140px]" title={err}>⚠ {err}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
export default function ReportTab({ calEvents, drivers, cotHistory }: Props) {
|
||||
const [state, setState] = useState<ReportState>(() => load() ?? defaultState());
|
||||
const [fxData, setFxData] = useState<FxWeeklyEntry[] | null>(null);
|
||||
const [fxLoading, setFxLoad] = useState(false);
|
||||
const [weekTo, setWeekTo] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [showCharts, setShowCharts] = useState(false);
|
||||
|
||||
const loadFx = useCallback(async (override?: string) => {
|
||||
setFxLoad(true);
|
||||
try {
|
||||
const url = override ? `/api/fx-weekly?weekTo=${override}` : "/api/fx-weekly";
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
setFxData(d.currencies);
|
||||
const newCcys = { ...state.currencies };
|
||||
for (const e of d.currencies as FxWeeklyEntry[]) {
|
||||
newCcys[e.ccy] = { ...newCcys[e.ccy], pct: e.pct > 0 ? `+${e.pct.toFixed(1)}%` : `${e.pct.toFixed(1)}%` };
|
||||
}
|
||||
setState(s => ({
|
||||
...s,
|
||||
weekFrom: d.weekFrom,
|
||||
weekTo: d.weekTo,
|
||||
weekLabel: `${fmtShort(d.weekFrom)} — ${fmtDate(d.weekTo)}`,
|
||||
currencies: newCcys,
|
||||
}));
|
||||
} finally { setFxLoad(false); }
|
||||
}, [state.currencies]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadFx(); }, []); // eslint-disable-line
|
||||
|
||||
const upd = (patch: Partial<ReportState>) =>
|
||||
setState(s => { const n = { ...s, ...patch }; save(n); return n; });
|
||||
|
||||
const updCcy = (ccy: string, f: "pct" | "analysis" | "level", v: string) => {
|
||||
const c = { ...state.currencies, [ccy]: { ...state.currencies[ccy], [f]: v } };
|
||||
upd({ currencies: c });
|
||||
};
|
||||
|
||||
const updTheme = (i: number, f: "title" | "body", v: string) =>
|
||||
upd({ themes: state.themes.map((t, j) => j === i ? { ...t, [f]: v } : t) });
|
||||
|
||||
const handleSave = () => { save(state); setSaved(true); setTimeout(() => setSaved(false), 2000); };
|
||||
|
||||
// Devises triées par perf hebdo
|
||||
const sorted = [...G10].sort((a, b) => {
|
||||
const pa = parseFloat(state.currencies[a]?.pct ?? "0");
|
||||
const pb = parseFloat(state.currencies[b]?.pct ?? "0");
|
||||
return (isNaN(pb) ? 0 : pb) - (isNaN(pa) ? 0 : pa);
|
||||
});
|
||||
|
||||
// Calendrier semaine suivante
|
||||
const nextEvents = calEvents
|
||||
.filter(e => e.week === "next" && e.impact !== "low" && !e.isGroupChild)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const calByDay: Record<string, CalendarEvent[]> = {};
|
||||
for (const e of nextEvents) {
|
||||
const d = e.date.slice(0, 10);
|
||||
(calByDay[d] ??= []).push(e);
|
||||
}
|
||||
const calDays = Object.keys(calByDay).sort();
|
||||
|
||||
const pubDate = new Date().toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* ── Contrôles ─────────────────────────────────────────────────────── */}
|
||||
<div className="no-print flex items-center justify-between flex-wrap gap-3 bg-slate-900/60 border border-slate-800 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">📋 Rapport hebdomadaire</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-slate-500">Vendredi clôture :</span>
|
||||
<input type="date" value={weekTo} onChange={e => setWeekTo(e.target.value)}
|
||||
className="text-[11px] bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-300 focus:outline-none focus:border-sky-500/50" />
|
||||
<button onClick={() => loadFx(weekTo || undefined)} disabled={fxLoading}
|
||||
className="flex items-center gap-1 text-[11px] bg-sky-500/15 text-sky-400 border border-sky-500/25 px-2 py-1 rounded hover:bg-sky-500/25 disabled:opacity-50">
|
||||
<RefreshCw size={10} className={fxLoading ? "animate-spin" : ""} /> Charger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => { setState(defaultState()); localStorage.removeItem(STORAGE_KEY); }}
|
||||
className="flex items-center gap-1.5 text-[11px] text-slate-500 hover:text-slate-300 px-2 py-1.5 rounded border border-slate-800 hover:border-slate-700">
|
||||
<RotateCcw size={11} /> Reset
|
||||
</button>
|
||||
<button onClick={handleSave}
|
||||
className={`flex items-center gap-1.5 text-[11px] px-2 py-1.5 rounded border transition-all ${saved ? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30" : "text-slate-400 border-slate-800 hover:border-slate-600"}`}>
|
||||
<Save size={11} /> {saved ? "Sauvegardé !" : "Sauvegarder"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCharts(v => !v)}
|
||||
className={`flex items-center gap-1.5 text-[11px] px-2 py-1.5 rounded border transition-all ${showCharts ? "bg-sky-500/20 text-sky-400 border-sky-500/30" : "text-slate-500 border-slate-800 hover:border-slate-600"}`}>
|
||||
{showCharts ? "Masquer graphiques" : "Afficher graphiques TradingView"}
|
||||
</button>
|
||||
<button onClick={() => window.print()}
|
||||
className="flex items-center gap-1.5 text-[11px] bg-sky-600/80 hover:bg-sky-600 text-white px-3 py-1.5 rounded font-medium">
|
||||
<Printer size={11} /> Exporter PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══════════════════════════════════════════════════════════════════════
|
||||
RAPPORT IMPRIMABLE
|
||||
══════════════════════════════════════════════════════════════════════ */}
|
||||
<div className="report-root font-sans">
|
||||
|
||||
{/* ── PAGE 1 : COUVERTURE ─────────────────────────────────────────── */}
|
||||
<div className="report-page rp-cover bg-[#080c14] min-h-[297mm] flex flex-col p-10">
|
||||
|
||||
{/* Bande top */}
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-sky-500 flex items-center justify-center">
|
||||
<span className="text-white font-black text-xs">FX</span>
|
||||
</div>
|
||||
<Field value={state.author} onChange={v => upd({ author: v })}
|
||||
className="text-sky-400 text-xs font-semibold tracking-wide" placeholder="Auteur…" />
|
||||
</div>
|
||||
<span className="text-slate-600 text-[10px]">Publiée le {pubDate}</span>
|
||||
</div>
|
||||
|
||||
{/* Titre central */}
|
||||
<div className="flex-1 flex flex-col justify-center space-y-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sky-500 text-xs uppercase tracking-[0.25em] font-semibold">Rapport Macro Weekly</p>
|
||||
<Field value={state.weekLabel} onChange={v => upd({ weekLabel: v })}
|
||||
className="text-white text-4xl font-black leading-tight tracking-tight block"
|
||||
placeholder="Semaine du … au …" />
|
||||
<Field value={state.subtitle} onChange={v => upd({ subtitle: v })}
|
||||
className="text-slate-500 text-sm block mt-2" placeholder="Sous-titre…" />
|
||||
</div>
|
||||
|
||||
{/* Barre de séparation animée */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-sky-500 to-transparent" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-sky-500" />
|
||||
</div>
|
||||
|
||||
{/* Classement G10 */}
|
||||
{fxData && (
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{sorted.map(ccy => {
|
||||
const meta = CURRENCY_META[ccy as keyof typeof CURRENCY_META];
|
||||
const n = parseFloat(state.currencies[ccy]?.pct ?? "0");
|
||||
const c = n > 0 ? "#34d399" : n < 0 ? "#f87171" : "#94a3b8";
|
||||
return (
|
||||
<div key={ccy} className="flex flex-col items-center gap-1 p-2 rounded-lg bg-white/[0.03] border border-white/[0.06]">
|
||||
<span className="text-xl leading-none">{meta?.flag}</span>
|
||||
<span className="text-white text-xs font-bold">{ccy}</span>
|
||||
<span className="font-mono font-bold text-xs" style={{ color: c }}>
|
||||
{isNaN(n) ? "—" : `${n > 0 ? "+" : ""}${n.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thèmes clés */}
|
||||
<div className="space-y-2 mt-4">
|
||||
<p className="text-slate-600 text-[9px] uppercase tracking-widest font-semibold no-print">Faits marquants de la semaine</p>
|
||||
{state.themes.map((theme, i) => (
|
||||
<div key={i} className="group relative flex gap-3 p-3 rounded-lg bg-white/[0.03] border-l-2 border-sky-500/60">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<Field value={theme.title} onChange={v => updTheme(i, "title", v)}
|
||||
className="text-sky-400 text-[10px] font-bold uppercase tracking-wider"
|
||||
placeholder="TITRE DU FAIT MARQUANT…" />
|
||||
<Field value={theme.body} onChange={v => updTheme(i, "body", v)}
|
||||
className="text-slate-400 text-xs" placeholder="Description courte et impact marché…" />
|
||||
</div>
|
||||
<button onClick={() => upd({ themes: state.themes.filter((_, j) => j !== i) })}
|
||||
className="no-print opacity-0 group-hover:opacity-100 text-slate-700 hover:text-red-400 shrink-0 self-start mt-0.5">
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => upd({ themes: [...state.themes, { title: "", body: "" }] })}
|
||||
className="no-print flex items-center gap-1 text-[10px] text-slate-700 hover:text-sky-400 mt-1">
|
||||
<Plus size={10} /> Ajouter un fait marquant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Données drivers en bas */}
|
||||
{drivers && (
|
||||
<div className="mt-8 pt-4 border-t border-white/[0.05] grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ l: "VIX", v: drivers.vix?.toFixed(1), s: drivers.vix != null && drivers.vix > 25 ? "⚠" : "" },
|
||||
{ l: "DXY", v: (drivers as {dxy?:number|null}).dxy?.toFixed(2) },
|
||||
{ l: "Brent", v: drivers.brent ? `$${drivers.brent.toFixed(1)}` : null },
|
||||
{ l: "US 10Y", v: drivers.us10y ? `${drivers.us10y.toFixed(2)}%` : null },
|
||||
].map(({ l, v, s }) => (
|
||||
<div key={l} className="flex items-center justify-between p-2 rounded-md bg-white/[0.03]">
|
||||
<span className="text-slate-600 text-[10px]">{l}</span>
|
||||
<span className="text-slate-300 text-[11px] font-semibold tabular-nums">{v ?? "—"} {s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── PAGE 2–3 : ANALYSES DEVISES ─────────────────────────────────── */}
|
||||
<div className="report-page bg-[#080c14] min-h-[297mm] p-10 space-y-6">
|
||||
|
||||
{/* En-tête section */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Actualité G10 · Analyses Devises</h2>
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
</div>
|
||||
|
||||
{/* Grille 2 colonnes */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{sorted.map(ccy => {
|
||||
const meta = CURRENCY_META[ccy as keyof typeof CURRENCY_META];
|
||||
const entry = state.currencies[ccy];
|
||||
const n = parseFloat(entry?.pct ?? "0");
|
||||
const col = n > 0 ? "text-emerald-400" : n < 0 ? "text-red-400" : "text-slate-500";
|
||||
const borderCol = n > 0 ? "border-emerald-500/30" : n < 0 ? "border-red-500/30" : "border-slate-700";
|
||||
|
||||
return (
|
||||
<div key={ccy} className={`flex flex-col gap-3 p-4 rounded-xl bg-[#0f1623] border ${borderCol}`}>
|
||||
{/* Header devise */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl leading-none">{meta?.flag}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-black text-sm">{ccy}</span>
|
||||
<div className="no-print">
|
||||
<Field value={entry?.pct ?? ""} onChange={v => updCcy(ccy, "pct", v)}
|
||||
className={`font-mono font-bold text-sm w-16 ${col}`} placeholder="±0.0%" />
|
||||
</div>
|
||||
<span className={`print-only font-mono font-bold text-sm ${col}`}>
|
||||
{isNaN(n) ? entry?.pct : `${n > 0 ? "+" : ""}${n.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-600 text-[10px]">
|
||||
{meta?.flag && ccy}
|
||||
{cotHistory?.tff?.[ccy as keyof typeof cotHistory.tff]?.[0]?.net != null && (
|
||||
<span className="ml-2">
|
||||
COT {(cotHistory.tff[ccy as keyof typeof cotHistory.tff]?.[0]?.net ?? 0) > 0 ? "▲" : "▼"}
|
||||
{" "}{((cotHistory.tff[ccy as keyof typeof cotHistory.tff]?.[0]?.net ?? 0) / 1000).toFixed(1)}k
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AiButton
|
||||
ccy={ccy}
|
||||
weekFrom={state.weekFrom}
|
||||
weekTo={state.weekTo}
|
||||
pct={entry?.pct ?? "—"}
|
||||
cotHistory={cotHistory}
|
||||
onResult={v => updCcy(ccy, "analysis", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Analyse */}
|
||||
<Field value={entry?.analysis ?? ""} onChange={v => updCcy(ccy, "analysis", v)}
|
||||
multiline
|
||||
className="text-slate-300 text-xs leading-relaxed"
|
||||
placeholder={`Analyse ${ccy} — cliquer ✨ pour générer avec l'IA, ou rédiger manuellement…`} />
|
||||
|
||||
{/* Niveau clé */}
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-white/[0.05]">
|
||||
<span className="text-slate-600 text-[10px] shrink-0">Niveau clé :</span>
|
||||
<Field value={entry?.level ?? ""} onChange={v => updCcy(ccy, "level", v)}
|
||||
className="text-sky-400 text-[11px] font-mono" placeholder="ex: 1.1700 résistance…" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-slate-700 text-[9px] text-center pt-4">{state.weekLabel} · {state.author}</p>
|
||||
</div>
|
||||
|
||||
{/* ── PAGE 3 : GRAPHIQUES TRADINGVIEW ─────────────────────────────── */}
|
||||
{showCharts && (
|
||||
<div className="report-page bg-[#080c14] min-h-[297mm] p-10 space-y-6">
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Vue d'ensemble · Marchés Globaux</h2>
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
</div>
|
||||
|
||||
{/* Macro overview : S&P, VIX, DXY, US10Y */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TvAdvancedChart symbol="SP:SPX" label="S&P 500 · Weekly" interval="W" height={220} />
|
||||
<TvAdvancedChart symbol="TVC:VIX" label="VIX · Daily" interval="D" height={220} />
|
||||
<TvAdvancedChart symbol="TVC:DXY" label="DXY Dollar Index · Weekly" interval="W" height={220} />
|
||||
<TvAdvancedChart symbol="TVC:US10Y" label="US 10Y Yield · Weekly" interval="W" height={220} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Currency Charts · G10 Weekly</h2>
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
</div>
|
||||
|
||||
{/* 8 currency mini charts */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ sym: "TVC:DXY", label: "🇺🇸 USD · DXY" },
|
||||
{ sym: "FX:EURUSD", label: "🇪🇺 EUR/USD" },
|
||||
{ sym: "FX:GBPUSD", label: "🇬🇧 GBP/USD" },
|
||||
{ sym: "FX:USDJPY", label: "🇯🇵 USD/JPY" },
|
||||
{ sym: "FX:USDCHF", label: "🇨🇭 USD/CHF" },
|
||||
{ sym: "FX:USDCAD", label: "🇨🇦 USD/CAD" },
|
||||
{ sym: "FX:AUDUSD", label: "🇦🇺 AUD/USD" },
|
||||
{ sym: "FX:NZDUSD", label: "🇳🇿 NZD/USD" },
|
||||
].map(({ sym, label }) => (
|
||||
<TvMiniChart key={sym} symbol={sym} label={label} height={160} showInfo={false} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-slate-700 text-[9px] text-center pt-2">
|
||||
{state.weekLabel} · {state.author} · Sources TradingView
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PAGE 4 : CALENDRIER + NOTES ─────────────────────────────────── */}
|
||||
{calDays.length > 0 && (
|
||||
<div className="report-page bg-[#080c14] min-h-[297mm] p-10 space-y-5">
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Calendrier Économique · Semaine à Venir</h2>
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
</div>
|
||||
|
||||
{calDays.map(day => (
|
||||
<div key={day} className="space-y-1">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="w-1 h-4 rounded-full bg-sky-500" />
|
||||
<span className="text-sky-300 text-[11px] font-bold uppercase tracking-wider">
|
||||
{new Date(day + "T12:00:00").toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-3 rounded-lg overflow-hidden border border-white/[0.05]">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{calByDay[day].map(ev => {
|
||||
const time = new Date(ev.date).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||
const dot = ev.impact === "high" ? "bg-red-500" : ev.impact === "medium" ? "bg-amber-400" : "bg-slate-600";
|
||||
const meta = CURRENCY_META[ev.currency as keyof typeof CURRENCY_META];
|
||||
return (
|
||||
<tr key={ev.id} className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.02]">
|
||||
<td className="py-1.5 px-3 text-[11px] text-slate-500 tabular-nums w-14">{time}</td>
|
||||
<td className="py-1.5 px-2 w-12">
|
||||
<span className="text-xs font-bold text-slate-300">{meta?.flag} {ev.currency}</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-5 text-center">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${dot}`} />
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-[11px] text-slate-300">{ev.title}</td>
|
||||
<td className="py-1.5 px-3 text-[10px] text-slate-500 text-right tabular-nums">{ev.previous ?? "—"}</td>
|
||||
<td className="py-1.5 px-3 text-[10px] text-sky-400 text-right font-medium tabular-nums">{ev.forecast ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Notes éditables */}
|
||||
<div className="mt-4 p-4 rounded-xl bg-[#0f1623] border border-white/[0.05] space-y-2">
|
||||
<p className="text-sky-400 text-[10px] font-bold uppercase tracking-wider">Points d'attention pour la semaine à venir</p>
|
||||
<Field value={state.notes} onChange={v => upd({ notes: v })} multiline
|
||||
className="text-slate-300 text-xs leading-relaxed"
|
||||
placeholder="Thèmes clés, banques centrales à surveiller, niveaux importants, risques géopolitiques…" />
|
||||
</div>
|
||||
|
||||
<p className="text-slate-700 text-[9px] text-center pt-4">{state.weekLabel} · {state.author}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── CSS print ─────────────────────────────────────────────────────── */}
|
||||
<style>{`
|
||||
@media print {
|
||||
body > * { visibility: hidden !important; }
|
||||
.report-root, .report-root * { visibility: visible !important; }
|
||||
.report-root { position: fixed; inset: 0; overflow: visible; }
|
||||
.no-print { display: none !important; }
|
||||
.report-page { page-break-after: always; min-height: 100vh; }
|
||||
@page { size: A4; margin: 0; }
|
||||
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
||||
input, textarea { border: none !important; padding: 0 !important; }
|
||||
}
|
||||
.print-only { display: none; }
|
||||
@media print { .print-only { display: inline !important; } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CURRENCY_META } from "@/lib/constants";
|
||||
import type { Currency } from "@/lib/types";
|
||||
|
||||
interface MyfxSymbol {
|
||||
name: string;
|
||||
name: string;
|
||||
longPercentage: number;
|
||||
shortPercentage: number;
|
||||
shortPercentage:number;
|
||||
longVolume: number;
|
||||
shortVolume: number;
|
||||
longPositions: number;
|
||||
shortPositions: number;
|
||||
totalPositions: number;
|
||||
avgLongPrice?: number;
|
||||
avgShortPrice?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
symbols: MyfxSymbol[] | null;
|
||||
}
|
||||
|
||||
// Toutes les 28 combinaisons (C(8,2)) des 8 devises — base/quote dans l'ordre standard Forex
|
||||
const PAIRS: { base: Currency; quote: Currency; std: string }[] = [
|
||||
// Majeures USD
|
||||
{ base: "EUR", quote: "USD", std: "EURUSD" },
|
||||
{ base: "GBP", quote: "USD", std: "GBPUSD" },
|
||||
{ base: "USD", quote: "JPY", std: "USDJPY" },
|
||||
@@ -24,146 +29,278 @@ const PAIRS: { base: Currency; quote: Currency; std: string }[] = [
|
||||
{ base: "USD", quote: "CAD", std: "USDCAD" },
|
||||
{ base: "AUD", quote: "USD", std: "AUDUSD" },
|
||||
{ base: "NZD", quote: "USD", std: "NZDUSD" },
|
||||
// Crosses EUR
|
||||
{ base: "EUR", quote: "GBP", std: "EURGBP" },
|
||||
{ base: "EUR", quote: "JPY", std: "EURJPY" },
|
||||
{ base: "EUR", quote: "CHF", std: "EURCHF" },
|
||||
{ base: "EUR", quote: "CAD", std: "EURCAD" },
|
||||
{ base: "EUR", quote: "AUD", std: "EURAUD" },
|
||||
{ base: "EUR", quote: "NZD", std: "EURNZD" },
|
||||
// Crosses GBP
|
||||
{ base: "GBP", quote: "JPY", std: "GBPJPY" },
|
||||
{ base: "GBP", quote: "CHF", std: "GBPCHF" },
|
||||
{ base: "GBP", quote: "CAD", std: "GBPCAD" },
|
||||
{ base: "GBP", quote: "AUD", std: "GBPAUD" },
|
||||
{ base: "GBP", quote: "NZD", std: "GBPNZD" },
|
||||
// Crosses AUD
|
||||
{ base: "AUD", quote: "JPY", std: "AUDJPY" },
|
||||
{ base: "AUD", quote: "CAD", std: "AUDCAD" },
|
||||
{ base: "AUD", quote: "CHF", std: "AUDCHF" },
|
||||
{ base: "AUD", quote: "NZD", std: "AUDNZD" },
|
||||
// Crosses CAD
|
||||
{ base: "CAD", quote: "JPY", std: "CADJPY" },
|
||||
// Crosses CHF
|
||||
{ base: "CHF", quote: "JPY", std: "CHFJPY" },
|
||||
// Crosses NZD
|
||||
{ base: "NZD", quote: "JPY", std: "NZDJPY" },
|
||||
{ base: "NZD", quote: "CAD", std: "NZDCAD" },
|
||||
{ base: "NZD", quote: "CHF", std: "NZDCHF" },
|
||||
// Croisée manquante CAD/CHF
|
||||
{ base: "CAD", quote: "CHF", std: "CADCHF" },
|
||||
];
|
||||
|
||||
// Groupes pour l'affichage
|
||||
const GROUPS = [
|
||||
{ label: "Majeures USD", pairs: ["EURUSD","GBPUSD","USDJPY","USDCHF","USDCAD","AUDUSD","NZDUSD"] },
|
||||
{ label: "Crosses EUR", pairs: ["EURGBP","EURJPY","EURCHF","EURCAD","EURAUD","EURNZD"] },
|
||||
{ label: "Crosses GBP", pairs: ["GBPJPY","GBPCHF","GBPCAD","GBPAUD","GBPNZD"] },
|
||||
{ label: "Crosses AUD/NZD",pairs: ["AUDJPY","AUDCAD","AUDCHF","AUDNZD","NZDJPY","NZDCAD","NZDCHF"] },
|
||||
{ label: "Crosses CAD/CHF",pairs: ["CADJPY","CHFJPY","CADCHF"] },
|
||||
{ label: "Majeures USD", pairs: ["EURUSD","GBPUSD","USDJPY","USDCHF","USDCAD","AUDUSD","NZDUSD"] },
|
||||
{ label: "Crosses EUR", pairs: ["EURGBP","EURJPY","EURCHF","EURCAD","EURAUD","EURNZD"] },
|
||||
{ label: "Crosses GBP", pairs: ["GBPJPY","GBPCHF","GBPCAD","GBPAUD","GBPNZD"] },
|
||||
{ label: "Crosses AUD/NZD", pairs: ["AUDJPY","AUDCAD","AUDCHF","AUDNZD","NZDJPY","NZDCAD","NZDCHF"] },
|
||||
{ label: "Crosses CAD/CHF", pairs: ["CADJPY","CHFJPY","CADCHF"] },
|
||||
];
|
||||
|
||||
function SentimentBar({ longPct }: { longPct: number }) {
|
||||
const isContrarian = longPct >= 70 || longPct <= 30;
|
||||
function fmtVol(v: number): string {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
|
||||
return v.toFixed(0);
|
||||
}
|
||||
|
||||
function fmtPos(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ── Barre Long/Short % ────────────────────────────────────────────────────────
|
||||
function PctBar({ longPct }: { longPct: number }) {
|
||||
const extreme = longPct >= 70 || longPct <= 30;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-[120px]">
|
||||
<span className={`text-[10px] tabular-nums font-medium w-8 text-right ${isContrarian ? "text-amber-600 font-bold" : "text-green-600"}`}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] tabular-nums font-semibold w-8 text-right ${extreme ? "text-amber-400" : "text-emerald-400"}`}>
|
||||
{longPct}%
|
||||
</span>
|
||||
<div className="flex h-2 w-20 rounded-full overflow-hidden">
|
||||
<div className="bg-green-400 transition-all" style={{ width: `${longPct}%` }} />
|
||||
<div className="bg-red-400 flex-1" />
|
||||
<div className="relative flex h-2.5 w-24 rounded-full overflow-hidden bg-slate-700">
|
||||
<div
|
||||
className={`h-full transition-all rounded-full ${extreme ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${longPct}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex">
|
||||
<div style={{ width: `${longPct}%` }} />
|
||||
<div className="flex-1 bg-red-500/70 rounded-r-full" />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] tabular-nums font-medium w-8 ${isContrarian ? "text-amber-600 font-bold" : "text-red-500"}`}>
|
||||
<span className={`text-[11px] tabular-nums font-semibold w-8 ${extreme ? "text-amber-400" : "text-red-400"}`}>
|
||||
{100 - longPct}%
|
||||
</span>
|
||||
{isContrarian && (
|
||||
<span className="text-[9px] text-amber-500 font-semibold">⚠</span>
|
||||
)}
|
||||
{extreme && <span className="text-amber-400 text-[11px] font-bold" title="Signal contrarien">⚡</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Barre Volume ──────────────────────────────────────────────────────────────
|
||||
function VolBar({ longVol, shortVol }: { longVol: number; shortVol: number }) {
|
||||
const total = longVol + shortVol || 1;
|
||||
const longPct = Math.round((longVol / total) * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] tabular-nums text-emerald-400 w-10 text-right">{fmtVol(longVol)}</span>
|
||||
<div className="relative flex h-1.5 w-20 rounded-full overflow-hidden bg-slate-700">
|
||||
<div className="h-full bg-emerald-500/70" style={{ width: `${longPct}%` }} />
|
||||
<div className="flex-1 bg-red-500/60" />
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums text-red-400 w-10">{fmtVol(shortVol)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row ───────────────────────────────────────────────────────────────────────
|
||||
function PairRow({ pairName, sym, base, quote, showVol }: {
|
||||
pairName: string;
|
||||
sym: MyfxSymbol | undefined;
|
||||
base: Currency;
|
||||
quote: Currency;
|
||||
showVol: boolean;
|
||||
}) {
|
||||
const baseMeta = CURRENCY_META[base];
|
||||
const quoteMeta = CURRENCY_META[quote];
|
||||
const extreme = sym && (sym.longPercentage >= 70 || sym.longPercentage <= 30);
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-slate-800/50 hover:bg-slate-800/30 transition-colors ${extreme ? "bg-amber-500/5" : ""}`}>
|
||||
{/* Paire */}
|
||||
<td className="py-2.5 px-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-base leading-none">{baseMeta?.flag}</span>
|
||||
<span className="text-base leading-none">{quoteMeta?.flag}</span>
|
||||
<span className={`text-xs font-bold ${extreme ? "text-amber-300" : "text-slate-200"}`}>{pairName}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* % Long/Short */}
|
||||
<td className="py-2.5 px-3">
|
||||
{sym
|
||||
? <PctBar longPct={sym.longPercentage} />
|
||||
: <span className="text-[10px] text-slate-700 italic">N/D</span>}
|
||||
</td>
|
||||
|
||||
{/* Volume lots */}
|
||||
{showVol && (
|
||||
<td className="py-2.5 px-3">
|
||||
{sym
|
||||
? <VolBar longVol={sym.longVolume} shortVol={sym.shortVolume} />
|
||||
: <span className="text-[10px] text-slate-700">—</span>}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Positions (traders) */}
|
||||
<td className="py-2.5 px-3 whitespace-nowrap">
|
||||
{sym ? (
|
||||
<div className="flex items-center gap-1 text-[10px] tabular-nums">
|
||||
<span className="text-emerald-400">{fmtPos(sym.longPositions)}</span>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-red-400">{fmtPos(sym.shortPositions)}</span>
|
||||
</div>
|
||||
) : <span className="text-slate-700">—</span>}
|
||||
</td>
|
||||
|
||||
{/* Prix moy. */}
|
||||
<td className="py-2.5 px-3 text-right hidden lg:table-cell">
|
||||
{sym?.avgLongPrice ? (
|
||||
<div className="text-[9px] tabular-nums space-y-0.5">
|
||||
<div className="text-emerald-400/70">{sym.avgLongPrice.toFixed(4)}</div>
|
||||
<div className="text-red-400/70">{sym.avgShortPrice?.toFixed(4) ?? "—"}</div>
|
||||
</div>
|
||||
) : <span className="text-slate-700 text-[10px]">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
export default function SentimentPairsTab({ symbols }: Props) {
|
||||
const [showVol, setShowVol] = useState(true);
|
||||
|
||||
const symMap: Record<string, MyfxSymbol> = {};
|
||||
for (const s of symbols ?? []) symMap[s.name] = s;
|
||||
|
||||
const pairMap: Record<string, { base: Currency; quote: Currency }> = {};
|
||||
for (const p of PAIRS) pairMap[p.std] = { base: p.base, quote: p.quote };
|
||||
|
||||
// Paires avec signal contrarien pour le résumé
|
||||
const contrarians = PAIRS
|
||||
.map(p => ({ ...p, sym: symMap[p.std] }))
|
||||
.filter(p => p.sym && (p.sym.longPercentage >= 70 || p.sym.longPercentage <= 30))
|
||||
.sort((a, b) => {
|
||||
const scoreA = Math.abs((a.sym!.longPercentage) - 50);
|
||||
const scoreB = Math.abs((b.sym!.longPercentage) - 50);
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<h2 className="text-sm font-semibold text-gray-800">Sentiment retail — toutes les paires</h2>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
Source : Myfxbook Community Outlook · {symbols ? `${Object.keys(symMap).length} paires disponibles` : "chargement…"}
|
||||
· Long = retail haussier sur la devise de base · ⚠ = signal contrarien (>70% ou <30%)
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Sentiment Retail — Myfxbook Community Outlook
|
||||
</h2>
|
||||
<p className="text-[11px] text-slate-600 mt-0.5">
|
||||
{symbols ? `${Object.keys(symMap).length} paires` : "chargement…"}
|
||||
{" "}· Long % = traders retail haussiers sur la devise de base
|
||||
{" "}· ⚡ signal contrarien (>70% ou <30%)
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[11px] text-slate-500 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVol}
|
||||
onChange={e => setShowVol(e.target.checked)}
|
||||
className="w-3 h-3 accent-amber-500"
|
||||
/>
|
||||
Afficher volumes (lots)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{GROUPS.map((group) => (
|
||||
{/* Résumé signaux contrarien */}
|
||||
{contrarians.length > 0 && (
|
||||
<div className="bg-amber-500/8 border border-amber-500/20 rounded-xl p-3">
|
||||
<p className="text-[10px] font-semibold text-amber-400 uppercase tracking-wider mb-2">
|
||||
⚡ {contrarians.length} signal{contrarians.length > 1 ? "s" : ""} contrarien{contrarians.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{contrarians.slice(0, 8).map(p => {
|
||||
const dir = p.sym!.longPercentage >= 70 ? "short" : "long";
|
||||
return (
|
||||
<div key={p.std} className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-[11px] font-medium ${
|
||||
dir === "short"
|
||||
? "bg-red-500/10 border-red-500/20 text-red-300"
|
||||
: "bg-emerald-500/10 border-emerald-500/20 text-emerald-300"
|
||||
}`}>
|
||||
<span>{CURRENCY_META[p.base]?.flag}{CURRENCY_META[p.quote]?.flag}</span>
|
||||
<span className="font-bold">{p.std}</span>
|
||||
<span className="text-[10px] opacity-70">
|
||||
{p.sym!.longPercentage}%L → signal {dir === "short" ? "↓ SELL" : "↑ BUY"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tables par groupe */}
|
||||
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
|
||||
{GROUPS.map((group, gi) => (
|
||||
<div key={group.label}>
|
||||
{/* Group header */}
|
||||
<div className="px-4 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-[9px] font-semibold text-gray-500 uppercase tracking-wider">{group.label}</span>
|
||||
<div className={`px-4 py-2 border-b border-slate-800 ${gi > 0 ? "border-t border-t-slate-700" : ""} bg-slate-900/60`}>
|
||||
<span className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
|
||||
<table className="w-full min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="text-[9px] text-gray-400 uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="py-1.5 px-4 text-left w-32">Paire</th>
|
||||
<th className="py-1.5 px-4 text-left">Long L / Short S</th>
|
||||
<th className="py-1.5 px-4 text-right w-28">Positions totales</th>
|
||||
<tr className="text-[9px] text-slate-600 uppercase tracking-wider border-b border-slate-800/60">
|
||||
<th className="py-1.5 px-3 text-left w-32">Paire</th>
|
||||
<th className="py-1.5 px-3 text-left">% Long / Short (retail)</th>
|
||||
{showVol && <th className="py-1.5 px-3 text-left">Volume lots (L / S)</th>}
|
||||
<th className="py-1.5 px-3 text-left">Traders (L / S)</th>
|
||||
<th className="py-1.5 px-3 text-right hidden lg:table-cell">Prix moy. entrée</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.pairs.map((pairName) => {
|
||||
{group.pairs.map(pairName => {
|
||||
const def = pairMap[pairName];
|
||||
const sym = symMap[pairName];
|
||||
const baseMeta = def ? CURRENCY_META[def.base] : null;
|
||||
const quoteMeta = def ? CURRENCY_META[def.quote] : null;
|
||||
|
||||
return (
|
||||
<tr key={pairName} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors">
|
||||
{/* Pair name */}
|
||||
<td className="py-2 px-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm leading-none">{baseMeta?.flag}</span>
|
||||
<span className="text-sm leading-none">{quoteMeta?.flag}</span>
|
||||
<span className="text-xs font-semibold text-gray-800">{pairName}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Sentiment bar */}
|
||||
<td className="py-2 px-4">
|
||||
{sym ? (
|
||||
<SentimentBar longPct={sym.longPercentage} />
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-300 italic">Non disponible sur Myfxbook</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Total positions */}
|
||||
<td className="py-2 px-4 text-right">
|
||||
{sym ? (
|
||||
<span className="text-[10px] text-gray-500 tabular-nums">
|
||||
{sym.totalPositions.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-200">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<PairRow
|
||||
key={pairName}
|
||||
pairName={pairName}
|
||||
sym={symMap[pairName]}
|
||||
base={def?.base ?? "USD"}
|
||||
quote={def?.quote ?? "EUR"}
|
||||
showVol={showVol}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400">
|
||||
Long % = % des positions retail haussières sur la devise de base de la paire · Données Myfxbook Community Outlook
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2.5 border-t border-slate-800 text-[10px] text-slate-600 flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" /> Long
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 inline-block" /> Short
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-amber-400">⚡</span> Contrarien (>70% ou <30%)
|
||||
</span>
|
||||
<span className="ml-auto hidden sm:inline">Source : Myfxbook Community Outlook · ~50k traders retail trackés</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useId } from "react";
|
||||
|
||||
// Déclaration globale TradingView (chargé via script)
|
||||
declare global {
|
||||
interface Window {
|
||||
TradingView?: {
|
||||
MiniSymbolOverview: new (config: Record<string, unknown>) => void;
|
||||
widget: new (config: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface TvMiniChartProps {
|
||||
symbol: string; // ex: "FX:EURUSD", "TVC:DXY", "SP:SPX"
|
||||
label?: string; // titre affiché au-dessus
|
||||
interval?: "W" | "D" | "M";
|
||||
height?: number;
|
||||
showInfo?: boolean; // afficher nom + prix sous le graphique
|
||||
}
|
||||
|
||||
// Script TradingView chargé une seule fois
|
||||
let scriptLoaded = false;
|
||||
let scriptLoading = false;
|
||||
const onLoadCallbacks: (() => void)[] = [];
|
||||
|
||||
function loadTvScript(cb: () => void) {
|
||||
if (scriptLoaded) { cb(); return; }
|
||||
onLoadCallbacks.push(cb);
|
||||
if (scriptLoading) return;
|
||||
scriptLoading = true;
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://s3.tradingview.com/tv.js";
|
||||
s.async = true;
|
||||
s.onload = () => {
|
||||
scriptLoaded = true;
|
||||
onLoadCallbacks.forEach(f => f());
|
||||
onLoadCallbacks.length = 0;
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
export function TvMiniChart({ symbol, label, height = 180, showInfo = true }: TvMiniChartProps) {
|
||||
const uid = useId().replace(/:/g, "_");
|
||||
const id = `tv_mini_${uid}`;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const init = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (init.current) return;
|
||||
init.current = true;
|
||||
|
||||
loadTvScript(() => {
|
||||
if (!window.TradingView || !ref.current) return;
|
||||
try {
|
||||
new window.TradingView.MiniSymbolOverview({
|
||||
symbol,
|
||||
container_id: id,
|
||||
width: "100%",
|
||||
height,
|
||||
locale: "fr",
|
||||
dateRange: "1M",
|
||||
colorTheme: "dark",
|
||||
trendLineColor: "#38bdf8",
|
||||
underLineColor: "rgba(56,189,248,0.08)",
|
||||
underLineBottomColor: "rgba(56,189,248,0)",
|
||||
isTransparent: true,
|
||||
autosize: false,
|
||||
largeChartUrl: "",
|
||||
noTimeScale: false,
|
||||
chartOnly: !showInfo,
|
||||
});
|
||||
} catch { /* TradingView indisponible */ }
|
||||
});
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">{label}</p>}
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="rounded-lg overflow-hidden bg-[#0f1623]"
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vue avancée plein format (pour la page graphiques) ──────────────────────
|
||||
|
||||
interface TvAdvancedChartProps {
|
||||
symbol: string;
|
||||
label?: string;
|
||||
interval?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function TvAdvancedChart({ symbol, label, interval = "W", height = 250 }: TvAdvancedChartProps) {
|
||||
const uid = useId().replace(/:/g, "_");
|
||||
const id = `tv_adv_${uid}`;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const init = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (init.current) return;
|
||||
init.current = true;
|
||||
|
||||
loadTvScript(() => {
|
||||
if (!window.TradingView || !ref.current) return;
|
||||
try {
|
||||
new window.TradingView.widget({
|
||||
autosize: false,
|
||||
width: "100%",
|
||||
height,
|
||||
symbol,
|
||||
interval,
|
||||
timezone: "Europe/Paris",
|
||||
theme: "dark",
|
||||
style: "1",
|
||||
locale: "fr",
|
||||
toolbar_bg: "#0f1623",
|
||||
enable_publishing: false,
|
||||
hide_top_toolbar: true,
|
||||
hide_legend: false,
|
||||
save_image: false,
|
||||
container_id: id,
|
||||
backgroundColor: "rgba(8,12,20,0)",
|
||||
gridColor: "rgba(30,45,61,0.5)",
|
||||
hide_volume: false,
|
||||
studies: [],
|
||||
});
|
||||
} catch { /* TradingView indisponible */ }
|
||||
});
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">{label}</p>}
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="rounded-lg overflow-hidden bg-[#0f1623] border border-white/[0.05]"
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user